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
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:
parent
a899cb9b1f
commit
6e34928dd4
2 changed files with 211 additions and 4 deletions
|
|
@ -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). Раньше весь
|
||||
|
|
|
|||
169
backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py
Normal file
169
backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py
Normal 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
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue