fix(#3402): капча = «Captcha…» и «Вы не робот?» (бан+ротация); «Ошибка - Циан» — только лог на цикл наблюдения
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m8s
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 1m27s

Ревью (⚠️ minor) на #3403: «Ошибка - Циан» может быть транзиентной 5xx-страницей,
отданной с кодом 200, а не отказом конкретному узлу. Цена ошибки несимметрична —
mark_banned эскалирует TTL до часов, поэтому 20-минутный сбой площадки выбил бы из
выдачи весь пул. Один список маркеров этого различить не мог: и капча, и страница
ошибки шли одним путём в BanPageDetectedError.

Проба прода 06.09.2026 09:25 UTC (одна карточка по узлам через сайдкар):
* узел 14, час назад отдававший «Captcha - база объявлений ЦИАН», вернул НАСТОЯЩУЮ
  карточку — капча снимается за 1-2 часа, то есть TTL бана по назначению;
* узел 1 отдал ТРЕТИЙ вариант отказа — `<title>Вы не робот?`, 16 КБ (час назад —
  «Ошибка - Циан», 374 КБ). Прежние маркеры его не знали вовсе: отказ уезжал наверх
  как валидный HTML ровно так же, как до #3402.

Маркеры разделены на два класса, одинаково в обоих слоях (образы backend и browser
деплоятся раздельно и расходятся на часы):
* КАПЧА — «captcha - база объявлений циан» + «вы не робот?»: безусловный отказ
  площадки, прежний путь (сайдкар → BanPageDetectedError → 403 + ban_page; kit →
  report_platform_ban + CianBlockedError). За ней нет контента, и узел, которому её
  показали, будет получать её дальше;
* «ошибка - циан» — ТОЛЬКО ЛОГ: сайдкар отдаёт HTML клиенту как есть и пишет WARNING
  «страница ошибки Циана (title=…, upstream=…) — не бан, только лог (#3402)», kit при
  провале extract_state пишет WARNING и возвращает прежний None. Ни бана, ни рапорта,
  ни исключения — решение принимаем по частоте в логах за цикл наблюдения, а не по
  догадке о природе страницы.

Нормализация заголовка прежняя (регистр/пробелы/тире). `_is_cian_refusal` →
`_is_cian_captcha` + `_log_cian_error_page`; `_refusal_title` → `_page_title` и два
кортежа маркеров рядом.

Фальсификация: «вы не робот?» убран из маркеров обоих слоёв → kit 1 failed
(«DID NOT RAISE CianBlockedError»), сайдкар 3 failed («DID NOT RAISE
BanPageDetectedError», `_is_cian_captcha` → assert False is True). Маркер возвращён,
обе сьюты зелёные: backend 5599 passed / 35 skipped, browser 246 passed.
This commit is contained in:
bot-backend 2026-09-06 14:55:45 +05:00
parent ff979d4afd
commit 957e9e8c01
4 changed files with 230 additions and 72 deletions

View file

@ -1,26 +1,37 @@
"""#3402: капча Циана приходит с HTTP 200 — это отказ площадки, а не «не разобрали». """#3402: капча Циана приходит с HTTP 200 — это отказ площадки, а не «не разобрали».
Замер прода 06.09.2026: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`, Замер прода 06.09.2026: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`,
44 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом **200**. Детектор 44 КБ; `<title>Вы не робот?`, 16 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 374 КБ)
сайдкара их не знал (`_REFUSAL_STATUSES` = {403,429}, маркеры сняты с Авито/Домклика), с кодом **200**. Детектор сайдкара их не знал (`_REFUSAL_STATUSES` = {403,429}, маркеры
HTML уезжал клиенту как успех, `extract_state` возвращал None и провайдер печатал сняты с Авито/Домклика), HTML уезжал клиенту как успех, `extract_state` возвращал None
«defaultState extraction failed» то есть отказ ПЛОЩАДКИ читался как дрейф НАШЕЙ и провайдер печатал «defaultState extraction failed» то есть отказ ПЛОЩАДКИ читался
разметки. Аренда не менялась: `fetch()` уже отрапортовал `mark_health(ok=True)` как дрейф НАШЕЙ разметки. Аренда не менялась: `fetch()` уже отрапортовал
(HTTP-уровень успешен), fail-streak обнулялся, и один капча-узел сжигал весь батч `mark_health(ok=True)` (HTTP-уровень успешен), fail-streak обнулялся, и один капча-узел
прогоны 6200: 0/210; 6123/6091/6052/6032/6010/5981: 0/400 при 161/162 через здоровый сжигал весь батч прогоны 6200: 0/210; 6123/6091/6052/6032/6010/5981: 0/400 при 161/162
узел на прогоне 13. через здоровый узел на прогоне 13.
ДВА КЛАССА, а не один (проба прода 06.09.2026 09:25 UTC одна карточка по узлам):
* КАПЧА («Captcha - база объявлений ЦИАН», «Вы не робот?») безусловный отказ
площадки: `report_platform_ban` + `CianBlockedError`. Снимается за 1-2 часа (узел 14,
час назад отдававший капчу, вернул настоящую карточку) TTL бана по назначению;
* «Ошибка - Циан» ТОЛЬКО ЛОГ, прежний `None`. Природа не доказана: страница может
быть транзиентной 5xx-заглушкой под кодом 200, а не отказом конкретному узлу (узел 1
за час сменил её на «Вы не робот?»). `mark_banned` эскалирует TTL до часов по этой
догадке 20-минутный сбой Циана выбил бы из выдачи весь пул. Решение по частоте в
логах за цикл наблюдения.
Этот файл покрывает ВТОРОЙ слой (kit) он нужен потому, что образы backend и browser Этот файл покрывает ВТОРОЙ слой (kit) он нужен потому, что образы backend и browser
деплоятся раздельно и расходятся на часы: в это окно сайдкар ещё отдаёт 200 с капчей. деплоятся раздельно и расходятся на часы: в это окно сайдкар ещё отдаёт 200 с капчей.
Первый слой (сайдкар: 403 + ban_page) `browser/test_server_cian_captcha.py`. Первый слой (сайдкар: 403 + ban_page) `browser/test_server_cian_captcha.py`.
Красное на main: Красное на main:
* `fetch_detail` на капче возвращал None и НЕ звал `report_platform_ban`; * `fetch_detail` на капче возвращала None и НЕ звала `report_platform_ban`;
* батч из двух объявлений давал 0 успехов второй листинг шёл через тот же узел. * батч из двух объявлений давал 0 успехов второй листинг шёл через тот же узел.
""" """
from __future__ import annotations from __future__ import annotations
import logging
import os import os
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@ -43,6 +54,11 @@ _CAPTCHA_HTML = (
"<body><div id='captcha'></div>" "<body><div id='captcha'></div>"
"<script>window.__captcha__ = {sitekey: 'x'};</script></body></html>" "<script>window.__captcha__ = {sitekey: 'x'};</script></body></html>"
) )
# Второй вариант капчи — тот же отказ, другая вёрстка (проба 06.09.2026, узел 1, 16 КБ).
_ROBOT_HTML = (
"<!DOCTYPE html><html lang='ru'><head><title>Вы не робот?</title></head>"
"<body><div id='captcha-container'></div></body></html>"
)
_ERROR_PAGE_HTML = ( _ERROR_PAGE_HTML = (
"<!DOCTYPE html><html lang='ru'><head><title>Ошибка - Циан</title></head>" "<!DOCTYPE html><html lang='ru'><head><title>Ошибка - Циан</title></head>"
"<body><h1>Что-то пошло не так</h1></body></html>" "<body><h1>Что-то пошло не так</h1></body></html>"
@ -97,20 +113,37 @@ class _FakeFetcher:
# ── Слой kit: капча → CianBlockedError + рапорт бана ────────────────────────── # ── Слой kit: капча → CianBlockedError + рапорт бана ──────────────────────────
@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ERROR_PAGE_HTML]) @pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML])
async def test_captcha_page_is_a_platform_refusal(html: str) -> None: async def test_captcha_page_is_a_platform_refusal(html: str) -> None:
"""HTTP 200 + капча/страница ошибки → CianBlockedError, а не тихий None.""" """HTTP 200 + капча (оба варианта вёрстки) → CianBlockedError, а не тихий None."""
fetcher = _FakeFetcher(refusal_html=html) fetcher = _FakeFetcher(refusal_html=html)
with pytest.raises(CianBlockedError) as exc_info: with pytest.raises(CianBlockedError) as exc_info:
await cian_detail.fetch_detail(_URL, browser_fetcher=fetcher) await cian_detail.fetch_detail(_URL, browser_fetcher=fetcher)
assert "капча/страница ошибки" in str(exc_info.value) assert "капча Циана" in str(exc_info.value)
# Рапорт бана — по ЖИВОМУ lease, внутри `async with BrowserFetcher(...)` caller'а. # Рапорт бана — по ЖИВОМУ lease, внутри `async with BrowserFetcher(...)` caller'а.
assert len(fetcher.ban_reports) == 1 assert len(fetcher.ban_reports) == 1
assert "cian detail" in fetcher.ban_reports[0] assert "cian detail" in fetcher.ban_reports[0]
async def test_error_page_is_logged_but_not_banned(caplog: pytest.LogCaptureFixture) -> None:
"""«Ошибка - Циан» → прежний None + WARNING; `report_platform_ban` НЕ зовётся.
Цикл наблюдения (#3402): страница может быть транзиентным сбоем площадки, а бан
эскалирует TTL до часов за 20-минутный сбой Циана пул вылетел бы из выдачи.
"""
fetcher = _FakeFetcher(refusal_html=_ERROR_PAGE_HTML)
with caplog.at_level(logging.WARNING):
result = await cian_detail.fetch_detail(_URL, browser_fetcher=fetcher)
assert result is None
assert fetcher.ban_reports == []
assert "страница ошибки Циана" in caplog.text
assert "не бан, только лог" in caplog.text
async def test_page_without_state_and_without_markers_still_returns_none() -> None: async def test_page_without_state_and_without_markers_still_returns_none() -> None:
"""Дрейф разметки (нет состояния, нет маркеров) — прежнее поведение, не бан.""" """Дрейф разметки (нет состояния, нет маркеров) — прежнее поведение, не бан."""
fetcher = _FakeFetcher(refusal_html=_NO_STATE_HTML) fetcher = _FakeFetcher(refusal_html=_NO_STATE_HTML)

View file

@ -1975,11 +1975,26 @@ def _is_domclick_refusal(html: str) -> bool:
# НОРМАЛЬНОЙ карточке (антифрод-скрипты), 17 — на странице капчи; как признак оно # НОРМАЛЬНОЙ карточке (антифрод-скрипты), 17 — на странице капчи; как признак оно
# неотличимо. Тире в заголовке нормализуется (— и → -): вёрстка Циана печатает # неотличимо. Тире в заголовке нормализуется (— и → -): вёрстка Циана печатает
# его по-разному, а различать заголовки по виду дефиса — заведомо хрупко. # его по-разному, а различать заголовки по виду дефиса — заведомо хрупко.
_CIAN_REFUSAL_TITLES: tuple[str, ...] = ( #
# КАПЧА — безусловный отказ площадки: страница требует пройти проверку, контента за
# ней нет, а узел, которому её показали, будет показывать её и дальше. Наверх идёт
# BanPageDetectedError → бан пары «узел×cian» + ротация аренды. Проба прода
# 06.09.2026 09:25 UTC по узлам: капча снимается за 1-2 часа (узел 14 через час
# отдавал уже настоящую карточку), то есть TTL бана по назначению.
_CIAN_CAPTCHA_TITLES: tuple[str, ...] = (
"captcha - база объявлений циан", "captcha - база объявлений циан",
"ошибка - циан", "вы не робот?",
) )
# «Ошибка - Циан» — НЕ бан (#3402, цикл наблюдения). Природа страницы не доказана:
# она может быть транзиентной 5xx-заглушкой, отданной с кодом 200, а не отказом
# конкретному узлу (та же проба 06.09.2026: узел 1 за час сменил «Ошибка - Циан»,
# 374 КБ, на «Вы не робот?», 16 КБ). Цена ошибочного бана несимметрична: mark_banned
# эскалирует TTL до часов, и 20-минутный сбой Циана выбил бы из выдачи весь пул.
# Поэтому здесь только WARNING — решение принимаем по частоте в логах, а не по
# догадке о причине.
_CIAN_ERROR_TITLES: tuple[str, ...] = ("ошибка - циан",)
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL) _TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
@ -1991,10 +2006,24 @@ def _page_title(html: str) -> str:
return " ".join(match.group(1).split()).lower().replace("", "-").replace("", "-") return " ".join(match.group(1).split()).lower().replace("", "-").replace("", "-")
def _is_cian_refusal(html: str) -> bool: def _is_cian_captcha(html: str) -> bool:
"""True, если HTML — капча или страница ошибки Циана (см. _CIAN_REFUSAL_TITLES).""" """True, если HTML — капча Циана (см. _CIAN_CAPTCHA_TITLES) — отказ площадки."""
title = _page_title(html) title = _page_title(html)
return any(marker in title for marker in _CIAN_REFUSAL_TITLES) return any(marker in title for marker in _CIAN_CAPTCHA_TITLES)
def _log_cian_error_page(html: str, url: str, status: int | None) -> None:
"""WARNING на «Ошибка - Циан» (см. _CIAN_ERROR_TITLES); ни бана, ни исключения."""
title = _page_title(html)
if not any(marker in title for marker in _CIAN_ERROR_TITLES):
return
logger.warning(
"tradein-browser[cian]: страница ошибки Циана (title=%r, upstream=%s) — "
"не бан, только лог (#3402) url=%r",
title,
status,
url,
)
# Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по # Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по
@ -2369,11 +2398,13 @@ async def _fetch_once(
raise BanPageDetectedError( raise BanPageDetectedError(
f"tradein-browser[{provider}]: статический отказ площадки url={url!r}" f"tradein-browser[{provider}]: статический отказ площадки url={url!r}"
) )
if provider == "cian" and _is_cian_refusal(text): if provider == "cian":
if _is_cian_captcha(text):
raise BanPageDetectedError( raise BanPageDetectedError(
f"tradein-browser[{provider}]: капча/страница ошибки Циана " f"tradein-browser[{provider}]: капча Циана "
f"(title={_page_title(text)!r}) url={url!r}" f"(title={_page_title(text)!r}) url={url!r}"
) )
_log_cian_error_page(text, url, status)
logger.info( logger.info(
"tradein-browser[%s]: %s → HTTP %s, тело %d Б url=%r", "tradein-browser[%s]: %s → HTTP %s, тело %d Б url=%r",
provider, provider,
@ -2409,14 +2440,17 @@ async def _fetch_once(
raise BanPageDetectedError( raise BanPageDetectedError(
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}" f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
) )
# Капча/страница ошибки Циана (#3402): приходит с HTTP 200, поэтому ни # Капча Циана (#3402): приходит с HTTP 200, поэтому ни _REFUSAL_STATUSES, ни
# _REFUSAL_STATUSES, ни маркеры Авито её не ловят — распознаём по <title> # маркеры Авито её не ловят — распознаём по <title> и отдаём наверх тем же
# и отдаём наверх тем же путём (403 + ban_page), что отказ DomClick. # путём (403 + ban_page), что отказ DomClick. «Ошибка - Циан» — НЕ бан, только
if provider == "cian" and _is_cian_refusal(html): # лог: см. _CIAN_ERROR_TITLES.
if provider == "cian":
if _is_cian_captcha(html):
raise BanPageDetectedError( raise BanPageDetectedError(
f"tradein-browser[{provider}]: капча/страница ошибки Циана " f"tradein-browser[{provider}]: капча Циана "
f"(title={_page_title(html)!r}) url={url!r}" f"(title={_page_title(html)!r}) url={url!r}"
) )
_log_cian_error_page(html, url, _last_response_status.get(provider))
if provider == "domclick": if provider == "domclick":
# DomClick — своя ветка (#3196): нет отдельного маркера самого # DomClick — своя ветка (#3196): нет отдельного маркера самого
# рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому # рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому

View file

@ -1,16 +1,25 @@
"""test_server_cian_captcha.py — капча Циана приходит с HTTP 200 (#3402). """test_server_cian_captcha.py — капча Циана приходит с HTTP 200 (#3402).
Замер прода 06.09.2026: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`, Замер прода 06.09.2026: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`,
44 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом **200**, поэтому 44 КБ; `<title>Вы не робот?`, 16 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 374 КБ)
_REFUSAL_STATUSES {403,429} их не видит, а маркеры Авито (_CHALLENGE_MARKERS / с кодом **200**, поэтому _REFUSAL_STATUSES {403,429} их не видит, а маркеры Авито
_BAN_MARKERS) в них не встречаются. HTML уезжал клиенту как успех, парсер не находил (_CHALLENGE_MARKERS / _BAN_MARKERS) в них не встречаются. HTML уезжал клиенту как успех,
defaultState, и cian_detail_backfill держал ОДНУ аренду на весь батч, сжигая через парсер не находил defaultState, и cian_detail_backfill держал ОДНУ аренду на весь батч,
капча-узел 210-400 карточек подряд (прогоны 6200: 0/210; 6123/6091/6052/6032/6010/5981: сжигая через капча-узел 210-400 карточек подряд (прогоны 6200: 0/210;
0/400 против 161/162 через здоровый узел на прогоне 13). 6123/6091/6052/6032/6010/5981: 0/400 против 161/162 через здоровый узел на прогоне 13).
Слово `captcha` как признак не годится: в НОРМАЛЬНОЙ карточке Циана оно встречается 11 Слово `captcha` как признак не годится: в НОРМАЛЬНОЙ карточке Циана оно встречается 11
раз (антифрод-скрипты), на странице капчи 17. Отсюда детект по <title>. раз (антифрод-скрипты), на странице капчи 17. Отсюда детект по <title>.
ДВА КЛАССА, а не один (проба прода 06.09.2026 09:25 UTC по узлам через сайдкар):
* КАПЧА («Captcha - база объявлений ЦИАН», «Вы не робот?») отказ площадки: бан пары
«узел×cian» + ротация аренды. Снимается за 1-2 часа (узел 14 через час отдавал уже
настоящую карточку), то есть TTL бана по назначению;
* «Ошибка - Циан» ТОЛЬКО ЛОГ. Природа не доказана: может быть транзиентной
5xx-заглушкой под кодом 200, а не отказом узлу (узел 1 за час сменил её на «Вы не
робот?»). Цена ошибки несимметрична mark_banned эскалирует TTL до часов, и
20-минутный сбой Циана выбил бы из выдачи весь пул. Решение по частоте в логах.
camoufox НЕ запускается: _browsers[provider] поддельный browser/page (зеркалит camoufox НЕ запускается: _browsers[provider] поддельный browser/page (зеркалит
test_server_http_status.py). wait_for_timeout на фейковой page no-op. test_server_http_status.py). wait_for_timeout на фейковой page no-op.
@ -24,6 +33,7 @@ from __future__ import annotations
import asyncio import asyncio
import importlib.util import importlib.util
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -62,6 +72,11 @@ _CAPTCHA_HTML = (
"<script>window.__captcha__ = {sitekey: 'x'};</script>" "<script>window.__captcha__ = {sitekey: 'x'};</script>"
"</body></html>" "</body></html>"
) )
# Второй вариант капчи — тот же отказ, другая вёрстка (проба 06.09.2026, узел 1, 16 КБ).
_ROBOT_HTML = (
"<!DOCTYPE html><html lang='ru'><head><title>Вы не робот?</title></head>"
"<body><div id='captcha-container'></div></body></html>"
)
_ERROR_PAGE_HTML = ( _ERROR_PAGE_HTML = (
"<!DOCTYPE html><html lang='ru'><head><title>Ошибка - Циан</title></head>" "<!DOCTYPE html><html lang='ru'><head><title>Ошибка - Циан</title></head>"
"<body><h1>Что-то пошло не так</h1></body></html>" "<body><h1>Что-то пошло не так</h1></body></html>"
@ -140,37 +155,66 @@ def _make_request(body: dict[str, Any]) -> Any:
return request return request
# ── Детектор: что считается отказом, а что нет ──────────────────────────────── # ── Детектор: что капча, что страница ошибки, что ни то ни другое ─────────────
@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ERROR_PAGE_HTML]) @pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML])
def test_cian_refusal_pages_are_recognised(html: str) -> None: def test_cian_captcha_pages_are_recognised(html: str) -> None:
assert server._is_cian_refusal(html) is True """Оба варианта капчи — отказ площадки (второй, «Вы не робот?», добавлен 06.09)."""
assert server._is_cian_captcha(html) is True
def test_error_page_is_not_a_captcha() -> None:
"""«Ошибка - Циан» баном НЕ считается — иначе 20-минутный сбой Циана выбивает пул."""
assert server._is_cian_captcha(_ERROR_PAGE_HTML) is False
def test_normal_card_with_the_word_captcha_is_not_a_refusal() -> None: def test_normal_card_with_the_word_captcha_is_not_a_refusal() -> None:
"""Слово `captcha` в теле нормальной карточки признаком отказа НЕ является.""" """Слово `captcha` в теле нормальной карточки признаком отказа НЕ является."""
assert "captcha" in _CARD_HTML.lower() assert "captcha" in _CARD_HTML.lower()
assert server._is_cian_refusal(_CARD_HTML) is False assert server._is_cian_captcha(_CARD_HTML) is False
def test_em_dash_in_title_is_normalised() -> None: def test_em_dash_in_title_is_normalised(caplog: pytest.LogCaptureFixture) -> None:
"""Вёрстка печатает тире по-разному — детект не должен зависеть от его вида.""" """Вёрстка печатает тире по-разному — детект не должен зависеть от его вида."""
assert server._is_cian_refusal("<title>Ошибка — Циан</title>") is True with caplog.at_level(logging.WARNING):
server._log_cian_error_page("<title>Ошибка — Циан</title>", "https://x", 200)
assert "страница ошибки Циана" in caplog.text
# ── /fetch: 403 + ban_page на капче, отданной с HTTP 200 ────────────────────── # ── /fetch: 403 + ban_page на капче, отданной с HTTP 200 ──────────────────────
def test_fetch_once_raises_on_cian_captcha(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML])
def test_fetch_once_raises_on_cian_captcha(monkeypatch: pytest.MonkeyPatch, html: str) -> None:
"""HTTP 200 + капча → BanPageDetectedError, а не «валидный HTML» наверх.""" """HTTP 200 + капча → BanPageDetectedError, а не «валидный HTML» наверх."""
page = _Page(_CAPTCHA_HTML) page = _Page(html)
_install(monkeypatch, page) _install(monkeypatch, page)
with pytest.raises(server.BanPageDetectedError): with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("cian", "https://ekb.cian.ru/sale/flat/1/")) asyncio.run(server._fetch_once("cian", "https://ekb.cian.ru/sale/flat/1/"))
def test_fetch_once_logs_error_page_without_banning(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""«Ошибка - Циан» → HTML уезжает наверх как есть + WARNING; исключения НЕТ.
Пока идёт цикл наблюдения (#3402): страница может быть транзиентным сбоем площадки,
а `mark_banned` эскалирует TTL до часов бан по догадке дороже пропущенного отказа.
"""
page = _Page(_ERROR_PAGE_HTML)
_install(monkeypatch, page)
with caplog.at_level(logging.WARNING):
html = asyncio.run(server._fetch_once("cian", "https://ekb.cian.ru/sale/flat/1/"))
assert html == _ERROR_PAGE_HTML
assert "страница ошибки Циана" in caplog.text
assert "не бан, только лог" in caplog.text
def test_fetch_once_passes_normal_card_through(monkeypatch: pytest.MonkeyPatch) -> None: def test_fetch_once_passes_normal_card_through(monkeypatch: pytest.MonkeyPatch) -> None:
"""Здоровая карточка (с тем же словом в теле) отдаётся как раньше.""" """Здоровая карточка (с тем же словом в теле) отдаётся как раньше."""
page = _Page(_CARD_HTML) page = _Page(_CARD_HTML)
@ -181,8 +225,10 @@ def test_fetch_once_passes_normal_card_through(monkeypatch: pytest.MonkeyPatch)
assert html == _CARD_HTML assert html == _CARD_HTML
@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML])
def test_fetch_handler_returns_403_with_ban_page_on_captcha( def test_fetch_handler_returns_403_with_ban_page_on_captcha(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
html: str,
) -> None: ) -> None:
"""Тот же путь, что #3379/#3288 п.4: 403 + ban_page + ЧЕСТНЫЙ upstream-статус 200. """Тот же путь, что #3379/#3288 п.4: 403 + ban_page + ЧЕСТНЫЙ upstream-статус 200.
@ -191,7 +237,7 @@ def test_fetch_handler_returns_403_with_ban_page_on_captcha(
бэкенда деплоятся врозь). бэкенда деплоятся врозь).
""" """
monkeypatch.setattr(server, "IS_PROD", False) monkeypatch.setattr(server, "IS_PROD", False)
page = _Page(_CAPTCHA_HTML) page = _Page(html)
_install(monkeypatch, page) _install(monkeypatch, page)
async def _ensure(provider: str, proxy_override: str | None = None) -> bool: async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
@ -212,6 +258,34 @@ def test_fetch_handler_returns_403_with_ban_page_on_captcha(
assert "BanPageDetectedError" in body["error"] assert "BanPageDetectedError" in body["error"]
def test_fetch_handler_returns_200_without_ban_page_on_error_page(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""«Ошибка - Циан» доезжает клиенту как обычный ответ: 200, без `ban_page`."""
monkeypatch.setattr(server, "IS_PROD", False)
page = _Page(_ERROR_PAGE_HTML)
_install(monkeypatch, page)
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
with caplog.at_level(logging.WARNING):
response = asyncio.run(
server.fetch_handler(
_make_request({"url": "https://ekb.cian.ru/sale/flat/1/", "source": "cian"})
)
)
body = _json_body(response)
assert response.status == 200
assert "ban_page" not in body
assert body["html"] == _ERROR_PAGE_HTML
assert "не бан, только лог" in caplog.text
def test_fetch_handler_does_not_ban_other_providers_on_the_same_html( def test_fetch_handler_does_not_ban_other_providers_on_the_same_html(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:

View file

@ -101,30 +101,41 @@ def _raise_if_blocked(offer_url: str, status_code: int) -> None:
# ── Капча/страница ошибки Циана с HTTP 200 (#3402) ────────────────────────────── # ── Капча/страница ошибки Циана с HTTP 200 (#3402) ──────────────────────────────
# Дубль детектора сайдкара (browser/server.py::_is_cian_refusal) — намеренный: # Дубль детектора сайдкара (browser/server.py::_is_cian_captcha) — намеренный:
# образы backend и browser деплоятся раздельно и расходятся на часы, а в этот # образы backend и browser деплоятся раздельно и расходятся на часы, а в этот
# промежуток отказ площадки приходит сюда ровно так же, как до правки — 200 + HTML # промежуток отказ площадки приходит сюда ровно так же, как до правки — 200 + HTML
# капчи. Опознаём по <title>, а не по слову "captcha": оно есть и в нормальной # капчи. Опознаём по <title>, а не по слову "captcha": оно есть и в нормальной
# карточке (11 вхождений против 17 на капче), то есть как признак не различает. # карточке (11 вхождений против 17 на капче), то есть как признак не различает.
_REFUSAL_TITLES: tuple[str, ...] = ( #
# КАПЧА — безусловный отказ площадки (бан пары «узел×cian» + ротация аренды): за ней
# нет контента, и узел, которому её показали, будет получать её дальше. Проба прода
# 06.09.2026 09:25 UTC по узлам: капча снимается за 1-2 часа, то есть TTL бана по
# назначению.
_CAPTCHA_TITLES: tuple[str, ...] = (
"captcha - база объявлений циан", "captcha - база объявлений циан",
"ошибка - циан", "вы не робот?",
) )
# «Ошибка - Циан» — НЕ бан (цикл наблюдения): природа страницы не доказана, она может
# быть транзиентной 5xx-заглушкой Циана под кодом 200, а не отказом конкретному узлу.
# Цена ошибки несимметрична — mark_banned эскалирует TTL до часов, и 20-минутный сбой
# площадки выбил бы из выдачи весь пул. Здесь только WARNING и прежний None; решение
# принимаем по частоте в логах.
_ERROR_PAGE_TITLES: tuple[str, ...] = ("ошибка - циан",)
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL) _TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
def _refusal_title(html: str) -> str | None: def _page_title(html: str) -> str:
"""Заголовок страницы, если это капча/ошибка Циана; иначе None. """Текст <title>: схлопнутые пробелы, нижний регистр, нормализованное тире.
Тире нормализуется ( и -): вёрстка печатает его по-разному, а различать Тире нормализуется ( и -): вёрстка печатает его по-разному, а различать
заголовки по виду дефиса заведомо хрупко. заголовки по виду дефиса заведомо хрупко.
""" """
match = _TITLE_RE.search(html) match = _TITLE_RE.search(html)
if match is None: if match is None:
return None return ""
title = " ".join(match.group(1).split()).lower().replace("", "-").replace("", "-") return " ".join(match.group(1).split()).lower().replace("", "-").replace("", "-")
return title if any(marker in title for marker in _REFUSAL_TITLES) else None
async def fetch_detail( async def fetch_detail(
@ -156,9 +167,11 @@ async def fetch_detail(
пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают
исключение в `errors_count`, а на own-session-пути оно дополнительно снимает исключение в `errors_count`, а на own-session-пути оно дополнительно снимает
узел с выдачи Циану через `curl_proxy_url`. Тем же исключением приезжает узел с выдачи Циану через `curl_proxy_url`. Тем же исключением приезжает
капча/страница ошибки Циана (#3402): её сайдкар отдаёт как `ban_page` + 403, КАПЧА Циана (#3402): её сайдкар отдаёт как `ban_page` + 403, а если образ
а если образ сайдкара старее она распознаётся здесь, по <title>, уже после сайдкара старее она распознаётся здесь, по <title>, уже после HTTP 200
HTTP 200 (`report_platform_ban` на живом lease + raise вместо тихого None). (`report_platform_ban` на живом lease + raise вместо тихого None). Страница
«Ошибка - Циан» баном НЕ считается WARNING и прежний None, см.
`_ERROR_PAGE_TITLES`.
NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а
не гасится в None: запрос не уходил, и следующий вызов упрётся в то же самое, не гасится в None: запрос не уходил, и следующий вызов упрётся в то же самое,
поэтому решение «оборвать батч» принимает вызывающий (#3197). поэтому решение «оборвать батч» принимает вызывающий (#3197).
@ -168,8 +181,8 @@ async def fetch_detail(
try: try:
html = await browser_fetcher.fetch(offer_url) html = await browser_fetcher.fetch(offer_url)
except SidecarBanPageError as exc: except SidecarBanPageError as exc:
# #3402: сайдкар опознал отказ площадки по маркерам тела (капча/страница # #3402: сайдкар опознал отказ площадки по маркерам тела (капча Циана,
# ошибки Циана, `ban_page` + 403). ПОРЯДОК ВЕТОК ВАЖЕН — SidecarBanPageError # `ban_page` + 403). ПОРЯДОК ВЕТОК ВАЖЕН — SidecarBanPageError
# подкласс httpx.HTTPStatusError, и общий `except` ниже увёл бы # подкласс httpx.HTTPStatusError, и общий `except` ниже увёл бы
# подтверждённый отказ Циана в `return None`, то есть в «не смогли # подтверждённый отказ Циана в `return None`, то есть в «не смогли
# разобрать». Зеркалит avito/detail.py и domclick/detail.py (#3283/#3239). # разобрать». Зеркалит avito/detail.py и domclick/detail.py (#3283/#3239).
@ -232,12 +245,13 @@ async def fetch_detail(
# NOTE: detail pages use 'defaultState', SERP uses 'initialState' # NOTE: detail pages use 'defaultState', SERP uses 'initialState'
offer_state = extract_state(html, mfe="frontend-offer-card", key="defaultState") offer_state = extract_state(html, mfe="frontend-offer-card", key="defaultState")
if offer_state is None: if offer_state is None:
# #3402: страницы БЕЗ состояния бывают двух разных родов, и до этой правки # #3402: страницы БЕЗ состояния бывают трёх разных родов, и до этой правки все
# обе печатались как «extraction failed» — то есть отказ площадки читался как # печатались как «extraction failed» — то есть отказ площадки читался как дрейф
# дрейф нашей разметки. Капча/страница ошибки Циана приходит с HTTP 200, # нашей разметки. Капча приходит с HTTP 200, диагностировать её по статусу
# диагностировать её по статусу (ban_kind_from_status) нечем — только по телу. # (ban_kind_from_status) нечем — только по телу. Три рода: капча (бан+ротация),
title = _refusal_title(html) # страница ошибки (только лог, природа не доказана), дрейф разметки (как было).
if title is not None: title = _page_title(html)
if any(marker in title for marker in _CAPTCHA_TITLES):
if browser_fetcher is not None: if browser_fetcher is not None:
# Детект НАШ, сайдкар его не видел (старый образ отдал 200) — рапорт # Детект НАШ, сайдкар его не видел (старый образ отдал 200) — рапорт
# обязателен здесь, иначе тот же узел доработает батч до конца. lease # обязателен здесь, иначе тот же узел доработает батч до конца. lease
@ -245,15 +259,18 @@ async def fetch_detail(
# report_platform_ban (а не report_ban): бан пары «узел×cian» ПЛЮС # report_platform_ban (а не report_ban): бан пары «узел×cian» ПЛЮС
# fail-streak, по которому аренда меняется ВНУТРИ батча — ровно то же, # fail-streak, по которому аренда меняется ВНУТРИ батча — ровно то же,
# что делает сайдкар-путь через SidecarBanPageError. # что делает сайдкар-путь через SidecarBanPageError.
browser_fetcher.report_platform_ban( browser_fetcher.report_platform_ban(f"cian detail: капча Циана для {offer_url}")
f"cian detail: капча/страница ошибки Циана для {offer_url}" logger.warning("Cian detail %s: капча Циана (title=%r)", offer_url, title)
) raise CianBlockedError(f"Cian detail: капча Циана (title={title!r}) для {offer_url}")
if any(marker in title for marker in _ERROR_PAGE_TITLES):
# Ни рапорта, ни исключения — см. _ERROR_PAGE_TITLES: пока это наблюдение,
# а не диагноз. Возврат прежний (None) — вызывающий считает «не разобрали».
logger.warning( logger.warning(
"Cian detail %s: капча/страница ошибки Циана (title=%r)", offer_url, title "Cian detail %s: страница ошибки Циана (title=%r) — не бан, только лог (#3402)",
) offer_url,
raise CianBlockedError( title,
f"Cian detail: капча/страница ошибки Циана (title={title!r}) для {offer_url}"
) )
return None
logger.warning("Cian detail %s: defaultState extraction failed", offer_url) logger.warning("Cian detail %s: defaultState extraction failed", offer_url)
return None return None