From 876bf378e966c6673aef5866a46ada61dd443fb7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 9 Aug 2026 22:50:38 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/cian):=20=D0=B1=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=83=D0=B7=D0=B5=D0=BB=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20=D0=9D=D0=95?= =?UTF-8?q?=D0=A2,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=BE=20=D1=81=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=D1=83=20=C2=ABcaptcha=C2=BB=20(#2767)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живая регрессия предыдущего 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 --- .../backend/tests/test_2767_cian_waf_block.py | 42 +++++++++++++++++++ .../scraper_kit/providers/cian/newbuilding.py | 27 +++++++----- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/backend/tests/test_2767_cian_waf_block.py b/tradein-mvp/backend/tests/test_2767_cian_waf_block.py index 1269018d..660db0f7 100644 --- a/tradein-mvp/backend/tests/test_2767_cian_waf_block.py +++ b/tradein-mvp/backend/tests/test_2767_cian_waf_block.py @@ -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 = ( + '" +) + + @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 → пул. diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py index a2d19d77..89034166 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py @@ -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",