gendesign/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py

186 lines
9.7 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.

"""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-вызовов) -- запускается in-app
scheduler'ом через trigger_deactivate_stale_run() (scheduler.py),
по образцу 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,
)