"""Deactivate stale listings that have not been seen in >TTL days. Первоначально создано для avito (#759). Теперь поддерживает любой источник (listing_source) с опциональной фильтрацией по listing_segment. Ключевые решения: - Cian/Yandex не поддерживают full-coverage sweep -> паушальный TTL сломает живой инвентарь. DECISION: для yandex/cian деактивировать ТОЛЬКО listing_segment='vtorichka', TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем. - avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений. - Строки НЕ удаляются -- история нужна для бэктеста (#667). Задача синхронная (DB-only, никаких внешних HTTP-вызовов) -- запускается kit-scheduler'ом через product_handlers._job_deactivate_stale (wildcard-handler deactivate_stale_*), по образцу snapshot_listing_sources / recompute_asking_to_sold_ratios (sync task в run_in_executor). TTL для avito берётся из settings.avito_stale_ttl_days (env AVITO_STALE_TTL_DAYS, default 10). """ from __future__ import annotations import logging from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import settings from app.services import scrape_runs as runs_mod logger = logging.getLogger(__name__) # Разрешённые колонки-таймстемпы для проверки свежести (staleness). # Whitelist ЖЁСТКО ограничивает f-string-интерполяцию имени колонки в SQL: # значение колонки НИКОГДА не берётся из пользовательского ввода без этой проверки. # - last_seen_at: дефолт; двигается при каждом «увиденном» объявлении в прогоне. # - scraped_at: честная свежесть для источников с нетрекаемым bulk-touch # last_seen_at (domklik: #2204 — bulk UPDATE двигает last_seen_at всем строкам # одним timestamp, поэтому TTL по last_seen_at бесполезен; scraped_at двигает # только реальный скрейп). _ALLOWED_STALENESS_COLUMNS = frozenset({"last_seen_at", "scraped_at"}) def _build_all_segments_sql(staleness_column: str) -> Any: """UPDATE без фильтра по сегменту: все сегменты для данного source. staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings, поэтому f-string-подстановка имени колонки безопасна. Значения (:listing_source, :ttl_days) остаются param-binding — psycopg v3 safe (никаких :param::type). """ return text( f""" UPDATE listings SET is_active = false WHERE source = :listing_source AND is_active = true AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) """ ) def _build_segments_sql(staleness_column: str) -> Any: """UPDATE с фильтром по сегменту (segments задан): только указанные сегменты. staleness_column уже прошёл whitelist-проверку. = ANY(CAST(:segments AS text[])) -- psycopg v3 адаптирует Python list -> text[]. """ return text( f""" UPDATE listings SET is_active = false WHERE source = :listing_source AND is_active = true AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) AND listing_segment = ANY(CAST(:segments AS text[])) """ ) # Дефолтные (last_seen_at) варианты SQL -- сохранены как модульные константы для # обратной совместимости (тесты читают .text, product_handlers/scheduler не менялись). _DEACTIVATE_SQL_ALL_SEGMENTS = _build_all_segments_sql("last_seen_at") _DEACTIVATE_SQL_SEGMENTS = _build_segments_sql("last_seen_at") # Алиас для обратной совместимости -- использовался в тестах через task_mod._DEACTIVATE_SQL _DEACTIVATE_SQL = _DEACTIVATE_SQL_ALL_SEGMENTS def deactivate_stale_listings( db: Session, run_id: int, *, listing_source: str, ttl_days: int, segments: list[str] | None = None, staleness_column: str = "last_seen_at", ) -> dict[str, int]: """Пометить is_active=false объявления, чья свежесть старше ttl_days дней. Параметры: listing_source: значение поля source в таблице listings ('avito', 'yandex', 'cian', 'domklik'). ttl_days: количество дней TTL; объявления старше этого порога деактивируются. segments: если задан -- деактивировать только объявления с указанными listing_segment значениями. None -> все сегменты (поведение avito по умолчанию). staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist {"last_seen_at", "scraped_at"} — иначе ValueError ДО любого SQL. Дефолт last_seen_at. Для domklik (#2204) — scraped_at: нетрекаемый bulk-touch двигает last_seen_at всем строкам одним timestamp, поэтому честная свежесть = scraped_at (двигается только реальным скрейпом). Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). Один UPDATE в транзакции. Финализирует scrape_runs (mark_done / mark_failed). Returns {"deactivated": N} -- количество обновлённых строк. Raises: ValueError: если staleness_column не входит в whitelist (проверка ДО SQL, никакой интерполяции пользовательского ввода в запрос). """ counters: dict[str, int] = {"deactivated": 0} try: # Whitelist-проверка ДО построения/выполнения SQL: только после неё имя колонки # интерполируется f-string'ом. Значения по-прежнему идут через param-binding. # Внутри try -> невалидная колонка финализирует run как failed (mark_failed), # а не оставляет его «running» (единый контракт с прочими сбоями). if staleness_column not in _ALLOWED_STALENESS_COLUMNS: raise ValueError( f"invalid staleness_column={staleness_column!r}; " f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}" ) # segments is None -> все сегменты (поведение avito). segments=[...] -> только # перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список [] # означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0), # а НЕ "все сегменты" — иначе случайный [] стёр бы весь источник. if segments is not None: params: dict[str, Any] = { "listing_source": listing_source, "ttl_days": ttl_days, "segments": segments, } result = db.execute(_build_segments_sql(staleness_column), params) else: params = {"listing_source": listing_source, "ttl_days": ttl_days} result = db.execute(_build_all_segments_sql(staleness_column), params) counters["deactivated"] = result.rowcount or 0 db.commit() runs_mod.mark_done(db, run_id, counters) logger.info( "deactivate_stale source=%s run_id=%d done: deactivated=%d " "(ttl_days=%d, segments=%r, staleness_column=%s)", listing_source, run_id, counters["deactivated"], ttl_days, segments, staleness_column, ) return counters except Exception as exc: logger.exception("deactivate_stale source=%s run_id=%d failed", listing_source, run_id) db.rollback() runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) raise def deactivate_stale_avito_listings(db: Session, run_id: int) -> dict[str, int]: """Обёртка для обратной совместимости -- deactivate_stale_avito (все сегменты, TTL из settings). Пометить is_active=false все avito-объявления с last_seen_at > TTL дней назад. Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). Один UPDATE в транзакции. Финализирует scrape_runs (mark_done / mark_failed). Returns {"deactivated": N} -- количество обновлённых строк. """ return deactivate_stale_listings( db, run_id, listing_source="avito", ttl_days=settings.avito_stale_ttl_days, segments=None, )