diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index d9558635..50f73cce 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -1910,17 +1910,19 @@ def trigger_ekburg_permits( return {"task_id": result.id, "scope": scope, "queued_at": "now"} -# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24 -# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object -# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) — -# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession -# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1). -_WAF_COOLDOWN_GUARD_MSG = ( - "Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP " - "2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же " - "причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно " - "принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — " - "передай i_understand_waf_risk=true в теле запроса." +# Текст отказа guard'а блокировки DOM.РФ (#2443, #2445 D1). Зонды 20.08, 27.08 и +# 01.09: наш.дом.рф за StormWall отдаёт серверу «Доступ заблокирован [403]», таймера +# у этой блокировки нет. Beat-записи catalog-object и catalog-flat scrape'ов +# выключены по той же причине (см. beat_schedule.py); оба ad-hoc эндпоинта ниже +# ходят тем же /сервисы/* BrowserSession path family. Оператор решает по этому +# тексту, ставить ли флаг, поэтому он называет реальное условие (#3307). +_DOMRF_BLOCK_GUARD_MSG = ( + "Ad-hoc catalog-scrape заблокирован guard'ом: наш.дом.рф за StormWall отдаёт " + "этому серверу «Доступ заблокирован [403]» (зонды 20.08-01.09, issue #2443), " + "beat schedule для этого таска выключен по той же причине. Ожидание блокировку " + "не снимает, запуск отсюда даст только отказы. Передавай " + "i_understand_waf_risk=true, только когда сбор идёт через прокси и kn-прогон " + "принят по числу строк (#3307)." ) @@ -1952,20 +1954,20 @@ def trigger_kn_catalog_objects( """Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff, ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__). - Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc - запуска (smoke-тест после деплоя или повторный pass для свежесозданных - объектов до next beat fire). + Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py). + Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для + свежесозданных объектов). - max_objects=None → дефолтный лимит таска (300). - max_objects=3 → smoke-тест. - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд. - WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — - beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc - re-trigger без явного подтверждения оператора запрещён. + Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true — + beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc + запуск без явного подтверждения оператора запрещён (условие — #3307). """ if not payload.i_understand_waf_risk: - raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG) + raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG) from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects @@ -2022,12 +2024,12 @@ def trigger_kn_catalog_flats( - max_flats=3 → smoke-тест. - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash. - WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — - same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger - того же WAF-бана. + Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true — + same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же + блокировкой StormWall (условие — #3307). """ if not payload.i_understand_waf_risk: - raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG) + raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG) from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index b0cce749..35ebe25f 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -346,11 +346,13 @@ def build_beat_schedule() -> dict: # Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта # за ~5 недель полного обновления. # - # DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed - # extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession - # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет - # 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после - # cooldown 24-48h (проверить через targeted test). + # DISABLED с 2026-05-24. Исходная причина «hard-ban, cooldown 24-48h» оказалась + # догадкой: kn-прогоны 29-33 (03.06-28.06) шли успешно уже после 24.05. Зонды + # 20.08, 27.08 и 01.09 (#2443, #3307): наш.дом.рф стоит за StormWall (куки + # spid/spjs/spsc), IP Poincare получает «Доступ заблокирован [403]» — это + # блокировка, ожидание её не снимает. Каталог не собирался с 19.05. + # Включать только после решения по прокси и принятого по числу строк + # kn-прогона (#3307). Выключенность держит test_beat_schedule_domrf_catalog.py. # schedule["scrape-kn-catalog-objects-weekly"] = { # "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects", # "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК @@ -362,11 +364,11 @@ def build_beat_schedule() -> dict: # (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из # SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL. # - # DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP — - # каталог-квартиры используют тот же BrowserSession + /сервисы/* paths. + # DISABLED (как и object-level выше): 1) та же блокировка StormWall на IP + # Poincare — каталог-квартиры используют тот же BrowserSession + /сервисы/* paths. # 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и # свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет. - # Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом). + # Включать после прокси и принятого kn-прогона с hash (#3307), не по таймеру. # Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку — # напр. четверг 04:00 МСК. # schedule["scrape-kn-catalog-flats-weekly"] = { diff --git a/backend/app/workers/tasks/scrape_kn_catalog_flats.py b/backend/app/workers/tasks/scrape_kn_catalog_flats.py index 0e30d1c6..756e666f 100644 --- a/backend/app/workers/tasks/scrape_kn_catalog_flats.py +++ b/backend/app/workers/tasks/scrape_kn_catalog_flats.py @@ -24,8 +24,9 @@ Sequencing dependency (важно): эта задача НЕ дублирует 1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И 2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах. До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен -ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) — WAF-cooldown на -VPS IP; включается вручную после проверки targeted-тестом. +ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level): наш.дом.рф за +StormWall отдаёт «Доступ заблокирован [403]» (#2443), ожидание это не снимает; +включать после прокси и принятого kn-прогона (#3307). """ from __future__ import annotations @@ -116,7 +117,7 @@ def scrape_kn_catalog_flats( Concurrency: No Redis lock — consistent с sibling tasks (scrape_kn_catalog_objects и т.д.). - Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне + Beat отключён (блокировка StormWall, #2443); при ручном/ad-hoc запуске пересечение крайне маловероятно. Если случится: - UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id). - Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch. diff --git a/backend/app/workers/tasks/scrape_kn_catalog_objects.py b/backend/app/workers/tasks/scrape_kn_catalog_objects.py index 40d5b786..89ad9bd1 100644 --- a/backend/app/workers/tasks/scrape_kn_catalog_objects.py +++ b/backend/app/workers/tasks/scrape_kn_catalog_objects.py @@ -9,7 +9,7 @@ Selector logic: - DATE(catalog_scraped_at) = CURRENT_DATE — уже сегодня обновлён, пропускаем - force=True — игнорирует фильтр, загружает все объекты последнего snapshot -Beat schedule: вторник 04:00 UTC (в beat_schedule.py). +Beat-запись (вторник 04:00 МСК) выключена в beat_schedule.py: блокировка StormWall (#2443). """ from __future__ import annotations diff --git a/backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py b/backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py index db72ae82..643b786e 100644 --- a/backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py +++ b/backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py @@ -1,10 +1,11 @@ -"""Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах. +"""Тесты guard'а блокировки DOM.РФ (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах. POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же -/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24 -(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты -должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный -smoke-тест/re-trigger не углубил бан. +/сервисы/* BrowserSession path family, что закрыт StormWall (#2443). Beat schedule +для обоих тасков отключён по этой причине — эти эндпоинты должны отказывать в +запуске БЕЗ явного i_understand_waf_risk=true. Текст отказа — то, по чему оператор +решает, ставить ли флаг, поэтому он обязан называть реальное условие (#3307), а не +ожидание таймера: зонды 20.08-01.09 показали, что ждать нечего. """ from __future__ import annotations @@ -19,6 +20,15 @@ OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects" FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats" +def _assert_refusal_names_real_condition(detail: str) -> None: + assert "2443" in detail + assert "i_understand_waf_risk" in detail + # Условие снятия — прокси и принятый kn-прогон (#3307). Обещание «cooldown + # прошёл» подсказывало оператору обойти блокировку, которую ожидание не снимает. + assert "3307" in detail + assert "cooldown" not in detail.lower() + + def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock: result = MagicMock() result.id = task_id @@ -33,9 +43,7 @@ def test_kn_catalog_objects_refuses_without_override() -> None: resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66}) assert resp.status_code == 400 - detail = resp.json()["detail"] - assert "2443" in detail - assert "i_understand_waf_risk" in detail + _assert_refusal_names_real_condition(resp.json()["detail"]) mock_apply_async.assert_not_called() @@ -68,9 +76,7 @@ def test_kn_catalog_flats_refuses_without_override() -> None: resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66}) assert resp.status_code == 400 - detail = resp.json()["detail"] - assert "2443" in detail - assert "i_understand_waf_risk" in detail + _assert_refusal_names_real_condition(resp.json()["detail"]) mock_apply_async.assert_not_called() diff --git a/backend/tests/workers/test_beat_schedule_domrf_catalog.py b/backend/tests/workers/test_beat_schedule_domrf_catalog.py new file mode 100644 index 00000000..d2a2b954 --- /dev/null +++ b/backend/tests/workers/test_beat_schedule_domrf_catalog.py @@ -0,0 +1,38 @@ +"""#2443: каталожные DOM.РФ-скраперы не должны попасть в beat, пока стоит блокировка. + +С 01.09 наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]». +Решение «выключено до прокси» раньше жило только в комментарии с обещанием +«cooldown 24-48h» и месяцами читалось как таймер. Здесь оно проверяется по +значению: включить запись = осознанно поправить этот тест после #3307. +""" + +from __future__ import annotations + +from unittest.mock import patch + +_BLOCKED_TASKS = ( + "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects", + "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats", +) + + +def test_domrf_catalog_scrapers_not_scheduled_while_stormwall_blocks() -> None: + from app.workers import beat_schedule + + with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}): + schedule = beat_schedule.build_beat_schedule() + + # Контроль: статическая часть расписания реально построилась, иначе + # отсутствие задач ниже было бы зелёным по построению. + assert schedule["refresh-quarter-price-index"]["task"] == ( + "tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain" + ) + + # По task, а не по ключу: переименованный ключ не должен обойти проверку. + scheduled = { + name: entry["task"] for name, entry in schedule.items() if entry["task"] in _BLOCKED_TASKS + } + assert scheduled == {}, ( + f"каталог DOM.РФ включён в beat: {scheduled}. IP Poincare заблокирован " + "StormWall (#2443); включать только после прокси и принятого kn-прогона (#3307)" + ) diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 2f7dd5e7..cd8eab1e 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -1919,17 +1919,17 @@ export interface paths { * @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff, * ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__). * - * Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc - * запуска (smoke-тест после деплоя или повторный pass для свежесозданных - * объектов до next beat fire). + * Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py). + * Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для + * свежесозданных объектов). * * - max_objects=None → дефолтный лимит таска (300). * - max_objects=3 → smoke-тест. * - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд. * - * WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — - * beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc - * re-trigger без явного подтверждения оператора запрещён. + * Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true — + * beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc + * запуск без явного подтверждения оператора запрещён (условие — #3307). */ post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"]; delete?: never; @@ -1960,9 +1960,9 @@ export interface paths { * - max_flats=3 → smoke-тест. * - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash. * - * WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — - * same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger - * того же WAF-бана. + * Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true — + * same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же + * блокировкой StormWall (условие — #3307). */ post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"]; delete?: never;