feat(22d): catalog scraper — skip-today filter + force flag #348

Merged
lekss361 merged 1 commit from feat/22d-catalog-skip-today-force into main 2026-05-17 23:15:22 +00:00
3 changed files with 153 additions and 15 deletions

View file

@ -1254,6 +1254,14 @@ def trigger_ekburg_permits(
class TriggerKnCatalogObjectsRequest(BaseModel):
region_code: int = Field(default=66, ge=1, le=99)
max_objects: int | None = Field(default=None, ge=1, le=2000)
force: bool = Field(
default=False,
description=(
"True — игнорировать фильтр 'уже сегодня обновлён' и грузить ВСЕ объекты "
"последнего snapshot ('Загрузить все'). По умолчанию пропускает то, "
"что уже скраплено сегодня."
),
)
@router.post("/kn-catalog-objects")
@ -1268,12 +1276,16 @@ def trigger_kn_catalog_objects(
запуска (smoke-тест после деплоя или повторный pass для свежесозданных
объектов до next beat fire).
- max_objects=None берёт из job_settings (default 300 для beat).
- max_objects=None дефолтный лимит таска (300).
- max_objects=3 smoke-тест.
- force=True "Загрузить все": игнорирует skip-today, грузит всё подряд.
"""
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
kwargs: dict[str, Any] = {"region_code": payload.region_code}
kwargs: dict[str, Any] = {
"region_code": payload.region_code,
"force": payload.force,
}
if payload.max_objects is not None:
kwargs["max_objects"] = payload.max_objects
result = scrape_kn_catalog_objects.apply_async(kwargs=kwargs)
@ -1282,5 +1294,6 @@ def trigger_kn_catalog_objects(
"task_id": result.id,
"region_code": payload.region_code,
"max_objects": payload.max_objects,
"force": payload.force,
"queued_at": "now",
}

View file

@ -3,8 +3,11 @@
Дополняет ~25 NULL колонок в domrf_kn_objects из SSR-страниц каталога.
kn-API эти поля не возвращает они только на публичных страницах объектов.
Выбирает объекты где catalog_scraped_at IS NULL или устарело (>30 дней).
Ограничивает batch per run чтобы не нагружать сайт.
Selector logic:
- catalog_scraped_at IS NULL "новый" объект, всегда грузим
- DATE(catalog_scraped_at) < CURRENT_DATE сегодня ещё не обновлялся, грузим
- DATE(catalog_scraped_at) = CURRENT_DATE уже сегодня обновлён, пропускаем
- force=True игнорирует фильтр, загружает все объекты последнего snapshot
Beat schedule: вторник 04:00 UTC (в beat_schedule.py).
"""
@ -23,19 +26,24 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Запрос для выбора устаревших / ненаполненных объектов.
# Запрос для выбора объектов на скрап.
# Возвращает obj_id + snapshot_date одним запросом, чтобы избежать race condition:
# если между двумя запросами kn-scraper запишет новый snapshot — UPDATE по старой
# snapshot_date не затронет ни одной строки. MAX subquery ограничена тем же
# region_cd чтобы не захватить snapshot другого региона.
_SELECT_STALE_SQL = text(
#
# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня
# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today).
# При :force = true фильтр снимается — грузим все объекты последнего snapshot.
_SELECT_TARGETS_SQL = text(
"""
SELECT obj_id, snapshot_date
FROM domrf_kn_objects
WHERE region_cd = :region_code
AND (
catalog_scraped_at IS NULL
OR catalog_scraped_at < NOW() - INTERVAL '30 days'
CAST(:force AS boolean)
OR catalog_scraped_at IS NULL
OR DATE(catalog_scraped_at) < CURRENT_DATE
)
AND snapshot_date = (
SELECT MAX(snapshot_date)
@ -60,15 +68,16 @@ def scrape_kn_catalog_objects(
self: Any,
region_code: int = 66,
max_objects: int | None = None,
force: bool = False,
) -> dict[str, Any]:
"""Periodic catalog-object scrape.
Picks objects from domrf_kn_objects where catalog data missing OR stale
(catalog_scraped_at < NOW() - INTERVAL '30 days'). Limits per run.
Args:
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
max_objects: Максимум объектов за один run. Default 300.
force: Если True игнорирует фильтр "уже сегодня обновлён" и грузит
все объекты последнего snapshot (admin "Загрузить все"). По умолчанию
False пропускает то, что уже скраплено сегодня.
Returns:
dict с ключами: region_code, snapshot_date, obj_ids_count,
@ -92,8 +101,8 @@ def scrape_kn_catalog_objects(
try:
rows = (
db.execute(
_SELECT_STALE_SQL,
{"region_code": region_code, "max_objects": limit},
_SELECT_TARGETS_SQL,
{"region_code": region_code, "max_objects": limit, "force": force},
)
.mappings()
.all()
@ -106,12 +115,14 @@ def scrape_kn_catalog_objects(
if not obj_ids:
logger.info(
"scrape_kn_catalog_objects: no stale objects for region=%d, nothing to do",
"scrape_kn_catalog_objects: nothing to do for region=%d (force=%s)",
region_code,
force,
)
db.close()
return {
"region_code": region_code,
"force": force,
"obj_ids_count": 0,
"processed": 0,
"succeeded": 0,
@ -125,11 +136,12 @@ def scrape_kn_catalog_objects(
snapshot_date_val: date = rows[0]["snapshot_date"]
logger.info(
"scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d (limit=%d)",
"scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%d force=%s",
region_code,
snapshot_date_val,
len(obj_ids),
limit,
force,
)
try:
@ -149,6 +161,7 @@ def scrape_kn_catalog_objects(
result: dict[str, Any] = {
"region_code": region_code,
"force": force,
"snapshot_date": str(snapshot_date_val),
"obj_ids_count": len(obj_ids),
**stats,

View file

@ -0,0 +1,112 @@
"""Тесты для backend/app/workers/tasks/scrape_kn_catalog_objects.py.
Покрывают skip-today фильтр и force flag (admin "Загрузить все").
"""
from __future__ import annotations
from datetime import date
from typing import Any
from unittest.mock import MagicMock, patch
def _make_mock_db(rows: list[dict[str, Any]]) -> tuple[MagicMock, list[dict[str, Any]]]:
"""Mock DB session. Возвращает (db, captured_params)."""
captured: list[dict[str, Any]] = []
def execute_router(stmt: Any, params: Any = None) -> MagicMock:
if params is not None:
captured.append(params)
result = MagicMock()
result.mappings.return_value.all.return_value = rows
return result
db = MagicMock()
db.execute = execute_router
return db, captured
def test_skip_today_filter_active_by_default() -> None:
"""force=False (default) → :force = False передаётся в SQL → SQL пропустит
объекты с catalog_scraped_at = сегодня (логика в WHERE)."""
from app.workers.tasks import scrape_kn_catalog_objects as task_mod
db, captured = _make_mock_db(rows=[])
with patch.object(task_mod, "SessionLocal", return_value=db):
result = task_mod.scrape_kn_catalog_objects.run(region_code=66)
# SQL вызван 1 раз с force=False
assert len(captured) == 1
assert captured[0]["force"] is False
assert captured[0]["region_code"] == 66
# Пустой результат → ранний выход
assert result["obj_ids_count"] == 0
assert result["force"] is False
def test_force_true_passes_through_to_sql() -> None:
"""force=True → :force = True передаётся в SQL → SQL грузит все объекты."""
from app.workers.tasks import scrape_kn_catalog_objects as task_mod
db, captured = _make_mock_db(rows=[])
with patch.object(task_mod, "SessionLocal", return_value=db):
result = task_mod.scrape_kn_catalog_objects.run(region_code=66, force=True)
assert len(captured) == 1
assert captured[0]["force"] is True
assert result["force"] is True
def test_max_objects_passed_as_limit() -> None:
"""max_objects явный — должен прилететь в SQL bind как :max_objects."""
from app.workers.tasks import scrape_kn_catalog_objects as task_mod
db, captured = _make_mock_db(rows=[])
with patch.object(task_mod, "SessionLocal", return_value=db):
task_mod.scrape_kn_catalog_objects.run(region_code=66, max_objects=10)
assert captured[0]["max_objects"] == 10
def test_processes_rows_when_returned() -> None:
"""Если SELECT вернул строки — вызывается scrape_catalog_objects, snapshot_date
из первой строки."""
from app.workers.tasks import scrape_kn_catalog_objects as task_mod
rows = [
{"obj_id": 65136, "snapshot_date": date(2026, 5, 17)},
{"obj_id": 65137, "snapshot_date": date(2026, 5, 17)},
]
db, _ = _make_mock_db(rows=rows)
mock_stats = {"processed": 2, "succeeded": 2, "failed": 0, "skipped": 0}
with (
patch.object(task_mod, "SessionLocal", return_value=db),
patch(
"app.services.scrapers.domrf_catalog_object.scrape_catalog_objects",
return_value=mock_stats,
) as mock_scrape,
):
result = task_mod.scrape_kn_catalog_objects.run(region_code=66)
mock_scrape.assert_called_once()
call_kwargs = mock_scrape.call_args[1]
assert call_kwargs["obj_ids"] == [65136, 65137]
assert call_kwargs["snapshot_date"] == date(2026, 5, 17)
assert call_kwargs["region_code"] == 66
assert result["obj_ids_count"] == 2
assert result["snapshot_date"] == "2026-05-17"
def test_sql_contains_skip_today_predicate() -> None:
"""Smoke: финальный SQL содержит DATE(catalog_scraped_at) < CURRENT_DATE и
CAST(:force AS boolean) иначе фильтр не работает."""
from app.workers.tasks.scrape_kn_catalog_objects import _SELECT_TARGETS_SQL
sql = str(_SELECT_TARGETS_SQL).upper()
assert "DATE(CATALOG_SCRAPED_AT) < CURRENT_DATE" in sql
assert "CAST(:FORCE AS BOOLEAN)" in sql
assert "CATALOG_SCRAPED_AT IS NULL" in sql