feat(22d): catalog scraper — skip-today filter + force flag (#348)
This commit is contained in:
parent
c363ea133f
commit
f9adb5cc2f
3 changed files with 153 additions and 15 deletions
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
112
backend/tests/workers/test_scrape_kn_catalog_objects.py
Normal file
112
backend/tests/workers/test_scrape_kn_catalog_objects.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue