"""Yandex offer price-history capture из gate-ответа. В отличие от Cian (отдельный detail-fetch priceChanges, см. cian_price_history.py), Yandex gate-ответ уже несёт `price.previous` + `price.trend`, которые парсятся в `ScrapedLot.price_previous_rub` / `price_trend`. Otsenka-скрапер НЕ нужен — gate достаточно. Эта функция вызывается из run_yandex_city_sweep (scraper_kit.orchestration.pipeline, через инжектируемый EnrichmentJobs.record_yandex_price_history) СРАЗУ ПОСЛЕ save_listings (те же lots): резолвит listing_id по (source='yandex', source_id), читает последнюю по change_time точку offer_price_history и идемпотентно дописывает наблюдение, если цена изменилась. Idempotent-логика (на каждый yandex-lot): - история ПУСТА: * price_previous задан И != price_rub → seed previous-точку (change_time = now-1d, timestamp синтетический — gate реального времени смены не несёт), затем current-точку; * иначе → только current-точку (now). - история ЕСТЬ и latest_price != price_rub → одна current-точка (now) — зафиксировали смену. - история ЕСТЬ и latest_price == price_rub → skip (цена не менялась). Per-lot SAVEPOINT: один битый lot не валит весь батч. db.commit() в конце. psycopg v3 discipline: только CAST(:x AS type), никаких :x::type. Все timestamp'ы считаются в Python и биндятся как параметры (DB-agnostic, без Postgres-only interval). """ from __future__ import annotations import logging from datetime import UTC, datetime, timedelta # #2306: ScrapedLot migrated to scraper_kit (byte-identical field/method surface to # legacy base-module ScrapedLot, deleted #2397 финальный шаг E — kit единственный # живой путь). record_yandex_price_history reads lot.* via duck-typing only — no # isinstance checks — так что любой ScrapedLot-совместимый объект (source, source_id, # price_rub, price_previous_rub, price_trend) остаётся взаимозаменяем для этой функции. from scraper_kit.base import ScrapedLot from sqlalchemy import text from sqlalchemy.orm import Session logger = logging.getLogger(__name__) _SOURCE = "yandex" # Резолв listing_id по стабильному (source, source_id) — у Yandex source_id = offer_id. _RESOLVE_SQL = text( """ SELECT id FROM listings WHERE source = 'yandex' AND source_id = CAST(:source_id AS text) LIMIT 1 """ ) # Последняя зафиксированная цена для листинга (по change_time DESC — индекс oph_listing_time_idx). _LATEST_SQL = text( """ SELECT price_rub FROM offer_price_history WHERE listing_id = CAST(:listing_id AS bigint) AND source = 'yandex' ORDER BY change_time DESC LIMIT 1 """ ) # change_time bound как Python datetime — psycopg v3 адаптирует к timestamptz нативно, # CAST не нужен (и был бы вреден: SQLite numeric-affinity CAST манглит ISO-строку). _INSERT_SQL = text( """ INSERT INTO offer_price_history (listing_id, change_time, price_rub, source) VALUES ( CAST(:listing_id AS bigint), :change_time, CAST(:price_rub AS numeric), 'yandex' ) """ ) def _insert_point( db: Session, *, listing_id: int, price_rub: float, change_time: datetime, ) -> None: db.execute( _INSERT_SQL, { "listing_id": listing_id, "change_time": change_time, "price_rub": price_rub, }, ) def record_yandex_price_history(db: Session, lots: list[ScrapedLot]) -> int: """Дописать offer_price_history по yandex-lots из gate price.previous/trend. Args: db: SQLAlchemy session (caller-owned; commit делается здесь в конце). lots: те же ScrapedLot, что были переданы в save_listings. Не-yandex и lot'ы без source_id / без резолвящегося листинга — пропускаются. Returns: Число фактически вставленных строк offer_price_history. """ if not lots: return 0 now = datetime.now(UTC) prev_ts = now - timedelta(days=1) inserted = 0 skipped = 0 errors = 0 for lot in lots: if lot.source != _SOURCE: continue if not lot.source_id: skipped += 1 continue try: with db.begin_nested(): row = db.execute( _RESOLVE_SQL, {"source_id": lot.source_id}, ).fetchone() if row is None: skipped += 1 continue listing_id = int(row[0]) latest = db.execute( _LATEST_SQL, {"listing_id": listing_id}, ).fetchone() if latest is None: # История пуста — seed (+ опционально previous-точка). prev = lot.price_previous_rub if prev is not None and prev != lot.price_rub: _insert_point( db, listing_id=listing_id, price_rub=prev, change_time=prev_ts, ) inserted += 1 _insert_point( db, listing_id=listing_id, price_rub=lot.price_rub, change_time=now, ) inserted += 1 else: latest_price = float(latest[0]) if latest_price != float(lot.price_rub): _insert_point( db, listing_id=listing_id, price_rub=lot.price_rub, change_time=now, ) inserted += 1 else: skipped += 1 except Exception as exc: # Per-lot SAVEPOINT откатился — продолжаем батч, не роняем остальные lot'ы. errors += 1 logger.warning( "yandex_price_history: lot failed source_id=%s: %s", lot.source_id, exc, ) db.commit() logger.info( "yandex_price_history: lots=%d inserted=%d skipped=%d errors=%d", len(lots), inserted, skipped, errors, ) return inserted