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
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:
parent
ff979d4afd
commit
957e9e8c01
4 changed files with 230 additions and 72 deletions
|
|
@ -1,26 +1,37 @@
|
|||
"""#3402: капча Циана приходит с HTTP 200 — это отказ площадки, а не «не разобрали».
|
||||
|
||||
Замер прода 06.09.2026: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`,
|
||||
44 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом **200**. Детектор
|
||||
сайдкара их не знал (`_REFUSAL_STATUSES` = {403,429}, маркеры сняты с Авито/Домклика),
|
||||
HTML уезжал клиенту как успех, `extract_state` возвращал None и провайдер печатал
|
||||
«defaultState extraction failed» — то есть отказ ПЛОЩАДКИ читался как дрейф НАШЕЙ
|
||||
разметки. Аренда не менялась: `fetch()` уже отрапортовал `mark_health(ok=True)`
|
||||
(HTTP-уровень успешен), fail-streak обнулялся, и один капча-узел сжигал весь батч —
|
||||
прогоны 6200: 0/210; 6123/6091/6052/6032/6010/5981: 0/400 при 161/162 через здоровый
|
||||
узел на прогоне 13.
|
||||
44 КБ; `<title>Вы не робот?`, 16 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 374 КБ)
|
||||
с кодом **200**. Детектор сайдкара их не знал (`_REFUSAL_STATUSES` = {403,429}, маркеры
|
||||
сняты с Авито/Домклика), HTML уезжал клиенту как успех, `extract_state` возвращал None
|
||||
и провайдер печатал «defaultState extraction failed» — то есть отказ ПЛОЩАДКИ читался
|
||||
как дрейф НАШЕЙ разметки. Аренда не менялась: `fetch()` уже отрапортовал
|
||||
`mark_health(ok=True)` (HTTP-уровень успешен), fail-streak обнулялся, и один капча-узел
|
||||
сжигал весь батч — прогоны 6200: 0/210; 6123/6091/6052/6032/6010/5981: 0/400 при 161/162
|
||||
через здоровый узел на прогоне 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
|
||||
деплоятся раздельно и расходятся на часы: в это окно сайдкар ещё отдаёт 200 с капчей.
|
||||
Первый слой (сайдкар: 403 + ban_page) — `browser/test_server_cian_captcha.py`.
|
||||
|
||||
Красное на main:
|
||||
* `fetch_detail` на капче возвращал None и НЕ звал `report_platform_ban`;
|
||||
* `fetch_detail` на капче возвращала None и НЕ звала `report_platform_ban`;
|
||||
* батч из двух объявлений давал 0 успехов — второй листинг шёл через тот же узел.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -43,6 +54,11 @@ _CAPTCHA_HTML = (
|
|||
"<body><div id='captcha'></div>"
|
||||
"<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 = (
|
||||
"<!DOCTYPE html><html lang='ru'><head><title>Ошибка - Циан</title></head>"
|
||||
"<body><h1>Что-то пошло не так</h1></body></html>"
|
||||
|
|
@ -97,20 +113,37 @@ class _FakeFetcher:
|
|||
# ── Слой 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:
|
||||
"""HTTP 200 + капча/страница ошибки → CianBlockedError, а не тихий None."""
|
||||
"""HTTP 200 + капча (оба варианта вёрстки) → CianBlockedError, а не тихий None."""
|
||||
fetcher = _FakeFetcher(refusal_html=html)
|
||||
|
||||
with pytest.raises(CianBlockedError) as exc_info:
|
||||
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'а.
|
||||
assert len(fetcher.ban_reports) == 1
|
||||
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:
|
||||
"""Дрейф разметки (нет состояния, нет маркеров) — прежнее поведение, не бан."""
|
||||
fetcher = _FakeFetcher(refusal_html=_NO_STATE_HTML)
|
||||
|
|
|
|||
|
|
@ -1975,11 +1975,26 @@ def _is_domclick_refusal(html: str) -> bool:
|
|||
# НОРМАЛЬНОЙ карточке (антифрод-скрипты), 17 — на странице капчи; как признак оно
|
||||
# неотличимо. Тире в заголовке нормализуется (— и – → -): вёрстка Циана печатает
|
||||
# его по-разному, а различать заголовки по виду дефиса — заведомо хрупко.
|
||||
_CIAN_REFUSAL_TITLES: tuple[str, ...] = (
|
||||
#
|
||||
# КАПЧА — безусловный отказ площадки: страница требует пройти проверку, контента за
|
||||
# ней нет, а узел, которому её показали, будет показывать её и дальше. Наверх идёт
|
||||
# BanPageDetectedError → бан пары «узел×cian» + ротация аренды. Проба прода
|
||||
# 06.09.2026 09:25 UTC по узлам: капча снимается за 1-2 часа (узел 14 через час
|
||||
# отдавал уже настоящую карточку), то есть TTL бана по назначению.
|
||||
_CIAN_CAPTCHA_TITLES: tuple[str, ...] = (
|
||||
"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)
|
||||
|
||||
|
||||
|
|
@ -1991,10 +2006,24 @@ def _page_title(html: str) -> str:
|
|||
return " ".join(match.group(1).split()).lower().replace("—", "-").replace("–", "-")
|
||||
|
||||
|
||||
def _is_cian_refusal(html: str) -> bool:
|
||||
"""True, если HTML — капча или страница ошибки Циана (см. _CIAN_REFUSAL_TITLES)."""
|
||||
def _is_cian_captcha(html: str) -> bool:
|
||||
"""True, если HTML — капча Циана (см. _CIAN_CAPTCHA_TITLES) — отказ площадки."""
|
||||
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 «страница прямо сейчас перезагружается». Ловим по
|
||||
|
|
@ -2369,11 +2398,13 @@ async def _fetch_once(
|
|||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: статический отказ площадки url={url!r}"
|
||||
)
|
||||
if provider == "cian" and _is_cian_refusal(text):
|
||||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: капча/страница ошибки Циана "
|
||||
f"(title={_page_title(text)!r}) url={url!r}"
|
||||
)
|
||||
if provider == "cian":
|
||||
if _is_cian_captcha(text):
|
||||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: капча Циана "
|
||||
f"(title={_page_title(text)!r}) url={url!r}"
|
||||
)
|
||||
_log_cian_error_page(text, url, status)
|
||||
logger.info(
|
||||
"tradein-browser[%s]: %s → HTTP %s, тело %d Б url=%r",
|
||||
provider,
|
||||
|
|
@ -2409,14 +2440,17 @@ async def _fetch_once(
|
|||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
|
||||
)
|
||||
# Капча/страница ошибки Циана (#3402): приходит с HTTP 200, поэтому ни
|
||||
# _REFUSAL_STATUSES, ни маркеры Авито её не ловят — распознаём по <title>
|
||||
# и отдаём наверх тем же путём (403 + ban_page), что отказ DomClick.
|
||||
if provider == "cian" and _is_cian_refusal(html):
|
||||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: капча/страница ошибки Циана "
|
||||
f"(title={_page_title(html)!r}) url={url!r}"
|
||||
)
|
||||
# Капча Циана (#3402): приходит с HTTP 200, поэтому ни _REFUSAL_STATUSES, ни
|
||||
# маркеры Авито её не ловят — распознаём по <title> и отдаём наверх тем же
|
||||
# путём (403 + ban_page), что отказ DomClick. «Ошибка - Циан» — НЕ бан, только
|
||||
# лог: см. _CIAN_ERROR_TITLES.
|
||||
if provider == "cian":
|
||||
if _is_cian_captcha(html):
|
||||
raise BanPageDetectedError(
|
||||
f"tradein-browser[{provider}]: капча Циана "
|
||||
f"(title={_page_title(html)!r}) url={url!r}"
|
||||
)
|
||||
_log_cian_error_page(html, url, _last_response_status.get(provider))
|
||||
if provider == "domclick":
|
||||
# DomClick — своя ветка (#3196): нет отдельного маркера самого
|
||||
# рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому
|
||||
|
|
|
|||
|
|
@ -1,16 +1,25 @@
|
|||
"""test_server_cian_captcha.py — капча Циана приходит с HTTP 200 (#3402).
|
||||
|
||||
Замер прода 06.09.2026: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`,
|
||||
44 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом **200**, поэтому
|
||||
_REFUSAL_STATUSES {403,429} их не видит, а маркеры Авито (_CHALLENGE_MARKERS /
|
||||
_BAN_MARKERS) в них не встречаются. HTML уезжал клиенту как успех, парсер не находил
|
||||
defaultState, и cian_detail_backfill держал ОДНУ аренду на весь батч, сжигая через
|
||||
капча-узел 210-400 карточек подряд (прогоны 6200: 0/210; 6123/6091/6052/6032/6010/5981:
|
||||
0/400 — против 161/162 через здоровый узел на прогоне 13).
|
||||
44 КБ; `<title>Вы не робот?`, 16 КБ) и страницу ошибки (`<title>Ошибка - Циан`, 374 КБ)
|
||||
с кодом **200**, поэтому _REFUSAL_STATUSES {403,429} их не видит, а маркеры Авито
|
||||
(_CHALLENGE_MARKERS / _BAN_MARKERS) в них не встречаются. HTML уезжал клиенту как успех,
|
||||
парсер не находил defaultState, и cian_detail_backfill держал ОДНУ аренду на весь батч,
|
||||
сжигая через капча-узел 210-400 карточек подряд (прогоны 6200: 0/210;
|
||||
6123/6091/6052/6032/6010/5981: 0/400 — против 161/162 через здоровый узел на прогоне 13).
|
||||
|
||||
Слово `captcha` как признак не годится: в НОРМАЛЬНОЙ карточке Циана оно встречается 11
|
||||
раз (антифрод-скрипты), на странице капчи — 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 (зеркалит
|
||||
test_server_http_status.py). wait_for_timeout на фейковой page — no-op.
|
||||
|
||||
|
|
@ -24,6 +33,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -62,6 +72,11 @@ _CAPTCHA_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 = (
|
||||
"<!DOCTYPE html><html lang='ru'><head><title>Ошибка - Циан</title></head>"
|
||||
"<body><h1>Что-то пошло не так</h1></body></html>"
|
||||
|
|
@ -140,37 +155,66 @@ def _make_request(body: dict[str, Any]) -> Any:
|
|||
return request
|
||||
|
||||
|
||||
# ── Детектор: что считается отказом, а что нет ────────────────────────────────
|
||||
# ── Детектор: что капча, что страница ошибки, что ни то ни другое ─────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ERROR_PAGE_HTML])
|
||||
def test_cian_refusal_pages_are_recognised(html: str) -> None:
|
||||
assert server._is_cian_refusal(html) is True
|
||||
@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML])
|
||||
def test_cian_captcha_pages_are_recognised(html: str) -> None:
|
||||
"""Оба варианта капчи — отказ площадки (второй, «Вы не робот?», добавлен 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:
|
||||
"""Слово `captcha` в теле нормальной карточки признаком отказа НЕ является."""
|
||||
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 ──────────────────────
|
||||
|
||||
|
||||
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» наверх."""
|
||||
page = _Page(_CAPTCHA_HTML)
|
||||
page = _Page(html)
|
||||
_install(monkeypatch, page)
|
||||
|
||||
with pytest.raises(server.BanPageDetectedError):
|
||||
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:
|
||||
"""Здоровая карточка (с тем же словом в теле) отдаётся как раньше."""
|
||||
page = _Page(_CARD_HTML)
|
||||
|
|
@ -181,8 +225,10 @@ def test_fetch_once_passes_normal_card_through(monkeypatch: pytest.MonkeyPatch)
|
|||
assert html == _CARD_HTML
|
||||
|
||||
|
||||
@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML])
|
||||
def test_fetch_handler_returns_403_with_ban_page_on_captcha(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
html: str,
|
||||
) -> None:
|
||||
"""Тот же путь, что #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)
|
||||
page = _Page(_CAPTCHA_HTML)
|
||||
page = _Page(html)
|
||||
_install(monkeypatch, page)
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
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(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -101,30 +101,41 @@ def _raise_if_blocked(offer_url: str, status_code: int) -> None:
|
|||
|
||||
|
||||
# ── Капча/страница ошибки Циана с HTTP 200 (#3402) ──────────────────────────────
|
||||
# Дубль детектора сайдкара (browser/server.py::_is_cian_refusal) — намеренный:
|
||||
# Дубль детектора сайдкара (browser/server.py::_is_cian_captcha) — намеренный:
|
||||
# образы backend и browser деплоятся раздельно и расходятся на часы, а в этот
|
||||
# промежуток отказ площадки приходит сюда ровно так же, как до правки — 200 + HTML
|
||||
# капчи. Опознаём по <title>, а не по слову "captcha": оно есть и в нормальной
|
||||
# карточке (11 вхождений против 17 на капче), то есть как признак не различает.
|
||||
_REFUSAL_TITLES: tuple[str, ...] = (
|
||||
#
|
||||
# КАПЧА — безусловный отказ площадки (бан пары «узел×cian» + ротация аренды): за ней
|
||||
# нет контента, и узел, которому её показали, будет получать её дальше. Проба прода
|
||||
# 06.09.2026 09:25 UTC по узлам: капча снимается за 1-2 часа, то есть TTL бана по
|
||||
# назначению.
|
||||
_CAPTCHA_TITLES: tuple[str, ...] = (
|
||||
"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)
|
||||
|
||||
|
||||
def _refusal_title(html: str) -> str | None:
|
||||
"""Заголовок страницы, если это капча/ошибка Циана; иначе None.
|
||||
def _page_title(html: str) -> str:
|
||||
"""Текст <title>: схлопнутые пробелы, нижний регистр, нормализованное тире.
|
||||
|
||||
Тире нормализуется (— и – → -): вёрстка печатает его по-разному, а различать
|
||||
заголовки по виду дефиса — заведомо хрупко.
|
||||
"""
|
||||
match = _TITLE_RE.search(html)
|
||||
if match is None:
|
||||
return None
|
||||
title = " ".join(match.group(1).split()).lower().replace("—", "-").replace("–", "-")
|
||||
return title if any(marker in title for marker in _REFUSAL_TITLES) else None
|
||||
return ""
|
||||
return " ".join(match.group(1).split()).lower().replace("—", "-").replace("–", "-")
|
||||
|
||||
|
||||
async def fetch_detail(
|
||||
|
|
@ -156,9 +167,11 @@ async def fetch_detail(
|
|||
пришли (#2700). Оба вызывающих в orchestration/pipeline.py уже считают
|
||||
исключение в `errors_count`, а на own-session-пути оно дополнительно снимает
|
||||
узел с выдачи Циану через `curl_proxy_url`. Тем же исключением приезжает
|
||||
капча/страница ошибки Циана (#3402): её сайдкар отдаёт как `ban_page` + 403,
|
||||
а если образ сайдкара старее — она распознаётся здесь, по <title>, уже после
|
||||
HTTP 200 (`report_platform_ban` на живом lease + raise вместо тихого None).
|
||||
КАПЧА Циана (#3402): её сайдкар отдаёт как `ban_page` + 403, а если образ
|
||||
сайдкара старее — она распознаётся здесь, по <title>, уже после HTTP 200
|
||||
(`report_platform_ban` на живом lease + raise вместо тихого None). Страница
|
||||
«Ошибка - Циан» баном НЕ считается — WARNING и прежний None, см.
|
||||
`_ERROR_PAGE_TITLES`.
|
||||
NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а
|
||||
не гасится в None: запрос не уходил, и следующий вызов упрётся в то же самое,
|
||||
поэтому решение «оборвать батч» принимает вызывающий (#3197).
|
||||
|
|
@ -168,8 +181,8 @@ async def fetch_detail(
|
|||
try:
|
||||
html = await browser_fetcher.fetch(offer_url)
|
||||
except SidecarBanPageError as exc:
|
||||
# #3402: сайдкар опознал отказ площадки по маркерам тела (капча/страница
|
||||
# ошибки Циана, `ban_page` + 403). ПОРЯДОК ВЕТОК ВАЖЕН — SidecarBanPageError
|
||||
# #3402: сайдкар опознал отказ площадки по маркерам тела (капча Циана,
|
||||
# `ban_page` + 403). ПОРЯДОК ВЕТОК ВАЖЕН — SidecarBanPageError
|
||||
# подкласс httpx.HTTPStatusError, и общий `except` ниже увёл бы
|
||||
# подтверждённый отказ Циана в `return None`, то есть в «не смогли
|
||||
# разобрать». Зеркалит avito/detail.py и domclick/detail.py (#3283/#3239).
|
||||
|
|
@ -232,12 +245,13 @@ async def fetch_detail(
|
|||
# NOTE: detail pages use 'defaultState', SERP uses 'initialState'
|
||||
offer_state = extract_state(html, mfe="frontend-offer-card", key="defaultState")
|
||||
if offer_state is None:
|
||||
# #3402: страницы БЕЗ состояния бывают двух разных родов, и до этой правки
|
||||
# обе печатались как «extraction failed» — то есть отказ площадки читался как
|
||||
# дрейф нашей разметки. Капча/страница ошибки Циана приходит с HTTP 200,
|
||||
# диагностировать её по статусу (ban_kind_from_status) нечем — только по телу.
|
||||
title = _refusal_title(html)
|
||||
if title is not None:
|
||||
# #3402: страницы БЕЗ состояния бывают трёх разных родов, и до этой правки все
|
||||
# печатались как «extraction failed» — то есть отказ площадки читался как дрейф
|
||||
# нашей разметки. Капча приходит с HTTP 200, диагностировать её по статусу
|
||||
# (ban_kind_from_status) нечем — только по телу. Три рода: капча (бан+ротация),
|
||||
# страница ошибки (только лог, природа не доказана), дрейф разметки (как было).
|
||||
title = _page_title(html)
|
||||
if any(marker in title for marker in _CAPTCHA_TITLES):
|
||||
if browser_fetcher is not None:
|
||||
# Детект НАШ, сайдкар его не видел (старый образ отдал 200) — рапорт
|
||||
# обязателен здесь, иначе тот же узел доработает батч до конца. lease
|
||||
|
|
@ -245,15 +259,18 @@ async def fetch_detail(
|
|||
# report_platform_ban (а не report_ban): бан пары «узел×cian» ПЛЮС
|
||||
# fail-streak, по которому аренда меняется ВНУТРИ батча — ровно то же,
|
||||
# что делает сайдкар-путь через SidecarBanPageError.
|
||||
browser_fetcher.report_platform_ban(
|
||||
f"cian detail: капча/страница ошибки Циана для {offer_url}"
|
||||
)
|
||||
browser_fetcher.report_platform_ban(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(
|
||||
"Cian detail %s: капча/страница ошибки Циана (title=%r)", offer_url, title
|
||||
)
|
||||
raise CianBlockedError(
|
||||
f"Cian detail: капча/страница ошибки Циана (title={title!r}) для {offer_url}"
|
||||
"Cian detail %s: страница ошибки Циана (title=%r) — не бан, только лог (#3402)",
|
||||
offer_url,
|
||||
title,
|
||||
)
|
||||
return None
|
||||
logger.warning("Cian detail %s: defaultState extraction failed", offer_url)
|
||||
return None
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue