From cb5e2d4f70ae3753914a0b697b3c49a653c89146 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 15:33:32 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D1=81=D0=B5=D1=80=D0=B8=D1=8F=20?= =?UTF-8?q?WAF-=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=B1=D0=B0=D1=82=D1=87?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0,=20=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=B3=D0=BE=D0=BD=D0=B8=D1=82=20=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B4=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=86=D0=B0=20(#2464?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrape_catalog_object гасил WafBlockedError и возвращал его как обычную неудачу, поэтому батч-цикл шёл дальше и слал ЖИВОЙ запрос на каждый оставшийся obj_id в уже забаненную сессию. Цена. Замер прода 20.08: в очереди 13200 объектов из 13801, а DOM.РФ отдаёт страницу «Доступ заблокирован [403]» с капчей (#2443). Один прогон «Загрузить все» выдал бы 13200 живых запросов в забаненную сессию — ровно то, что углубляет бан. Анти-бан-комментарий к BrowserSession в этом же файле предупреждает про тот же path family, из-за которого бан и случился. Правка из двух частей: per-object функция пробрасывает WAF (это не «объект не дошёл», а закрытая дверь), батч-цикл считает ПОДРЯД идущие блоки и прерывается на третьем. Порог не единица намеренно: одиночный блок бывает переходным, три подряд — стена. Прерванный батч помечается в stats (aborted_on_waf), а stats уходит в результат задачи через **stats — видно в логе прогона. Про двусторонность честно: части проверяются по-разному. • Проброс WAF — ДВУСТОРОННЕ. Тест подменяет fetch_catalog_object_html (настоящий источник блока) и гоняет НАСТОЯЩУЮ функцию: на origin/main «DID NOT RAISE», потому что там исключение гасится. • Предохранитель — логики этой на origin/main нет, поэтому тесты подменяют scrape_catalog_object и меряют новый цикл в изоляции (сколько объектов он успел тронуть). На двустороннюю проверку они не претендуют, и это написано в шапке файла. Первая версия теста этого не различала: подменяла scrape_catalog_object и на main падала с пробросом исключения — то есть по «нет такого кода», а не по неверному поведению. Перестроено. Контроли: одиночный блок не рвёт прогон; успех между блоками сбрасывает счётчик; обычный сбой сети по-прежнему гасится в False (иначе один битый объект рвал бы батч); здоровый батч проходит все объекты. Прогоны: tests/services/scrapers + tests/workers — 525 passed rc=0. Co-Authored-By: Claude Opus 5 --- .../services/scrapers/domrf_catalog_object.py | 46 ++++- .../scrapers/test_2464_catalog_waf_breaker.py | 169 ++++++++++++++++++ 2 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py diff --git a/backend/app/services/scrapers/domrf_catalog_object.py b/backend/app/services/scrapers/domrf_catalog_object.py index 86d1310b..c1756736 100644 --- a/backend/app/services/scrapers/domrf_catalog_object.py +++ b/backend/app/services/scrapers/domrf_catalog_object.py @@ -29,6 +29,10 @@ from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedEr logger = logging.getLogger(__name__) +# Сколько WAF-блоков ПОДРЯД прерывают батч (#2464). Одиночный блок бывает +# переходным (сессия перегреет cookies и восстановится), три подряд — стена. +_WAF_BREAKER_THRESHOLD = 3 + # URL шаблон страницы объекта в каталоге DOM.РФ. # Человекочитаемый вид: https://наш.дом.рф/сервисы/каталог-новостроек/объект/{obj_id} CATALOG_OBJECT_PATH = "/сервисы/каталог-новостроек/объект/{obj_id}" @@ -353,9 +357,12 @@ async def scrape_catalog_object( try: html = await fetch_catalog_object_html(session, obj_id) - except WafBlockedError as exc: - logger.warning("catalog_object WAF blocked obj_id=%d: %s", obj_id, exc) - return False + except WafBlockedError: + # #2464: WAF-блок — не «этот объект не дошёл», а закрытая дверь. Раньше он + # гасился здесь и возвращался как обычная неудача, поэтому батч-цикл шёл + # дальше и слал ЖИВОЙ запрос на каждый оставшийся obj_id в уже забаненную + # сессию. Пробрасываем: решение принимает предохранитель в батче. + raise except Exception as exc: logger.warning("catalog_object fetch failed obj_id=%d: %s", obj_id, exc) return False @@ -457,9 +464,40 @@ async def scrape_catalog_objects( # Idempotent — один вызов покрывает весь batch через этот BrowserSession. await session.warm_up() + # #2464: предохранитель на серию WAF-блоков. Замер 20.08: в очереди 13200 + # объектов из 13801, а DOM.РФ отдаёт страницу «Доступ заблокирован [403]» + # с капчей (#2443). Без предохранителя один прогон «Загрузить все» выдал бы + # 13200 живых запросов в забаненную сессию — ровно то, что углубляет бан + # (анти-бан-комментарий к BrowserSession выше про тот же path family). + # Порог не единица: одиночный блок бывает переходным, три подряд — стена. + consecutive_waf = 0 + for obj_id in obj_ids: stats["processed"] += 1 - ok = await scrape_catalog_object(db, session, obj_id, snapshot_date) + try: + ok = await scrape_catalog_object(db, session, obj_id, snapshot_date) + except WafBlockedError as exc: + consecutive_waf += 1 + stats["failed"] += 1 + logger.warning( + "catalog_object WAF blocked obj_id=%d (подряд %d/%d): %s", + obj_id, + consecutive_waf, + _WAF_BREAKER_THRESHOLD, + exc, + ) + if consecutive_waf >= _WAF_BREAKER_THRESHOLD: + stats["aborted_on_waf"] = 1 + logger.error( + "scrape_catalog_objects: %d WAF-блока подряд — прерываю батч," + " обработано %d из %d", + consecutive_waf, + stats["processed"], + len(obj_ids), + ) + break + continue + consecutive_waf = 0 if ok: stats["succeeded"] += 1 # Фиксируем сразу, а не одним commit'ом в конце (#2464). Раньше весь diff --git a/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py b/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py new file mode 100644 index 00000000..ad58d072 --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py @@ -0,0 +1,169 @@ +"""Серия WAF-блоков прерывает батч каталога, а не гонит его до конца (#2464). + +`scrape_catalog_object` гасил `WafBlockedError` и возвращал его как обычную неудачу, +поэтому цикл шёл дальше и слал ЖИВОЙ запрос на каждый оставшийся obj_id в уже +забаненную сессию. + +Цена. Замер прода 20.08.2026: в очереди 13200 объектов из 13801, а DOM.РФ отдаёт +страницу «Доступ заблокирован [403]» с капчей (#2443). Один прогон «Загрузить все» +выдал бы 13200 живых запросов в забаненную сессию — ровно то, что углубляет бан. +Анти-бан-комментарий к BrowserSession в этом же файле предупреждает про тот же +path family, из-за которого бан и случился. + +Порог намеренно не единица: одиночный блок бывает переходным, три подряд — стена. + +Про двусторонность — честно. Правка состоит из ДВУХ частей, и проверяются они по-разному: + + • `scrape_catalog_object` перестал глотать WafBlockedError. Это ДВУСТОРОННЕ: + test_per_object_reraises_waf подменяет `fetch_catalog_object_html` (настоящий + источник блока) и гоняет НАСТОЯЩУЮ функцию — на origin/main она возвращает False, + тест краснеет по «DID NOT RAISE». + + • предохранитель в батч-цикле. Логики этой на origin/main НЕТ, поэтому тесты ниже + подменяют `scrape_catalog_object` и меряют новый цикл в изоляции: сколько объектов + он успел тронуть. Красное на main здесь означало бы «нет такого кода», а не + «неверное поведение», — на двустороннюю проверку они не претендуют. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import asyncio +from datetime import date +from typing import Any +from unittest.mock import MagicMock, patch + +_SNAPSHOT = date(2026, 8, 20) +_OBJ_IDS = list(range(1, 21)) # двадцать объектов — «до конца» хорошо отличимо от «прервались» + + +class _FakeSession: + def __init__(self, *_a: Any, **_kw: Any) -> None: + pass + + async def __aenter__(self) -> _FakeSession: + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def warm_up(self) -> None: + return None + + +def _run(behaviour) -> tuple[dict[str, Any], list[int]]: + """behaviour(obj_id) → True | False | raises. Возвращает (stats, тронутые obj_id).""" + from app.services.scrapers import domrf_catalog_object as mod + + touched: list[int] = [] + + async def _fake(_db: Any, _s: Any, obj_id: int, _d: date) -> bool: + touched.append(obj_id) + return behaviour(obj_id) + + with ( + patch.object(mod, "BrowserSession", _FakeSession), + patch.object(mod, "scrape_catalog_object", _fake), + ): + stats = asyncio.run( + mod.scrape_catalog_objects( + db=MagicMock(), obj_ids=list(_OBJ_IDS), snapshot_date=_SNAPSHOT, region_code=66 + ) + ) + return stats, touched + + +def _waf(_obj_id: int) -> bool: + from app.services.scrapers.stealth import WafBlockedError + + raise WafBlockedError("HTTP 200 text/html — страница блокировки") + + +def test_waf_streak_aborts_the_batch() -> None: + """Все запросы блокируются → прогон обязан остановиться на пороге, а не на 20-м. + + На origin/main WAF гасится внутри и цикл проходит все 20 объектов. + """ + stats, touched = _run(_waf) + + assert len(touched) == 3, ( + f"тронуто объектов: {len(touched)} из {len(_OBJ_IDS)} — предохранитель не сработал, " + "каждый следующий это живой запрос в забаненную сессию" + ) + assert stats.get("aborted_on_waf") == 1, f"батч не помечен как прерванный: {stats}" + assert stats["processed"] == 3 + + +def test_single_waf_does_not_abort() -> None: + """Контроль: одиночный блок не рвёт прогон — он бывает переходным. + + Ловит «починку» через abort-on-first: тогда один переходный блок стоил бы + всей ночной докачки. + """ + stats, touched = _run(lambda oid: (_waf(oid) if oid == 5 else True)) + + assert len(touched) == len(_OBJ_IDS), f"прогон оборвался на одиночном блоке: {len(touched)}" + assert "aborted_on_waf" not in stats + assert stats["succeeded"] == len(_OBJ_IDS) - 1 + + +def test_streak_resets_after_success() -> None: + """Контроль: успех между блоками сбрасывает счётчик — считаем ПОДРЯД идущие. + + Без сброса два разрозненных блока плюс третий где-то в конце оборвали бы прогон. + """ + blocked = {2, 4, 6, 8, 10} + stats, touched = _run(lambda oid: (_waf(oid) if oid in blocked else True)) + + assert len(touched) == len(_OBJ_IDS), f"прогон оборвался: тронуто {len(touched)}" + assert "aborted_on_waf" not in stats + assert stats["failed"] == len(blocked) + + +def test_healthy_batch_untouched() -> None: + """Контроль: без блоков прогон проходит все объекты и ничего не помечает.""" + stats, touched = _run(lambda _oid: True) + + assert len(touched) == len(_OBJ_IDS) + assert stats["succeeded"] == len(_OBJ_IDS) + assert "aborted_on_waf" not in stats + + +def test_per_object_reraises_waf() -> None: + """ДВУСТОРОННЯЯ часть: настоящая scrape_catalog_object обязана пробросить WAF. + + Подменяется `fetch_catalog_object_html` — настоящий источник блока, — а сама + функция работает как в проде. На origin/main она гасит исключение и возвращает + False, поэтому тест краснеет по «DID NOT RAISE». + """ + import pytest + + from app.services.scrapers import domrf_catalog_object as mod + from app.services.scrapers.stealth import WafBlockedError + + async def _blocked(_session: Any, _obj_id: int) -> str: + raise WafBlockedError("HTTP 200 text/html — страница блокировки") + + with patch.object(mod, "fetch_catalog_object_html", _blocked): + with pytest.raises(WafBlockedError): + asyncio.run(mod.scrape_catalog_object(MagicMock(), MagicMock(), 42, _SNAPSHOT)) + + +def test_per_object_still_swallows_ordinary_errors() -> None: + """Контроль к предыдущему: обычный сбой сети по-прежнему гасится в False. + + Ловит «починку», которая начала бы пробрасывать вообще всё — тогда один битый + объект рвал бы батч. + """ + from app.services.scrapers import domrf_catalog_object as mod + + async def _broken(_session: Any, _obj_id: int) -> str: + raise RuntimeError("оборвалось соединение на одном объекте") + + with patch.object(mod, "fetch_catalog_object_html", _broken): + assert ( + asyncio.run(mod.scrape_catalog_object(MagicMock(), MagicMock(), 42, _SNAPSHOT)) is False + ) -- 2.45.3