gendesign/tradein-mvp/backend/app/services/product_handlers.py
bot-backend 59482cae85
Some checks failed
CI Trade-In / frontend-checks (pull_request) Successful in 1m57s
CI Trade-In / backend-tests (pull_request) Failing after 5m27s
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(mera): витрина сделок в расписании, дата прогона на странице (#3469)
Задача landing_showcase_deals не запускалась вообще: строки в
scrape_schedules не было (0 строк по '%showcase%' на проде 12.09), а в
реестре product_handlers — обработчика. Пересчёт был ручным шагом, и
лэндинг показывал прогон от 30.08 — тринадцать суток.

Что сделано:

- Handler `landing_showcase_deals` в product_handlers: тело задачи
  писалось под `python -m` и про run_id не знает, поэтому done/failed
  ставит обработчик (как у refresh_search_matview).
- Миграция 303 сеет расписание: enabled=true, окно 06:00–07:00 UTC,
  interval_days=1. Такт суточный не из-за данных — сделки Росреестра
  квартальные, — а из-за кода: прогноз считает тот же спайн оценщика,
  что и боевой расчёт, и любой деплой меняет числа на витрине, не
  трогая ни одной сделки.
- Эта же строка заводит витрину в СУЩЕСТВУЮЩИЙ монитор свежести:
  сводка просроченных источников (emit_stale_digest, #2670) ходит по
  включённым расписаниям и бьёт ERROR → GlitchTip, когда источник
  молчит дольше 3× своего такта. Своего монитора не заводим: витрина
  была невидима не потому, что сводка не умеет про неё говорить, а
  потому, что источника для сводки не существовало.
- На странице под таблицей — дата прогона рядом со счётчиками:
  «Витрина пересчитана 30.08.2026». computed_at ручка /showcase отдавала
  и раньше, фронт его не показывал; даты нет — предложения нет.

Тесты: обработчик резолвится тем же resolve_handler, что и боевой
_dispatch; миграция на живой БД реально кладёт строку и не задваивает
её при повторе; настоящий запрос сводки видит витрину и отдаёт её
просроченной после 3× такта (число тактов — литерал из приёмки, не
константа кита: взятое из неё ожидание уезжало вместе с ней и держало
тесты зелёными при факторе 3650).

Closes #3469

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:05:19 +05:00

1023 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Продуктовые Handler'ы для kit-scheduler (#2192, strangler-миграция).
`build_product_handlers(ctx)` собирает реестр НЕ-sweep source'ов, тело которых
осталось в `app` (rosreestr_dkp / sber_index / deactivate_stale_* / *_backfill /
cadastral_geo_match / house_* / proxy_healthcheck / …). Kit-native sweep-оркестраторы
(avito/yandex/cian/domclick full-load) регистрируются самим `build_registry` через
`_default_kit_handlers` — здесь их НЕТ. Исключение: `domclick_city_sweep` (#3264) —
kit-native тело осталось прежним (`run_domclick_city_sweep`), но здесь переопределён
Handler, который ДО вызова читает БД за куки сессии (kit не имеет права на app.*/БД,
см. докстринг `_job_domclick_city_sweep` ниже); `build_registry` явно допускает такое
переопределение продуктовым ключом.
Дизайн-инвариант: НЕ дублируем логику. Каждый `job` переиспользует существующую боевую
джоб-функцию (`app.services.scheduler.import_rosreestr_dkp`, `app.tasks.*`,
`app.services.*`) — то же тело, что крутит боевой `trigger_*_run`. Kit `_dispatch` уже
делает claim + fresh-session + spawn + close, поэтому `job` содержит ТОЛЬКО работу
(без claim/spawn-boilerplate). Run-lifecycle (mark_done/mark_failed/heartbeat) для
джоб, что не владеют им сами, делается через инжектированный `ctx.runs`.
Ship-dark: модуль импортируется лишь когда `settings.use_kit_scheduler` True
(scheduler_main._run_kit_scheduler). Боевой `app.services.scheduler` не тронут.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
from scraper_kit.orchestration import runs as kit_runs
from scraper_kit.orchestration.scheduler import (
Handler,
reschedule_after_minutes,
)
from scraper_kit.orchestration.scheduler import (
_defer_next_run_at as kit_defer_next_run_at,
)
from scraper_kit.orchestration.scheduler import (
_pick_resume as kit_pick_resume,
)
if TYPE_CHECKING:
from scraper_kit.orchestration.scheduler import SchedulerContext
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ── cian_history_backfill — cookie-gated backfill ────────────────────────────
# Машиночитаемые причины пропуска (#2658) — пишутся в scrape_runs.error строки
# со status='skipped'. Отделены от kit-причин (already_running и т.п.): по слагу
# видно, встал ли сбор из-за кук или из-за конкурентного прогона.
SKIP_CIAN_COOKIES_MISSING = "cian_cookies_missing"
SKIP_CIAN_COOKIES_EXPIRED = "cian_cookies_expired"
SKIP_CIAN_COOKIES_INVALID = "cian_cookies_invalid"
def _alert_cian_cookies(source: str, detail: str) -> None:
"""Громкий алерт «сбор встал из-за кук» — logger.error, НЕ capture_message(warning).
В scraper-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR)
(scheduler_main.py) — ERROR-запись сама становится событием, а прежний
`capture_message(..., level="warning")` до этого уровня не дотягивал (и стоял в
недостижимой ветке, см. докстринг _cian_pre_claim). Заодно причина остаётся в
docker-логах и в строке scrape_runs, которая переживает редеплой.
"""
logger.error(
"scheduler: %s пропущен — %s. Перезалейте куки Циана через админку "
"(до этого backfill истории стоит)",
source,
detail,
)
async def _cian_pre_claim(db: Session, schedule_row: dict[str, Any], ctx: SchedulerContext) -> bool:
"""Pre-claim gate: проверить наличие/валидность cian-cookies ДО claim (#1522).
Cookies отсутствуют/протухли → пишем строку прогона status='skipped' с причиной,
двигаем next_run_at на следующее окно и skip (иначе get_due_schedules переотбирает
schedule каждые 60с и verify_session долбит Cian круглосуточно).
#2658 — что было не так. Первая ветка (load_session вернул None) молчала: warning в
docker-лог, сдвиг next_run_at, `return False`. Ни строки в scrape_runs, ни изменения
last_run_at — снаружи 37 дней простоя выглядели как «всё по расписанию». Sentry-алерт
стоял во ВТОРОЙ ветке (verify_session вернул None), до которой на протухших куках
исполнение не доходит НИКОГДА: load_session сам фильтрует expires_at_estimate > NOW()
и отдаёт None ещё в первой. Теперь громко в обеих + предупреждение ЗАРАНЕЕ, пока куки
ещё валидны (COOKIE_EXPIRY_WARN_DAYS) — обновление кук ручное, ему нужен запас.
"""
from app.services.cian_session import (
COOKIE_EXPIRY_WARN_DAYS,
load_session,
session_expires_at,
verify_session,
)
source: str = schedule_row["source"]
now = datetime.now(tz=UTC)
cookies = load_session(db)
if cookies is None:
expires_at = session_expires_at(db)
if expires_at is None:
reason, detail = SKIP_CIAN_COOKIES_MISSING, "кук Циана нет в БД"
elif expires_at <= now:
reason = SKIP_CIAN_COOKIES_EXPIRED
detail = (
f"куки Циана протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)"
)
else:
reason = SKIP_CIAN_COOKIES_INVALID
detail = "куки Циана помечены невалидными (last_invalid_at)"
_alert_cian_cookies(source, detail)
kit_runs.mark_skipped(db, source=source, reason=reason, details=detail)
kit_defer_next_run_at(db, schedule_row)
return False
state = await verify_session(cookies)
if state is None:
# verify вернул именно None (401 / isAuthenticated=false) — куки числятся
# валидными по сроку, но Циан их не принимает. Sentinel-ответы (бан / источник
# недоступен / сменилась вёрстка) сюда НЕ попадают, они truthy — см. cian_session.
detail = "Циан не принимает куки (разлогин)"
_alert_cian_cookies(source, detail)
kit_runs.mark_skipped(db, source=source, reason=SKIP_CIAN_COOKIES_INVALID, details=detail)
kit_defer_next_run_at(db, schedule_row)
return False
# Куки рабочие — предупреждаем, пока есть время их обновить без простоя сбора.
# valid_only=True: срок ИМЕННО той записи, которую взял load_session (при нескольких
# аккаунтах свежайшая-любая может быть чужой протухшей строкой).
expires_at = session_expires_at(db, valid_only=True)
if expires_at is not None and expires_at - now <= timedelta(days=COOKIE_EXPIRY_WARN_DAYS):
logger.error(
"scheduler: куки Циана протухнут %s (осталось %.1f дн.) — обновите заранее, "
"иначе %s встанет молча",
expires_at.date().isoformat(),
(expires_at - now).total_seconds() / 86400,
source,
)
return True
async def _job_cian_history_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.services.scheduler import _execute_cian_backfill
await _execute_cian_backfill(db, run_id=run_id, params=params)
# ── rosreestr_dkp_import — sync FDW-импорт в executor ─────────────────────────
async def _job_rosreestr_dkp(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.services.scheduler import import_rosreestr_dkp
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, import_rosreestr_dkp, db, run_id, params)
# ── listing_source_snapshot — sync DB-snapshot в executor ────────────────────
# params прокинуты (#2607) — snapshot_listing_sources теперь читает budget_sec из
# default_params (SET LOCAL statement_timeout, см. app/tasks/listing_source_snapshot.py).
async def _job_listing_source_snapshot(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.listing_source_snapshot import snapshot_listing_sources
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, snapshot_listing_sources, db, run_id, params)
# ── asking_to_sold_ratio_refresh — sync re-derive в executor ─────────────────
async def _job_asking_to_sold_ratio(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.asking_to_sold_ratio import recompute_asking_to_sold_ratios
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, recompute_asking_to_sold_ratios, db, run_id)
# ── deal_city_price_bands_refresh — sync tier-aware re-derive в executor ──────
async def _job_deal_city_price_bands_refresh(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.deal_city_price_bands_refresh import refresh_deal_city_price_bands
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, refresh_deal_city_price_bands, db, run_id)
# ── refresh_search_matview — REFRESH MATVIEW CONCURRENTLY (own connection) ────
async def _job_refresh_search_matview(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.refresh_search_matview import refresh_search_matview
loop = asyncio.get_event_loop()
try:
# refresh_search_matview() держит собственное autocommit-соединение
# (REFRESH CONCURRENTLY нельзя внутри транзакции).
await loop.run_in_executor(None, refresh_search_matview)
ctx.runs.mark_done(db, run_id, {})
except Exception:
logger.exception("scheduler: refresh_search_matview crashed run_id=%d", run_id)
ctx.runs.mark_failed(db, run_id, "refresh_search_matview failed", {})
# ── yandex_address_backfill — async, owns lifecycle ──────────────────────────
async def _job_yandex_address_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.yandex_address_backfill import run_yandex_address_backfill
await run_yandex_address_backfill(db, run_id=run_id, params=params)
# ── deactivate_stale_* — sync UPDATE в executor (wildcard-семейство) ──────────
async def _job_deactivate_stale(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.core.config import settings as _settings
from app.tasks.deactivate_stale_avito import (
CAP_MULT,
DEFAULT_FLOOR_DROP_RATIO,
DEFAULT_MAX_DEACTIVATED,
DEFAULT_MIN_CONFIRMATIONS,
DEFAULT_MIN_FLOOR_PAIRS,
DEFAULT_REVISIT_FLOOR_QUANTILE,
deactivate_stale_listings,
)
listing_source: str = params.get("listing_source", "avito")
ttl_days: int = params.get("ttl_days", _settings.avito_stale_ttl_days)
segments: list[str] | None = params.get("segments")
staleness_column: str = params.get("staleness_column", "last_seen_at")
# Гейт по здоровью сбора (#2659) включён по умолчанию: незасеянное расписание
# получает страховочный порог, а не «деактивируй вслепую». Посчитанные по
# источнику пороги приходят из default_params (миграция 219).
min_confirmations: int = params.get("min_confirmations", DEFAULT_MIN_CONFIRMATIONS)
# Пол TTL по измеренному циклу переобхода (#2659) — тоже включён по умолчанию:
# незасеянное расписание не должно снимать объявления по порогу ниже собственного
# хвоста обхода. Снять ручку вручную: revisit_floor_quantile = 0.
revisit_floor_quantile: float = params.get(
"revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE
)
# Пустой (NULL) listing_segment -- легаси-строки до миграции 011 + жертвы
# отсутствующего COALESCE в ON CONFLICT (base.py upsert никогда не перезаписывает
# listing_segment на повторном скрейпе). Отдельный явный предикат IS NULL, а не
# элемент :segments (ANY(...) никогда не матчит NULL) -- см. deactivate_stale_avito.py.
null_segment_only: bool = params.get("null_segment_only", False)
# Потолок эффективного TTL (см. CAP_MULT в deactivate_stale_avito.py) — множитель,
# а не голая константа: источник с непропорционально длинным хвостом переобхода
# относительно своего ttl_days переопределяет его через default_params (ключ
# "cap_mult"), не трогая дефолт для остальных источников.
cap_mult: float = params.get("cap_mult", CAP_MULT)
# Гейт деградации пола (PR-B, #2659 продолжение) -- включён по умолчанию, тот
# же принцип, что у min_confirmations/revisit_floor_quantile выше: незасеянное
# расписание получает страховку, а не «деактивируй вслепую».
min_floor_pairs: int = params.get("min_floor_pairs", DEFAULT_MIN_FLOOR_PAIRS)
floor_drop_ratio: float = params.get("floor_drop_ratio", DEFAULT_FLOOR_DROP_RATIO)
# Аварийный (не рабочий) потолок объёма снятия за один прогон -- см.
# DEFAULT_MAX_DEACTIVATED в deactivate_stale_avito.py.
max_deactivated: int = params.get("max_deactivated", DEFAULT_MAX_DEACTIVATED)
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: deactivate_stale_listings(
db,
run_id,
listing_source=listing_source,
ttl_days=ttl_days,
segments=segments,
staleness_column=staleness_column,
min_confirmations=min_confirmations,
revisit_floor_quantile=revisit_floor_quantile,
null_segment_only=null_segment_only,
cap_mult=cap_mult,
min_floor_pairs=min_floor_pairs,
floor_drop_ratio=floor_drop_ratio,
max_deactivated=max_deactivated,
),
)
# ── sber_index_pull — async, owns lifecycle ──────────────────────────────────
async def _job_sber_index_pull(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.sber_index_pull import run_sber_index_pull
await run_sber_index_pull(db, run_id=run_id, params=params)
# ── rosreestr_quarter_poll — async, owns lifecycle ───────────────────────────
async def _job_rosreestr_quarter_poll(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.rosreestr_quarter_poll import run_rosreestr_quarter_poll
await run_rosreestr_quarter_poll(db, run_id=run_id, params=params)
# ── deals_freshness_monitor — sync DB-only freshness check в executor ─────────
async def _job_deals_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.deals_freshness_monitor import check_deals_freshness
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, check_deals_freshness, db, run_id, params)
# ── landing_stats_refresh — sync DB-only пересчёт витрины в executor ─────────
async def _job_landing_stats(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.landing_stats import refresh_landing_stats
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
# ── landing_showcase_deals — sync пересчёт витрины сделок в executor ─────────
async def _job_landing_showcase_deals(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Пересчёт витрины сделок публичного лэндинга (#3469).
ЛАЙФСАЙКЛ ПРОГОНА ВЕДЁТ HANDLER, а не задача. `refresh_landing_showcase_deals`
писалась под ручной запуск (`python -m app.tasks.landing_showcase_deals`) и про
`run_id` ничего не знает — тот же случай, что у `_job_refresh_search_matview`,
и решается так же: done/failed ставим здесь.
Параметры берём ИЗ РАСПИСАНИЯ только те, что в нём есть: дефолты живут в
сигнатуре задачи, и повтор их здесь дал бы два места, которые разъедутся.
"""
from app.tasks.landing_showcase_deals import refresh_landing_showcase_deals
kwargs = {k: params[k] for k in ("sample", "since", "limit", "city") if k in params}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(
None, lambda: refresh_landing_showcase_deals(db, **kwargs)
)
ctx.runs.mark_done(db, run_id, counters)
except Exception:
logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id)
ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {})
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
async def _job_sber_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.sber_freshness_monitor import check_sber_freshness
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, check_sber_freshness, db, run_id, params)
# ── newbuilding_enrich — async, owns lifecycle ───────────────────────────────
async def _job_newbuilding_enrich(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.newbuilding_enrich_backfill import run_newbuilding_enrich
await run_newbuilding_enrich(db, run_id=run_id, params=params)
# ── yandex_newbuilding_sweep — async, lifecycle в job ────────────────────────
async def _job_yandex_newbuilding_sweep(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.yandex_newbuilding_sweep import enrich_yandex_newbuilding_sweep
try:
result = await enrich_yandex_newbuilding_sweep(
db,
limit=int(params.get("limit", 5)),
request_delay_sec=float(params.get("request_delay_sec", 8.0)),
city=str(params.get("city", "ekaterinburg")),
)
counters = result.to_dict()
# #2860: прогон, который обработал дома и не разрешил НИ ОДНОГО, успешным
# называть нельзя. Четырнадцать таких прогонов подряд (16.07-17.08.2026)
# стояли `done` с пустым error_text — механизм исполнялся, счётчик был
# честный, вывод из него не делал никто, и очередь тихо росла 351 → 397.
#
# НЕ подгоняем счётчик: если дома действительно не разрешаются, честный
# исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля.
processed = int(counters.get("processed") or 0)
succeeded = int(counters.get("succeeded") or 0)
if result.no_proxy_stop:
# #3197: прогон оборван на пустом пуле — к площадке не ходили вовсе.
# Это отказ нашей инфраструктуры, а не «ЖК не разрешились»: называть
# такой прогон успешным нельзя, и причина должна быть отличима.
#
# INFO, как у соседей (scheduler.py:175, avito_detail_backfill.py:1050,
# domclick:626): причина уже записана в mark_failed + counters.no_proxy_stop=1,
# а ERROR ставил её в один разряд с падением задачи (logger.exception ниже).
logger.info(
"yandex_newbuilding_sweep run_id=%d: пул прокси пуст — прогон оборван "
"(обработано %d)",
run_id,
processed,
)
ctx.runs.mark_failed(
db,
run_id,
"пул прокси пуст — прогон оборван, к площадке не ходили",
counters,
)
elif processed > 0 and succeeded == 0:
logger.warning(
"yandex_newbuilding_sweep run_id=%d: обработано %d, разрешено 0 — "
"помечаю прогон неуспешным",
run_id,
processed,
)
ctx.runs.mark_failed(
db,
run_id,
f"обработано {processed} домов, разрешено 0 — полный отказ разрешения slug",
counters,
)
else:
ctx.runs.mark_done(db, run_id, counters)
except Exception:
logger.exception("scheduler: enrich_yandex_newbuilding_sweep crashed run_id=%d", run_id)
try:
ctx.runs.mark_failed(db, run_id, "crashed", {})
except Exception:
pass
# ── geocode_missing_listings — async, owns lifecycle ─────────────────────────
async def _job_geocode_missing_listings(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.geocode_missing import run_geocode_missing_listings
await run_geocode_missing_listings(db, run_id=run_id, params=params)
# ── avito_detail_backfill — async, owns lifecycle ────────────────────────────
async def _job_avito_detail_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.avito_detail_backfill import run_avito_detail_backfill
await run_avito_detail_backfill(db, run_id=run_id, params=params)
# ── yandex_detail_backfill — async, owns lifecycle ───────────────────────────
async def _job_yandex_detail_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.yandex_detail_backfill import run_yandex_detail_backfill
await run_yandex_detail_backfill(db, run_id=run_id, params=params)
# ── domclick_detail_backfill — async, owns lifecycle (issue #2000 Layer B) ───
async def _job_domclick_detail_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.domclick_detail_backfill import run_domclick_detail_backfill
await run_domclick_detail_backfill(db, run_id=run_id, params=params)
# ── domclick_city_sweep — override kit-native с инъекцией куки сессии (#3264) ─
# Kit-native `_job_domclick_city_sweep` (scraper_kit.orchestration.scheduler) ходит на
# bff-search-web.domclick.ru БЕЗ кук: kit не имеет права импортировать app.* / читать БД
# (strangler-инвариант #2133). Каждый запрос свипа поэтому был обязан решать QRATOR PoW
# с нуля — прод 30.08 (прогон 5330): 9/9 запросов зависли на challenge, 0 листингов.
# `domclick_detail_backfill` (добор карточек) уже решает эту задачу инъекцией того же
# снимка (domclick_session.load_session) — здесь тот же приём для свипа.
#
# Регистрируем ЗДЕСЬ (app-side) под тем же ключом "domclick_city_sweep", а не правим
# kit: build_registry явно допускает переопределение kit-native продуктовым Handler'ом
# (см. докстринг build_registry — "последнее слово за продуктом"), а строгий запрет на
# app.* внутри kit при этом не нарушается — БД читает только этот модуль.
async def _job_domclick_city_sweep(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
from app.services import domclick_session as domclick_session_svc
# None — сессии в БД нет либо протухла (load_session сам фильтрует
# expires_at_estimate > NOW()). Это НЕ авария: свип продолжает работать без
# инъекции, ровно как до #3264, только логируем факт один раз для видимости.
cookies = domclick_session_svc.load_session(db)
if cookies is None:
logger.info(
"domclick_city_sweep run_id=%d: куки Sber ID сессии отсутствуют/протухли — "
"свип идёт без инъекции (см. #3264)",
run_id,
)
await run_domclick_city_sweep(
db,
run_id=run_id,
config=ctx.config,
matcher=ctx.matcher,
shutdown_requested=ctx.shutdown_requested,
proxy_provider=ctx.proxy_provider,
city_id=int(params.get("city_id", 4)),
rooms=params.get("rooms"),
pages=int(params.get("pages_per_anchor", 5)),
request_delay_sec=float(params.get("request_delay_sec", 6.0)),
resume_run_id=kit_pick_resume(db, run_id),
cookies=cookies,
)
# ── house_coords_from_listings — sync set-based UPDATE в executor (#2771) ─────
async def _job_house_coords_from_listings(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.backfill_house_coords_from_listings import run_house_coords_from_listings
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: run_house_coords_from_listings(db, run_id=run_id, params=params),
)
# ── geoportal_coords_backfill — sync local exact match в executor (#1967) ─────
async def _job_geoportal_coords_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.backfill_listings_coords_geoportal import run_geoportal_coords_backfill
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: run_geoportal_coords_backfill(db, run_id=run_id, params=params),
)
# ── cadastral_geo_match — sync FDW-refresh+match в executor ───────────────────
async def _job_cadastral_geo_match(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.cadastral_geo_match import run_cadastral_geo_match
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: run_cadastral_geo_match(db, run_id=run_id, params=params),
)
# ── osm_poi_ekb_refresh — sync FDW-refresh в executor ─────────────────────────
async def _job_osm_poi_ekb_refresh(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.osm_poi_ekb_refresh import run_osm_poi_ekb_refresh
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: run_osm_poi_ekb_refresh(db, run_id=run_id, params=params),
)
# ── dtp_stat_refresh — sync ZIP-скачивание+парс ДТП в executor ────────────────
async def _job_dtp_stat_refresh(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.dtp_stat_refresh import run_dtp_stat_refresh
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: run_dtp_stat_refresh(db, run_id=run_id, params=params),
)
# ── house_imv_backfill — async Avito-IMV с heartbeat, lifecycle в job ─────────
async def _job_house_imv_backfill(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
batch_size = int(params.get("batch_size", 50))
request_delay_sec = float(params.get("request_delay_sec", 5.0))
only_status = str(params.get("only_status", "pending"))
def _heartbeat() -> None:
# heartbeat каждые N домов — иначе reap_zombies снимет живой долгий IMV-run (#1363).
ctx.runs.update_heartbeat(db, run_id, {})
try:
result = await ctx.enrichment.house_imv_backfill(
db,
batch_size=batch_size,
request_delay_sec=request_delay_sec,
only_status=only_status,
heartbeat=_heartbeat,
)
counters = {
"checked": result.checked,
"saved": result.saved,
"skipped": result.skipped,
"errors": result.errors,
"duration_sec": int(result.duration_sec),
# #2674: _column_counts (scrape_runs.py) берёт выделенные колонки из
# ключей total_seen|lots_fetched и new_count|lots_inserted — ни одного
# из них тут не было, поэтому все 39 прогонов этого source лежат в БД
# с total_seen=0. А mark_done по этой же колонке шлёт алерт «3 подряд
# done с нулевым результатом» (#2625) — то есть даже идеальный прогон
# с 50 сохранёнными считался бы нулевым и через три дня выстрелил бы
# ложной тревогой про капчу.
# Трейд-офф: на исчерпанной очереди checked=0 три дня подряд тоже даст
# алерт — но пустая очередь при ежедневном расписании это и правда сигнал.
"total_seen": result.checked,
"new_count": result.saved,
# #2674: из скольких слотов пакета взяты дома на ПОВТОР (transient_error)
# и сколько домов ушло в no_params до пакета одним запросом. Без этих
# двух счётчиков в scrape_runs.counters проверить, что застрявшие
# действительно возвращаются в очередь, можно только по houses.
"retried": result.retried,
"premarked": result.premarked,
}
# Честный статус (#2674, тот же класс, что #2670/#2657): успех — это
# «сделали то, что собирались», а не «не поймали известное исключение».
# На проде так ушли в done 31 прогон подряд: saved=0 при errors≈35 из 50.
# Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) — честная
# пустота, она по-прежнему done.
if result.saved == 0 and result.errors > 0:
ctx.runs.mark_failed(
db,
run_id,
f"saved=0 при errors={result.errors} (checked={result.checked})",
counters,
)
else:
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: house_imv_backfill crashed run_id=%d", run_id)
try:
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
except Exception:
logger.exception("scheduler: mark_failed crashed run_id=%d", run_id)
# ── domrf_kapremont_load — sync загрузка open data ДОМ.РФ в executor ─────────
# #2674: loader (services/domrf_kapremont_loader.py) и CLI (tasks/domrf_kapremont_load.py)
# написаны и покрыты тестами с #2013, но Handler'а и строки расписания не было — источник
# запускали руками ровно один раз, 12.07.2026 (29 978 строк, один и тот же loaded_at у всех).
# Это не мёртвый код, а оборванная проводка: нечему было его вызвать.
async def _job_domrf_kapremont_load(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Скачать КР1.1+КР1.2 ДОМ.РФ → staging → backfill houses → propagate listings.
Тело переиспользует те же три функции, что и CLI (дизайн-инвариант модуля: не
дублируем логику). Lifecycle не свой — mark_done/mark_failed здесь, как у
_job_yandex_newbuilding_sweep.
Счётчики кладём в total_seen/new_count: `scrape_runs._column_counts` берёт выделенные
колонки именно из этих ключей, и по ним же mark_done ловит «три подряд нулевых
прогона» (#2625) — без них идеальный прогон лежал бы в БД как нулевой (тот же
промах, что чинили у house_imv_backfill).
"""
from app.services.domrf_kapremont_loader import (
backfill_houses_from_domrf,
load_domrf_kapremont,
propagate_listings_year_from_houses,
)
def _run() -> dict[str, int]:
load_counts = load_domrf_kapremont(db)
db.commit()
houses_counts = backfill_houses_from_domrf(db)
listings_counts = propagate_listings_year_from_houses(db)
db.commit()
return {
"kr11_rows": load_counts["kr11_rows"],
"upserted": load_counts["upserted"],
"houses_updated": houses_counts["houses_updated"],
"listings_updated": listings_counts["listings_updated"],
# см. докстринг: выделенные колонки прогона + гейт «нулевой прогон».
"total_seen": load_counts["kr11_rows"],
"new_count": houses_counts["houses_updated"] + listings_counts["listings_updated"],
}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(None, _run)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: domrf_kapremont_load crashed run_id=%d", run_id)
db.rollback()
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── fns_opendata_load — bulk-дампы ФНС по юрлицам → fns_legal_entity_facts ────
# Открытые данные ФНС по ЮРЛИЦАМ (лицензия nalog.gov.ru/opendata разрешает
# переработку/перераспространение) — легальный обход того, что открытых данных
# ЕГРН по правообладателям-физлицам не существует (218-ФЗ ст. 62). Потребителя у
# fns_legal_entity_facts на момент добавления НЕТ (см. docstring
# app/services/fns_opendata_loader.py и fns_lookup.py) — расписание сидируется
# enabled=false, включение отдельным осознанным шагом.
async def _job_fns_opendata_load(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Скачать наборы ФНС opendata (revexp/sshr2019/debtam/snr) → fns_legal_entity_facts.
Тело переиспользует load_dataset (тот же дизайн-инвариант модуля, что у
_job_domrf_kapremont_load: не дублируем логику CLI). commit() после каждого
набора — сбой на debtam не должен откатывать уже загруженный revexp.
"""
from app.services.fns_opendata_loader import DATASET_SLUGS, load_dataset
datasets = params.get("datasets") or list(DATASET_SLUGS)
def _run() -> dict[str, int]:
total_records = 0
total_upserted = 0
for slug in datasets:
result = load_dataset(db, slug)
db.commit()
total_records += int(result["records"])
total_upserted += int(result["upserted"])
return {
"records": total_records,
"upserted": total_upserted,
# см. докстринг _job_domrf_kapremont_load: выделенные колонки прогона +
# гейт «три подряд нулевых прогона».
"total_seen": total_records,
"new_count": total_upserted,
}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(None, _run)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: fns_opendata_load crashed run_id=%d", run_id)
db.rollback()
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── frt_mkd_load — АИС ППК ФРТ, реестр МКД region 66 (issue #frt-mkd) ────────
async def _job_frt_mkd_load(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Скачать реестр МКД АИС ФРТ (node_id) → staging frt_mkd → backfill houses.
Тело переиспользует те же функции, что и CLI (app/tasks/frt_mkd_load.py) — не
дублируем логику. node_id/region_code берутся из scrape_schedules.default_params
(мигр. 291), с фоллбеком на дефолты loader'а.
"""
from app.services.frt_mkd_loader import (
DEFAULT_NODE_ID,
DEFAULT_REGION_CODE,
backfill_houses,
load_frt_mkd,
)
node_id = int(params.get("node_id", DEFAULT_NODE_ID))
region_code = int(params.get("region_code", DEFAULT_REGION_CODE))
def _run() -> dict[str, int]:
load_counts = load_frt_mkd(db, node_id=node_id, region_code=region_code)
db.commit()
houses_counts = backfill_houses(db)
db.commit()
return {
"rows": load_counts["rows"],
"upserted": load_counts["upserted"],
"houses_updated": houses_counts["houses_updated"],
"total_seen": load_counts["rows"],
"new_count": houses_counts["houses_updated"],
}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(None, _run)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: frt_mkd_load crashed run_id=%d", run_id)
db.rollback()
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── cbr_macro_pull — макро-ряды ЦБ РФ (ипотека по субъектам + ключевая ставка) ─
async def _job_cbr_macro_pull(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
"""Ипотечные ряды (XLSX) + ключевая ставка (SOAP) → cbr_mortgage_series/cbr_key_rate.
Тело переиспользует те же функции, что и CLI (app/tasks/cbr_macro_pull.py) —
дизайн-инвариант модуля: не дублируем логику. Lifecycle не свой —
mark_done/mark_failed здесь, как у _job_domrf_kapremont_load.
"""
from app.services.cbr_macro import load_key_rate, pull_cbr_mortgage
def _run() -> dict[str, int]:
mortgage_counts = pull_cbr_mortgage(db)
db.commit()
key_rate_counts = load_key_rate(db)
db.commit()
return {
"mortgage_upserted": mortgage_counts["upserted"],
"mortgage_skipped": mortgage_counts["skipped"],
"mortgage_errors": mortgage_counts["errors"],
"key_rate_upserted": key_rate_counts["upserted"],
# выделенные колонки прогона + гейт «три подряд нулевых прогона» (#2625).
"total_seen": mortgage_counts["upserted"] + key_rate_counts["rows"],
"new_count": mortgage_counts["upserted"] + key_rate_counts["upserted"],
}
loop = asyncio.get_event_loop()
try:
counters = await loop.run_in_executor(None, _run)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: cbr_macro_pull crashed run_id=%d", run_id)
db.rollback()
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
# ── purge_expired_trade_in_data — ЭТАП 4 B2C retention (152-ФЗ) ───────────────
async def _job_purge_expired_trade_in_data(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.purge_expired_trade_in_data import purge_expired_trade_in_data
batch_size = params.get("batch_size")
max_batches = params.get("max_batches")
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: purge_expired_trade_in_data(
db, run_id, batch_size=batch_size, max_batches=max_batches
),
)
# ── house_dedup_merge — sync destructive merge в executor, owns lifecycle ─────
async def _job_house_dedup_merge(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.services.house_dedup_merge import run_house_dedup_merge
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: run_house_dedup_merge(db, run_id=run_id, params=params),
)
# ── proxy_healthcheck — async, sub-hourly reschedule + lifecycle в job ────────
async def _job_proxy_healthcheck(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.services.proxy_pool import run_proxy_healthcheck
try:
counters = await run_proxy_healthcheck(db)
ctx.runs.mark_done(db, run_id, counters)
except Exception as exc:
logger.exception("scheduler: run_proxy_healthcheck crashed run_id=%d", run_id)
try:
ctx.runs.mark_failed(db, run_id, str(exc)[:1000], {})
except Exception:
logger.exception("scheduler: mark_failed crashed run_id=%d", run_id)
def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"""Реестр НЕ-sweep продуктовых source→Handler для kit build_registry.
Kit-native sweeps (avito/yandex/cian/domclick city/full-load/newbuilding) НЕ здесь —
их даёт build_registry(_default_kit_handlers). Здесь — именованные + 1 wildcard
(deactivate_stale_*), покрывающие каждый НЕ-sweep source боевого scheduler-dispatch.
(Число намеренно не названо: прежнее «19» разошлось с реальностью на пять записей.)
`ctx` — принят для симметрии контракта; сами Handler-job'ы получают ctx во время
dispatch (см. kit `_dispatch`), поэтому здесь он не замыкается.
"""
return {
"cian_history_backfill": Handler(
_job_cian_history_backfill,
"cian_history_backfill",
pre_claim=_cian_pre_claim,
),
# #3284: то же тело, другая очередь. Разные source нужны именно как РАЗНЫЕ
# строки расписания — у них свои окна, свой next_run_at и свой счётчик
# прогонов; гонять оба режима под одним source нельзя, планировщик держит
# на source ровно один активный прогон.
# Каденс — в минутах (замер прода 31.08.2026): та же суточная дыра
# compute_next_run_at, что у avito/yandex_detail_backfill — Циан держит
# темп ~23 с/карточку при очереди 20 501 и НОЛЕ блоков за неделю.
#
# 360 минут (4 прогона/сутки), НЕ 180 как у Яндекса — АСИММЕТРИЯ ниже
# ОБЯЗАТЕЛЬНА к прочтению перед тем, как выравнивать эти два интервала:
# Циан ходит через BrowserFetcher и ДЕРЖИТ lease узла на ВЕСЬ прогон, а
# не только берёт URL прокси (как Яндекс через resolve_proxy_url/
# proxy_egress). Пул сейчас 3 живых узла — дефицитный ресурс, который
# Циан на время прогона отбирает у avito_detail_backfill и
# domclick_detail_backfill. Учащение Циана до 180 мин без замера того,
# как это скажется на соседях по пулу, — прямой риск сжечь мощность,
# которой и так не хватает на троих.
"cian_detail_backfill": Handler(
_job_cian_history_backfill,
"cian_detail_backfill",
pre_claim=_cian_pre_claim,
post_claim=reschedule_after_minutes(param="interval_minutes", default=360),
),
"rosreestr_dkp_import": Handler(_job_rosreestr_dkp, "rosreestr_dkp_import"),
# Wildcard (#3051 п.3): rosreestr_dkp_import_77 (Москва, миграция 288) и любой
# будущий region_code-суффикс из той же семьи резолвятся сюда через
# resolve_handler по префиксу (тот же механизм, что deactivate_stale_* /
# avito_city_sweep_* — см. scraper_kit.orchestration.scheduler.resolve_handler).
# region_code берётся из default_params строки расписания (import_rosreestr_dkp
# сам валидирует его через app.services.regions.REGIONS), Handler-тело общее.
"rosreestr_dkp_import_*": Handler(_job_rosreestr_dkp, "rosreestr_dkp_import_*"),
"listing_source_snapshot": Handler(_job_listing_source_snapshot, "listing_source_snapshot"),
"asking_to_sold_ratio_refresh": Handler(
_job_asking_to_sold_ratio, "asking_to_sold_ratio_refresh"
),
"deal_city_price_bands_refresh": Handler(
_job_deal_city_price_bands_refresh, "deal_city_price_bands_refresh"
),
"refresh_search_matview": Handler(_job_refresh_search_matview, "refresh_search_matview"),
"yandex_address_backfill": Handler(_job_yandex_address_backfill, "yandex_address_backfill"),
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
"rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"),
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
"landing_showcase_deals": Handler(_job_landing_showcase_deals, "landing_showcase_deals"),
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
"yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
),
"geoportal_coords_backfill": Handler(
_job_geoportal_coords_backfill, "geoportal_coords_backfill"
),
"house_coords_from_listings": Handler(
_job_house_coords_from_listings, "house_coords_from_listings"
),
"geocode_missing_listings": Handler(
_job_geocode_missing_listings, "geocode_missing_listings"
),
# Каденс — в минутах, а не в сутках (замер 2026-08-22). Дефолтная
# гранулярность compute_next_run_at — сутки, и бэкфилл получал ровно один
# прогон в день. При этом прогон умирает по бану через 17-83 минуты, то
# есть 23 часа из 24 задание простаивало: 238 обогащённых карточек за
# сутки при 9 951 активном объявлении — очередь разбиралась бы месяцами.
#
# Хук сам себя throttl'ит: пока прогон идёт, has_running_run в _claim_run
# возвращает None и next_run_at не сбрасывается.
#
# 180 минут — ОСОЗНАННО консервативная отправная точка, а не найденный
# оптимум. Данных для подбора нет: два прогона дали противоречивую
# картину (4562 — 83 мин и 175 карточек; 4586 через 4.3 часа, когда пул
# прокси был давно чист, — 17 мин и 42 карточки). Значит память Авито
# длиннее часов, и учащение может ухудшить выход, а не улучшить.
#
# Риск, который надо держать в голове при подборе: те же 4 прокси
# обслуживают SERP-свипы — первичный сбор. Сжечь их на обогащении хуже,
# чем медленно обогащать. Двигать интервал вниз только по замеру
# нескольких суток, глядя и на свипы тоже.
"avito_detail_backfill": Handler(
_job_avito_detail_backfill,
"avito_detail_backfill",
post_claim=reschedule_after_minutes(param="interval_minutes", default=180),
),
# Каденс — в минутах (замер прода 31.08.2026), тот же дефект, что у
# avito_detail_backfill выше: без post_claim compute_next_run_at держит
# суточную гранулярность, а прогон Яндекса разбирает 375-450 карточек в
# час (budget_sec=3600) при очереди 11 110 и НУЛЕ блоков за неделю — узкое
# место чисто в частоте запуска, не в самом фетчере.
#
# 180 минут (8 прогонов/сутки) безопасны именно из-за асимметрии с Циан:
# Яндекс ходит через resolve_proxy_url (proxy_egress) — берёт URL прокси,
# но НЕ держит lease узла на весь прогон, поэтому учащение не отнимает
# узел у других source.
"yandex_detail_backfill": Handler(
_job_yandex_detail_backfill,
"yandex_detail_backfill",
post_claim=reschedule_after_minutes(param="interval_minutes", default=180),
),
"domclick_detail_backfill": Handler(
_job_domclick_detail_backfill, "domclick_detail_backfill"
),
# Override kit-native (#3264) — инъекция куки сессии, см. докстринг job'а выше.
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"),
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
"dtp_stat_refresh": Handler(_job_dtp_stat_refresh, "dtp_stat_refresh"),
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
"frt_mkd_load": Handler(_job_frt_mkd_load, "frt_mkd_load"),
"cbr_macro_pull": Handler(_job_cbr_macro_pull, "cbr_macro_pull"),
"fns_opendata_load": Handler(_job_fns_opendata_load, "fns_opendata_load"),
"purge_expired_trade_in_data": Handler(
_job_purge_expired_trade_in_data, "purge_expired_trade_in_data"
),
"proxy_healthcheck": Handler(
_job_proxy_healthcheck,
"proxy_healthcheck",
post_claim=reschedule_after_minutes(param="interval_minutes", default=30),
),
# Wildcard: deactivate_stale_avito / _yandex / _cian — один Handler на семейство
# (resolve_handler матчит по префиксу key[:-1]="deactivate_stale_").
"deactivate_stale_*": Handler(_job_deactivate_stale, "deactivate_stale"),
}
__all__ = ["build_product_handlers"]