fix(tradein/cian): банить узел только когда состояния НЕТ, а не по слову «captcha» (#2767)
All checks were successful
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 Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
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 4m2s

Живая регрессия предыдущего PR, поймана на проде через 17 секунд после деплоя:

    17:47:44 BrowserFetcher: lease id=9 (cian) BANNED — cian newbuilding: captcha
    17:47:44 Cian newbuilding zhk-tihiy-centr-ekb-i.cian.ru parsed: id=108855

Одна и та же страница в одну и ту же секунду: разобрана успешно И объявлена
блоком. За минуту так забанились два ЗДОРОВЫХ узла (9 и 10). Причина: обычная
карточка ЖК грузит скрипт SmartCaptcha, подстрока «captcha» на ней есть всегда.

Ошибка была в том, что признак сменил смысл при переносе. `_ANTIBOT_MARKERS`
создавался объяснять УЖЕ СЛУЧИВШИЙСЯ отказ разбора — `_describe_parse_miss`
зовётся только из ветки «состояния нет», и там подстрока действительно значит
блок. Применённый как самостоятельный детектор на каждом ответе, тот же список
означает уже другое: «страница упоминает капчу».

Разбор перенесён внутрь `async with`, бан репортится только при
`nb_state is None` И найденной подписи. Условие «нет состояния» несущее, а не
уточняющее.

Тест на здоровой карточке в текущем формате Циана, содержащей слово-ловушку:
красный на смерженном коде (report_ban called 1 times), зелёный здесь.

Refs #2767
This commit is contained in:
bot-backend 2026-08-09 22:50:38 +05:00
parent f3bcb1a25f
commit 876bf378e9
2 changed files with 59 additions and 10 deletions

View file

@ -96,6 +96,19 @@ def _spy_fetcher(html: str) -> MagicMock:
return fetcher
# Здоровая карточка ЖК в текущем формате Циана (.concat([...])), и на ней ЕСТЬ подстрока
# «captcha»: обычная страница грузит скрипт SmartCaptcha. Форма взята с живой страницы
# 2026-08-09, содержимое сокращено до того, что читает fetch_newbuilding.
_HEALTHY_CARD_WITH_CAPTCHA_SCRIPT = (
'<html><head><script src="https://smartcaptcha.yandexcloud.net/captcha.js" async>'
"</script></head><body><script>"
"window._cianConfig['newbuilding-card-desktop-frontend'] = "
"(window._cianConfig['newbuilding-card-desktop-frontend'] || []).concat("
'[{"key":"initialState","value":{"newbuilding":{"id":108855,"name":"ЖК «Пихтовый»"}}}]'
");</script></body></html>"
)
@pytest.mark.asyncio
async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html, caplog) -> None:
"""Блок → `report_ban`, пока lease ещё жив.
@ -123,6 +136,35 @@ async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html,
assert "cian_waf_block" in "\n".join(r.getMessage() for r in caplog.records)
@pytest.mark.asyncio
async def test_parsed_page_never_bans_the_node_even_with_captcha_word(monkeypatch) -> None:
"""Разобранная страница НЕ банит узел, даже если на ней написано «captcha».
Живая регрессия 2026-08-09: первая версия этой правки звала `_blocked_by` на КАЖДОМ
ответе, и за первые 17 секунд прогона забанила два ЗДОРОВЫХ узла (9 и 10) на
страницах, которые успешно разобрались обычная карточка ЖК грузит скрипт
SmartCaptcha, подстрока там есть всегда. Признак «есть подпись защиты» осмыслен
только вместе с «состояния нет»: список маркеров создавался объяснять УЖЕ
случившийся отказ разбора, а не служить самостоятельным детектором.
"""
fetcher = _spy_fetcher(_HEALTHY_CARD_WITH_CAPTCHA_SCRIPT)
monkeypatch.setattr(
"scraper_kit.providers.cian.newbuilding.build_browser_fetcher",
MagicMock(return_value=fetcher),
)
result = await fetch_newbuilding(
"https://zhk-pihtovyy-ekb-i.cian.ru",
config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"),
)
# Предпосылка теста: страница действительно и разбирается, и содержит слово-ловушку.
assert result is not None
assert result.cian_internal_house_id == 108855
assert "captcha" in _HEALTHY_CARD_WITH_CAPTCHA_SCRIPT
fetcher.report_ban.assert_not_called()
@pytest.mark.asyncio
async def test_proxy_provider_reaches_the_fetcher_factory(monkeypatch, waf_html) -> None:
"""Провод целиком: provider из задачи → build_browser_fetcher → пул.

View file

@ -227,20 +227,27 @@ async def fetch_newbuilding(
# прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый).
# При паузе 18 с между домами это терпимо и вдобавок само по себе ротация IP.
# Понадобится дешевле — поднимать фетчер в вызывающий цикл и передавать сюда.
async with browser:
html = await browser.fetch(zhk_url)
# Блок распознаётся ПОКА lease жив: после выхода из `async with` узел уже
# отпущен и `report_ban` стал бы no-op. Пул после этого не выдаёт узел под
# source='cian' (scrape_proxy_source_bans), и следующий дом идёт с другого IP.
blocked = _blocked_by(html)
if blocked is not None:
browser.report_ban(f"cian newbuilding: {blocked}")
# Cian ЖК-карточка: MFE 'newbuilding-card-desktop-frontend', key 'initialState'
# (verified live 2026-06-15; перепроверено живьём 2026-08-09 — разметка та же).
mfe = "newbuilding-card-desktop-frontend"
nb_state = extract_state(html, mfe=mfe, key="initialState")
async with browser:
html = await browser.fetch(zhk_url)
# Разбор — ВНУТРИ контекста, чтобы бан репортился, пока lease жив: после выхода
# из `async with` узел уже отпущен и `report_ban` стал бы no-op.
nb_state = extract_state(html, mfe=mfe, key="initialState")
# Условие бана — «состояния НЕТ и есть подпись защиты», а не одна подпись.
# Живая регрессия 2026-08-09 (поймана на проде через 17 секунд после деплоя):
# маркер по одному лишь тексту забанил два ЗДОРОВЫХ узла на странице, которая
# РАЗОБРАЛАСЬ, — обычная карточка ЖК грузит скрипт SmartCaptcha, и подстрока
# "captcha" на ней есть всегда. Список маркеров создавался для объяснения УЖЕ
# СЛУЧИВШЕГОСЯ отказа разбора (`_describe_parse_miss` зовётся только оттуда);
# применять его как самостоятельный детектор — менять смысл признака.
if nb_state is None:
blocked = _blocked_by(html)
if blocked is not None:
browser.report_ban(f"cian newbuilding: {blocked}")
if nb_state is None:
logger.warning(
"Cian newbuilding %s: initialState extraction failed — %s",