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.
200 lines
11 KiB
Python
200 lines
11 KiB
Python
"""#3402: капча Циана приходит с HTTP 200 — это отказ площадки, а не «не разобрали».
|
||
|
||
Замер прода 06.09.2026: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`,
|
||
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`;
|
||
* батч из двух объявлений давал 0 успехов — второй листинг шёл через тот же узел.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
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 scraper_kit.cian_exceptions import CianBlockedError
|
||
from scraper_kit.providers.cian import detail as cian_detail
|
||
|
||
from app.tasks import cian_history_backfill
|
||
|
||
_URL = "https://ekb.cian.ru/sale/flat/327830237/"
|
||
|
||
# Фрагмент прод-страницы капчи: заголовок + то самое слово в теле.
|
||
_CAPTCHA_HTML = (
|
||
"<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>"
|
||
"<title>Captcha - база объявлений ЦИАН</title></head>"
|
||
"<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>"
|
||
)
|
||
# Здоровая карточка в текущем (`.concat`) формате _cianConfig. Слово `captcha` в теле
|
||
# ЕСТЬ — в нормальной карточке Циана оно встречается 11 раз (антифрод-скрипты), и
|
||
# именно поэтому признаком отказа оно быть не может.
|
||
_CARD_HTML = (
|
||
"<html><head><title>Купить 1-комн. квартиру — ЦИАН</title></head><body>"
|
||
"<script>window.captchaSettings = {}; /* captcha */</script><script>"
|
||
"window._cianConfig['frontend-offer-card'] = "
|
||
"(window._cianConfig['frontend-offer-card'] || []).concat("
|
||
'[{"key":"defaultState","value":{"offerData":{"offer":{"cianId":327830237}}},'
|
||
'"priority":0}]);'
|
||
"</script></body></html>"
|
||
)
|
||
# Страница без состояния и без маркеров отказа — дрейф разметки, прежнее поведение.
|
||
_NO_STATE_HTML = "<html><head><title>Купить квартиру — ЦИАН</title></head><body></body></html>"
|
||
|
||
|
||
class _FakeFetcher:
|
||
"""BrowserFetcher-заглушка: капча, пока не сменится «аренда».
|
||
|
||
`report_platform_ban` зеркалит боевую механику ровно в том, что нас интересует:
|
||
рапорт по текущему lease + смена аренды (в бою — `report_ban` + fail-streak →
|
||
`_LEASE_ROTATE_AFTER_FAILS`, browser_fetcher.py). Здесь смена мгновенная — тест
|
||
про то, ЗОВЁТСЯ ли рапорт и продолжается ли батч на новом узле, а не про порог.
|
||
"""
|
||
|
||
def __init__(self, *, refusal_html: str = _CAPTCHA_HTML, **kwargs: Any) -> None:
|
||
self.node = 1
|
||
self.refusal_html = refusal_html
|
||
self.last_response_status: int | None = 200
|
||
self.ban_reports: list[str] = []
|
||
self.fetched: list[str] = []
|
||
|
||
async def __aenter__(self) -> _FakeFetcher:
|
||
return self
|
||
|
||
async def __aexit__(self, *_: object) -> None:
|
||
return None
|
||
|
||
async def fetch(self, url: str, **kwargs: Any) -> str:
|
||
self.fetched.append(url)
|
||
return self.refusal_html if self.node == 1 else _CARD_HTML
|
||
|
||
def report_platform_ban(self, reason: str) -> None:
|
||
self.ban_reports.append(reason)
|
||
self.node += 1
|
||
|
||
|
||
# ── Слой kit: капча → CianBlockedError + рапорт бана ──────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize("html", [_CAPTCHA_HTML, _ROBOT_HTML])
|
||
async def test_captcha_page_is_a_platform_refusal(html: str) -> 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)
|
||
# Рапорт бана — по ЖИВОМУ 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)
|
||
|
||
result = await cian_detail.fetch_detail(_URL, browser_fetcher=fetcher)
|
||
|
||
assert result is None
|
||
assert fetcher.ban_reports == []
|
||
|
||
|
||
async def test_healthy_card_with_the_word_captcha_parses() -> None:
|
||
"""Слово `captcha` в теле здоровой карточки баном НЕ считается."""
|
||
fetcher = _FakeFetcher()
|
||
fetcher.node = 2 # «здоровый узел» → отдаёт карточку
|
||
|
||
result = await cian_detail.fetch_detail(_URL, browser_fetcher=fetcher)
|
||
|
||
assert result is not None
|
||
assert result.cian_id == 327830237
|
||
assert fetcher.ban_reports == []
|
||
|
||
|
||
# ── Батч: аренда меняется ВНУТРИ прогона, а не на следующем ───────────────────
|
||
|
||
|
||
def _db_with_rows(n: int) -> 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 test_batch_rotates_after_captcha_and_enriches_the_next_listing() -> None:
|
||
"""Капча на первом объявлении → рапорт бана → второе обогащено на новом узле.
|
||
|
||
На main тест красный по значению: `fetch_detail` возвращал None, рапорта не было,
|
||
«узел» не менялся — оба объявления шли через капчу, listings_succeeded == 0.
|
||
"""
|
||
fetcher = _FakeFetcher()
|
||
|
||
with (
|
||
patch.object(cian_history_backfill, "BrowserFetcher", lambda **kw: fetcher),
|
||
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(2), do_listings=True, do_houses=False, do_valuations=False
|
||
)
|
||
|
||
assert fetcher.ban_reports, "капча не отрапортована пулу — узел доработает батч"
|
||
assert len(fetcher.fetched) == 2 # батч не оборван: следующее объявление взято
|
||
assert result.listings_succeeded == 1 # второе обогащено ПОСЛЕ смены аренды
|
||
assert result.listings_failed_fetch == 1
|