fix(ptica): серия WAF-блоков прерывает батч каталога, а не гонит его до конца (#2464) (#2971)
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 4m26s
Deploy / deploy (push) Successful in 1m33s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
Deploy / changes (push) Successful in 7s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 1m54s

This commit is contained in:
bot-backend 2026-08-20 10:54:11 +00:00
parent a899cb9b1f
commit 6e34928dd4
2 changed files with 211 additions and 4 deletions

View file

@ -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). Раньше весь

View file

@ -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
)