Merge remote-tracking branch 'forgejo/main' into feat/3404-proxy-run-attribution
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
This commit is contained in:
commit
f4099cb89a
10 changed files with 686 additions and 13 deletions
|
|
@ -490,7 +490,7 @@ def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPage
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_detail_does_not_report_ban_again_on_sidecar_ban_page() -> None:
|
async def test_fetch_detail_does_not_report_ban_again_on_sidecar_ban_page() -> None:
|
||||||
"""Sidecar-бан рапортует сам фетчер (`_report_platform_ban`, #3288) — здесь уже нет.
|
"""Sidecar-бан рапортует сам фетчер (`report_platform_ban`, #3288) — здесь уже нет.
|
||||||
|
|
||||||
Повтор отсюда приходит ПОСЛЕ возможной ротации lease по fail-streak и банил бы
|
Повтор отсюда приходит ПОСЛЕ возможной ротации lease по fail-streak и банил бы
|
||||||
свежий узел. Ветка `parse_detail_html` ниже — другой случай: тот детект наш,
|
свежий узел. Ветка `parse_detail_html` ниже — другой случай: тот детект наш,
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra(
|
||||||
async def test_fetch_detail_sidecar_ban_page_does_not_report_ban_again(
|
async def test_fetch_detail_sidecar_ban_page_does_not_report_ban_again(
|
||||||
sidecar_status: int,
|
sidecar_status: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Бан рапортует ФЕТЧЕР (`_report_platform_ban`), провайдер — уже нет (#3288).
|
"""Бан рапортует ФЕТЧЕР (`report_platform_ban`), провайдер — уже нет (#3288).
|
||||||
|
|
||||||
До #3288 здесь стоял `assert_called_once()`, и это было верно, пока фетчер о
|
До #3288 здесь стоял `assert_called_once()`, и это было верно, пока фетчер о
|
||||||
бане не знал. Теперь рапорт идёт из `_post_fetch` — РАНЬШЕ и по правильному
|
бане не знал. Теперь рапорт идёт из `_post_fetch` — РАНЬШЕ и по правильному
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,7 @@ async def test_empty_pool_during_post_ban_rotation_keeps_platform_diagnosis() ->
|
||||||
"""Ротация — best-effort: её `NoProxyAvailableError` не должна съесть бан.
|
"""Ротация — best-effort: её `NoProxyAvailableError` не должна съесть бан.
|
||||||
|
|
||||||
Второго узла в пуле нет, поэтому `_acquire_lease()` внутри ротации падает.
|
Второго узла в пуле нет, поэтому `_acquire_lease()` внутри ротации падает.
|
||||||
Фальсификация: убери `except NoProxyAvailableError` в `_report_platform_ban` —
|
Фальсификация: убери `except NoProxyAvailableError` в `report_platform_ban` —
|
||||||
наверх уедет она вместо `SidecarBanPageError`, провайдер завернёт её в
|
наверх уедет она вместо `SidecarBanPageError`, провайдер завернёт её в
|
||||||
AvitoSidecarUnavailableError, и подтверждённый отказ площадки попадёт в
|
AvitoSidecarUnavailableError, и подтверждённый отказ площадки попадёт в
|
||||||
`scrape_runs.ban_kind` как 'infra' (ровно та подмена, что чинил #3283).
|
`scrape_runs.ban_kind` как 'infra' (ровно та подмена, что чинил #3283).
|
||||||
|
|
|
||||||
200
tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py
Normal file
200
tradein-mvp/backend/tests/test_3402_cian_captcha_http200.py
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
"""#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
|
||||||
|
|
@ -1963,6 +1963,69 @@ def _is_domclick_refusal(html: str) -> bool:
|
||||||
return any(marker in lower for marker in _DOMCLICK_REFUSAL_MARKERS)
|
return any(marker in lower for marker in _DOMCLICK_REFUSAL_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Отказ Циана с кодом 200 (#3402, замер прода 06.09.2026) ────────────────────
|
||||||
|
# Циан отдаёт капчу и страницу ошибки с HTTP 200, поэтому _REFUSAL_STATUSES их не
|
||||||
|
# видит, а маркеров Авито (_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).
|
||||||
|
#
|
||||||
|
# Опознаём по <title>, а не по слову "captcha" в теле: оно встречается 11 раз и в
|
||||||
|
# НОРМАЛЬНОЙ карточке (антифрод-скрипты), 17 — на странице капчи; как признак оно
|
||||||
|
# неотличимо. Тире в заголовке нормализуется (— и – → -): вёрстка Циана печатает
|
||||||
|
# его по-разному, а различать заголовки по виду дефиса — заведомо хрупко.
|
||||||
|
#
|
||||||
|
# КАПЧА — безусловный отказ площадки: страница требует пройти проверку, контента за
|
||||||
|
# ней нет, а узел, которому её показали, будет показывать её и дальше. Наверх идёт
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
def _page_title(html: str) -> str:
|
||||||
|
"""Текст <title> — схлопнутые пробелы, нижний регистр, нормализованное тире."""
|
||||||
|
match = _TITLE_RE.search(html)
|
||||||
|
if match is None:
|
||||||
|
return ""
|
||||||
|
return " ".join(match.group(1).split()).lower().replace("—", "-").replace("–", "-")
|
||||||
|
|
||||||
|
|
||||||
|
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_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 «страница прямо сейчас перезагружается». Ловим по
|
||||||
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
|
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
|
||||||
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
|
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
|
||||||
|
|
@ -2335,6 +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":
|
||||||
|
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(
|
logger.info(
|
||||||
"tradein-browser[%s]: %s → HTTP %s, тело %d Б url=%r",
|
"tradein-browser[%s]: %s → HTTP %s, тело %d Б url=%r",
|
||||||
provider,
|
provider,
|
||||||
|
|
@ -2370,6 +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, поэтому ни _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":
|
if provider == "domclick":
|
||||||
# DomClick — своя ветка (#3196): нет отдельного маркера самого
|
# DomClick — своя ветка (#3196): нет отдельного маркера самого
|
||||||
# рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому
|
# рукопожатия (см. комментарий у _DOMCLICK_SUCCESS_MARKER), поэтому
|
||||||
|
|
|
||||||
307
tradein-mvp/browser/test_server_cian_captcha.py
Normal file
307
tradein-mvp/browser/test_server_cian_captcha.py
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
"""test_server_cian_captcha.py — капча Циана приходит с HTTP 200 (#3402).
|
||||||
|
|
||||||
|
Замер прода 06.09.2026: Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`,
|
||||||
|
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.
|
||||||
|
|
||||||
|
Запуск (из tradein-mvp/browser/)::
|
||||||
|
|
||||||
|
python -m pytest test_server_cian_captcha.py -q
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp.test_utils import make_mocked_request
|
||||||
|
|
||||||
|
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
|
||||||
|
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
|
||||||
|
assert _spec is not None and _spec.loader is not None
|
||||||
|
server = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(server)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Чистое per-provider состояние на каждый тест (зеркалит соседние тесты)."""
|
||||||
|
monkeypatch.setattr(server, "_browsers", {})
|
||||||
|
monkeypatch.setattr(server, "_browser_cms", {})
|
||||||
|
monkeypatch.setattr(server, "_page_counters", {})
|
||||||
|
monkeypatch.setattr(server, "_locks", {})
|
||||||
|
monkeypatch.setattr(server, "_retry_tasks", {})
|
||||||
|
monkeypatch.setattr(server, "_last_goto_at", {})
|
||||||
|
monkeypatch.setattr(server, "_last_response_status", {})
|
||||||
|
monkeypatch.setattr(server, "_launched_proxy", {})
|
||||||
|
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||||
|
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
# Обёртка капчи — фрагмент прод-страницы: заголовок + то самое слово в теле.
|
||||||
|
_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>"
|
||||||
|
)
|
||||||
|
# Нормальная карточка: слово captcha в теле есть (антифрод), состояние — на месте.
|
||||||
|
_CARD_HTML = (
|
||||||
|
"<html><head><title>Купить 1-комн. квартиру — ЦИАН</title></head><body>"
|
||||||
|
"<script>window.captchaConfig = {}; /* captcha captcha captcha */</script>"
|
||||||
|
"<script>window._cianConfig['frontend-offer-card'] = [];</script>"
|
||||||
|
"</body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
"""Поддельный playwright Response — интересует только .status."""
|
||||||
|
|
||||||
|
def __init__(self, status: int) -> None:
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
class _Page:
|
||||||
|
"""Поддельная page: goto отдаёт Response(200), content() — заданный HTML."""
|
||||||
|
|
||||||
|
def __init__(self, html: str, status: int = 200) -> None:
|
||||||
|
self._html = html
|
||||||
|
self._status = status
|
||||||
|
self.goto_urls: list[str] = []
|
||||||
|
self.closed = 0
|
||||||
|
|
||||||
|
async def route(self, pattern: str, handler: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def goto(self, url: str, **kwargs: Any) -> _Response:
|
||||||
|
self.goto_urls.append(url)
|
||||||
|
return _Response(self._status)
|
||||||
|
|
||||||
|
async def wait_for_timeout(self, ms: int) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def content(self) -> str:
|
||||||
|
return self._html
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed += 1
|
||||||
|
|
||||||
|
|
||||||
|
class _Browser:
|
||||||
|
def __init__(self, page: _Page) -> None:
|
||||||
|
self._page = page
|
||||||
|
|
||||||
|
async def new_page(self) -> _Page:
|
||||||
|
return self._page
|
||||||
|
|
||||||
|
|
||||||
|
def _install(monkeypatch: pytest.MonkeyPatch, page: _Page, provider: str = "cian") -> None:
|
||||||
|
server._browsers[provider] = _Browser(page)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server, "_RECYCLE_PAGES_BY_PROVIDER", dict.fromkeys(server.PROVIDERS, 10_000)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0)
|
||||||
|
monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {})
|
||||||
|
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_body(response: Any) -> dict[str, Any]:
|
||||||
|
return json.loads(response.body.decode())
|
||||||
|
|
||||||
|
|
||||||
|
async def _coro(value: Any) -> Any:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(body: dict[str, Any]) -> Any:
|
||||||
|
request = make_mocked_request("POST", "/fetch")
|
||||||
|
request.json = lambda: _coro(body) # type: ignore[method-assign]
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
# ── Детектор: что капча, что страница ошибки, что ни то ни другое ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
@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_captcha(_CARD_HTML) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_em_dash_in_title_is_normalised(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Вёрстка печатает тире по-разному — детект не должен зависеть от его вида."""
|
||||||
|
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 ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@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(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)
|
||||||
|
_install(monkeypatch, page)
|
||||||
|
|
||||||
|
html = asyncio.run(server._fetch_once("cian", "https://ekb.cian.ru/sale/flat/1/"))
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Статус передаём как есть: апстрим ответил 200, и врать про 403 площадки нельзя —
|
||||||
|
клиент опознаёт бан по признаку `ban_page`, а не по коду (образы сайдкара и
|
||||||
|
бэкенда деплоятся врозь).
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(server, "IS_PROD", False)
|
||||||
|
page = _Page(html)
|
||||||
|
_install(monkeypatch, page)
|
||||||
|
|
||||||
|
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||||
|
|
||||||
|
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 == 403
|
||||||
|
assert body["ban_page"] is True
|
||||||
|
assert body["status"] == 200
|
||||||
|
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:
|
||||||
|
"""Детект per-provider: та же страница у Авито проходит прежним путём (нет ложных банов)."""
|
||||||
|
monkeypatch.setattr(server, "IS_PROD", False)
|
||||||
|
page = _Page(_CAPTCHA_HTML)
|
||||||
|
_install(monkeypatch, page, provider="avito")
|
||||||
|
|
||||||
|
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
server.fetch_handler(_make_request({"url": "https://www.avito.ru/x", "source": "avito"}))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert _json_body(response)["html"] == _CAPTCHA_HTML
|
||||||
|
|
@ -823,7 +823,7 @@ class BrowserFetcher:
|
||||||
шаг — так отказ ПЛОЩАДКИ (подтверждённая бан-страница) не копит глобальный
|
шаг — так отказ ПЛОЩАДКИ (подтверждённая бан-страница) не копит глобальный
|
||||||
счётчик здоровья узла: он исправен, его отбил конкретный источник, и его
|
счётчик здоровья узла: он исправен, его отбил конкретный источник, и его
|
||||||
судьбу решает `mark_banned(source=...)` по паре «узел×источник»
|
судьбу решает `mark_banned(source=...)` по паре «узел×источник»
|
||||||
(см. `_report_platform_ban`). До #3288 узел с тремя бан-страницами Авито
|
(см. `report_platform_ban`). До #3288 узел с тремя бан-страницами Авито
|
||||||
уходил из выдачи ВСЕМ источникам — прод-замер 31.08: yandex/cian/domclick
|
уходил из выдачи ВСЕМ источникам — прод-замер 31.08: yandex/cian/domclick
|
||||||
получали ProxyPoolExhaustedError при banned_for_source=0 и трёх живых узлах;
|
получали ProxyPoolExhaustedError при banned_for_source=0 и трёх живых узлах;
|
||||||
- ok=False копит `_lease_fail_streak`; после `_LEASE_ROTATE_AFTER_FAILS`
|
- ok=False копит `_lease_fail_streak`; после `_LEASE_ROTATE_AFTER_FAILS`
|
||||||
|
|
@ -883,8 +883,13 @@ class BrowserFetcher:
|
||||||
)
|
)
|
||||||
self._lease = self._acquire_lease()
|
self._lease = self._acquire_lease()
|
||||||
|
|
||||||
def _report_platform_ban(self, reason: str) -> None:
|
def report_platform_ban(self, reason: str) -> None:
|
||||||
"""Исход /fetch, который сайдкар опознал как бан-страницу площадки (#3288).
|
"""Исход /fetch, который опознан как бан-страница площадки (#3288).
|
||||||
|
|
||||||
|
Публичный метод (#3402): тем же путём обязан идти отказ, распознанный не
|
||||||
|
сайдкаром, а провайдером — по телу ответа с HTTP 200 (капча Циана). Разница с
|
||||||
|
`report_ban` существенна: тот только пишет бан пары «узел×источник», а сменить
|
||||||
|
сожжённую аренду ВНУТРИ батча позволяет только fail-streak ниже.
|
||||||
|
|
||||||
Отличается от обычного провала РОВНО одним: узел не получает `mark_health(False)`.
|
Отличается от обычного провала РОВНО одним: узел не получает `mark_health(False)`.
|
||||||
Бан — приговор паре «узел×источник» (`mark_banned`, строка в
|
Бан — приговор паре «узел×источник» (`mark_banned`, строка в
|
||||||
|
|
@ -982,9 +987,7 @@ class BrowserFetcher:
|
||||||
# httpx.HTTPStatusError, и общий `except Exception` ниже забирал её себе,
|
# httpx.HTTPStatusError, и общий `except Exception` ниже забирал её себе,
|
||||||
# отправляя подтверждённый отказ ПЛОЩАДКИ в глобальный счётчик здоровья узла.
|
# отправляя подтверждённый отказ ПЛОЩАДКИ в глобальный счётчик здоровья узла.
|
||||||
self.last_response_status = None
|
self.last_response_status = None
|
||||||
self._report_platform_ban(
|
self.report_platform_ban(f"sidecar ban page (upstream={exc.upstream_status}) for {url}")
|
||||||
f"sidecar ban page (upstream={exc.upstream_status}) for {url}"
|
|
||||||
)
|
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
# NoProxyAvailableError (пустой пул) сюда НЕ приходит: он поднимается в
|
# NoProxyAvailableError (пустой пул) сюда НЕ приходит: он поднимается в
|
||||||
|
|
@ -1041,7 +1044,7 @@ class BrowserFetcher:
|
||||||
except SidecarBanPageError as exc:
|
except SidecarBanPageError as exc:
|
||||||
# Ветка ДО общего except по той же причине, что в _post_fetch (#3288):
|
# Ветка ДО общего except по той же причине, что в _post_fetch (#3288):
|
||||||
# бан-страница — отказ площадки, а не отказ узла.
|
# бан-страница — отказ площадки, а не отказ узла.
|
||||||
self._report_platform_ban(
|
self.report_platform_ban(
|
||||||
f"sidecar ban page on fetch-json (upstream={exc.upstream_status}) for {url}"
|
f"sidecar ban page on fetch-json (upstream={exc.upstream_status}) for {url}"
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -585,7 +585,7 @@ async def fetch_detail(
|
||||||
# DomClick (#3239/#3283, providers/domclick/detail.py).
|
# DomClick (#3239/#3283, providers/domclick/detail.py).
|
||||||
#
|
#
|
||||||
# report_ban ЗДЕСЬ НЕТ намеренно (#3288): бан рапортует сам fetcher, в
|
# report_ban ЗДЕСЬ НЕТ намеренно (#3288): бан рапортует сам fetcher, в
|
||||||
# _report_platform_ban, — раньше и по ПРАВИЛЬНОМУ lease. К моменту этого
|
# report_platform_ban, — раньше и по ПРАВИЛЬНОМУ lease. К моменту этого
|
||||||
# кадра fetcher мог уже сротироваться на свежий узел по fail-streak, и
|
# кадра fetcher мог уже сротироваться на свежий узел по fail-streak, и
|
||||||
# повторный рапорт отсюда банил бы того, кто к площадке не ходил.
|
# повторный рапорт отсюда банил бы того, кто к площадке не ходил.
|
||||||
raise AvitoBlockedError(
|
raise AvitoBlockedError(
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from scraper_kit.browser_fetcher import SidecarBanPageError
|
||||||
from scraper_kit.ceiling_height import plausible_ceiling_m
|
from scraper_kit.ceiling_height import plausible_ceiling_m
|
||||||
from scraper_kit.cian_exceptions import CianBlockedError
|
from scraper_kit.cian_exceptions import CianBlockedError
|
||||||
from scraper_kit.cian_state_parser import extract_all_states, extract_state
|
from scraper_kit.cian_state_parser import extract_all_states, extract_state
|
||||||
|
|
@ -99,6 +100,44 @@ def _raise_if_blocked(offer_url: str, status_code: int) -> None:
|
||||||
raise CianBlockedError(f"Cian detail {offer_url} → HTTP 403 (WAF-блок узла)")
|
raise CianBlockedError(f"Cian detail {offer_url} → HTTP 403 (WAF-блок узла)")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Капча/страница ошибки Циана с HTTP 200 (#3402) ──────────────────────────────
|
||||||
|
# Дубль детектора сайдкара (browser/server.py::_is_cian_captcha) — намеренный:
|
||||||
|
# образы backend и browser деплоятся раздельно и расходятся на часы, а в этот
|
||||||
|
# промежуток отказ площадки приходит сюда ровно так же, как до правки — 200 + HTML
|
||||||
|
# капчи. Опознаём по <title>, а не по слову "captcha": оно есть и в нормальной
|
||||||
|
# карточке (11 вхождений против 17 на капче), то есть как признак не различает.
|
||||||
|
#
|
||||||
|
# КАПЧА — безусловный отказ площадки (бан пары «узел×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 _page_title(html: str) -> str:
|
||||||
|
"""Текст <title>: схлопнутые пробелы, нижний регистр, нормализованное тире.
|
||||||
|
|
||||||
|
Тире нормализуется (— и – → -): вёрстка печатает его по-разному, а различать
|
||||||
|
заголовки по виду дефиса — заведомо хрупко.
|
||||||
|
"""
|
||||||
|
match = _TITLE_RE.search(html)
|
||||||
|
if match is None:
|
||||||
|
return ""
|
||||||
|
return " ".join(match.group(1).split()).lower().replace("—", "-").replace("–", "-")
|
||||||
|
|
||||||
|
|
||||||
async def fetch_detail(
|
async def fetch_detail(
|
||||||
offer_url: str,
|
offer_url: str,
|
||||||
*,
|
*,
|
||||||
|
|
@ -127,7 +166,12 @@ async def fetch_detail(
|
||||||
CianBlockedError: HTTP 403 на curl-путях — WAF Циана отбил узел, с которого мы
|
CianBlockedError: HTTP 403 на curl-путях — WAF Циана отбил узел, с которого мы
|
||||||
пришли (#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, а если образ
|
||||||
|
сайдкара старее — она распознаётся здесь, по <title>, уже после HTTP 200
|
||||||
|
(`report_platform_ban` на живом lease + raise вместо тихого None). Страница
|
||||||
|
«Ошибка - Циан» баном НЕ считается — WARNING и прежний None, см.
|
||||||
|
`_ERROR_PAGE_TITLES`.
|
||||||
NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а
|
NoProxyAvailableError: пул прокси пуст (#2616) — пробрасывается со ВСЕХ путей, а
|
||||||
не гасится в None: запрос не уходил, и следующий вызов упрётся в то же самое,
|
не гасится в None: запрос не уходил, и следующий вызов упрётся в то же самое,
|
||||||
поэтому решение «оборвать батч» принимает вызывающий (#3197).
|
поэтому решение «оборвать батч» принимает вызывающий (#3197).
|
||||||
|
|
@ -136,6 +180,18 @@ async def fetch_detail(
|
||||||
# Browser path: get fully JS-rendered HTML; same parse path follows.
|
# Browser path: get fully JS-rendered HTML; same parse path follows.
|
||||||
try:
|
try:
|
||||||
html = await browser_fetcher.fetch(offer_url)
|
html = await browser_fetcher.fetch(offer_url)
|
||||||
|
except SidecarBanPageError as exc:
|
||||||
|
# #3402: сайдкар опознал отказ площадки по маркерам тела (капча Циана,
|
||||||
|
# `ban_page` + 403). ПОРЯДОК ВЕТОК ВАЖЕН — SidecarBanPageError
|
||||||
|
# подкласс httpx.HTTPStatusError, и общий `except` ниже увёл бы
|
||||||
|
# подтверждённый отказ Циана в `return None`, то есть в «не смогли
|
||||||
|
# разобрать». Зеркалит avito/detail.py и domclick/detail.py (#3283/#3239).
|
||||||
|
#
|
||||||
|
# report_ban ЗДЕСЬ НЕТ намеренно (#3288): бан рапортует сам fetcher, в
|
||||||
|
# report_platform_ban, — раньше и по ПРАВИЛЬНОМУ lease.
|
||||||
|
raise CianBlockedError(
|
||||||
|
f"Cian detail: сайдкар опознал отказ площадки для {offer_url}: {exc}"
|
||||||
|
) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# «Пул пуст» — не отказ страницы: запрос не уходил вовсе, и вызывающий обязан
|
# «Пул пуст» — не отказ страницы: запрос не уходил вовсе, и вызывающий обязан
|
||||||
# оборвать батч (#3197). Проглоченный здесь, он приезжал наверх как None, то
|
# оборвать батч (#3197). Проглоченный здесь, он приезжал наверх как None, то
|
||||||
|
|
@ -189,6 +245,32 @@ 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: страницы БЕЗ состояния бывают трёх разных родов, и до этой правки все
|
||||||
|
# печатались как «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
|
||||||
|
# ещё жив: caller держит `async with BrowserFetcher(...)` снаружи.
|
||||||
|
# report_platform_ban (а не report_ban): бан пары «узел×cian» ПЛЮС
|
||||||
|
# fail-streak, по которому аренда меняется ВНУТРИ батча — ровно то же,
|
||||||
|
# что делает сайдкар-путь через SidecarBanPageError.
|
||||||
|
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) — не бан, только лог (#3402)",
|
||||||
|
offer_url,
|
||||||
|
title,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -639,7 +639,7 @@ async def fetch_detail(
|
||||||
# вместо 'platform' и ротация IP не запустилась бы вовсе.
|
# вместо 'platform' и ротация IP не запустилась бы вовсе.
|
||||||
#
|
#
|
||||||
# report_ban ЗДЕСЬ НЕТ намеренно (#3288): его делает сам fetcher в
|
# report_ban ЗДЕСЬ НЕТ намеренно (#3288): его делает сам fetcher в
|
||||||
# _report_platform_ban — раньше и по ПРАВИЛЬНОМУ lease (после ротации по
|
# report_platform_ban — раньше и по ПРАВИЛЬНОМУ lease (после ротации по
|
||||||
# fail-streak этот кадр забанил бы уже СЛЕДУЮЩИЙ узел). Ветка
|
# fail-streak этот кадр забанил бы уже СЛЕДУЮЩИЙ узел). Ветка
|
||||||
# parse_detail_html ниже — другой случай: там детект НАШ, fetcher его не
|
# parse_detail_html ниже — другой случай: там детект НАШ, fetcher его не
|
||||||
# видит, и report_ban остаётся.
|
# видит, и report_ban остаётся.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue