From b2ff92afbbaecba98f46c67018d0e5886984500d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:09:44 +0500 Subject: [PATCH 1/2] =?UTF-8?q?beat:=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=20DOM.=D0=A0=D0=A4=20=D0=B2=D1=8B=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=20=D0=B8=D0=B7-=D0=B7=D0=B0=20=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20StormW?= =?UTF-8?q?all,=20=D0=B0=20=D0=BD=D0=B5=20=C2=AB=D0=B4=D0=BE=20cooldown?= =?UTF-8?q?=C2=BB=20(#2443)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Комментарии у закомментированных записей scrape-kn-catalog-objects-weekly и scrape-kn-catalog-flats-weekly обещали «возврат после cooldown 24-48h (проверить через targeted test)». Тест проведён 20.08, 27.08 и 01.09: кулдауна нет (kn-прогоны 29-33 шли в июне, уже после «бана» 24.05), с 01.09 наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]». Комментарии теперь называют факт и условие включения (прокси и принятый kn-прогон, #3307). Решение больше не живёт только в комментарии: тест проверяет по значению (по task, не по ключу), что ни одна из двух задач не попала в собранное beat-расписание, и в тексте падения ведёт в #3307. Co-Authored-By: Claude Opus 5 --- backend/app/workers/beat_schedule.py | 18 +++++---- .../test_beat_schedule_domrf_catalog.py | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 backend/tests/workers/test_beat_schedule_domrf_catalog.py 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/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)" + ) -- 2.45.3 From 7cd15aab711967326bd526a20c129eb8fa7f5ac3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:58:31 +0500 Subject: [PATCH 2/2] =?UTF-8?q?=D0=9E=D1=82=D0=BA=D0=B0=D0=B7=20=D1=80?= =?UTF-8?q?=D1=83=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20DOM?= =?UTF-8?q?.=D0=A0=D0=A4=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BE=D0=B2=D0=B5=D1=82=D1=83=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20cooldown=20(#2443)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #3566: обещание таймера, убранное из beat, осталось в тексте HTTP 400 эндпоинтов /admin/scrape/kn-catalog-objects и /kn-catalog-flats. Отказ предлагал передать i_understand_waf_risk=true, «если WAF cooldown прошёл», то есть подсказывал оператору обойти блокировку, которую ожидание не снимает (зонды 20.08-01.09: StormWall, «Доступ заблокирован [403]»). Теперь отказ называет StormWall и реальное условие: прокси и kn-прогон, принятый по числу строк (#3307). Константа переименована в _DOMRF_BLOCK_GUARD_MSG. Докстринги эндпоинтов и задач scrape_kn_catalog_flats/objects больше не говорят про WAF cooldown и про «вторник 04:00 UTC» у выключенной записи с расписанием в МСК. api-types.ts перегенерирован как в CI-гейте openapi-codegen-check, изменились только два докстринга. Тесты отказа проверяют значение detail: #3307 есть, cooldown нет. Поведение guard'а (400 без флага, задача не ставится) не менялось. Co-Authored-By: Claude Opus 5 --- backend/app/api/v1/admin_scrape.py | 46 ++++++++++--------- .../workers/tasks/scrape_kn_catalog_flats.py | 7 +-- .../tasks/scrape_kn_catalog_objects.py | 2 +- .../test_admin_scrape_kn_catalog_waf_guard.py | 28 ++++++----- frontend/src/lib/api-types.ts | 18 ++++---- 5 files changed, 55 insertions(+), 46 deletions(-) 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/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/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; -- 2.45.3