gendesign/tradein-mvp/backend/app/services/yandex_price_history.py
bot-backend db3afa3176 fix(tradein/scrapers): migrate cian_price_history/cian_session/yandex_price_history imports to scraper_kit (#2306)
Group B of the scraper_kit migration epic (#2277): switch the three files'
internal dependency imports from legacy app/services/scrapers/* to their
byte/structurally-identical scraper_kit equivalents, proven via the new
parity harness (tests/support/parity.assert_parity, #2304). Public API of
all three files is unchanged, so no callers needed updating.

- cian_price_history.py: fetch_detail/save_detail_enrichment now from
  scraper_kit.providers.cian.detail. Wires config=RealScraperConfig() at
  the call site — kit's fetch_detail only reads cian_proxy_url when an
  explicit config is passed, unlike legacy's always-on settings read;
  without this the admin-triggered backfill would silently drop its
  datacenter-IP proxy (#806) after the import swap.
- cian_session.py: extract_state now from scraper_kit.cian_state_parser
  (byte-identical to legacy).
- yandex_price_history.py: ScrapedLot now from scraper_kit.base
  (byte-identical fields/methods; record_yandex_price_history only reads
  lot.* via duck-typing, no isinstance checks).

New tests/test_scraper_kit_pricehistory_session_parity.py proves the
migration delta specifically (extract_state, ScrapedLot.model_dump(),
fetch_detail+DetailEnrichment with config injection, and the own-session
proxy-kwarg wiring). Full backend suite passes (3145 passed, 6 skipped,
1 pre-existing unrelated failure confirmed on unmodified forgejo/main).
2026-07-04 00:20:13 +03:00

188 lines
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.

"""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 достаточно.
Эта функция вызывается из scrape_pipeline СРАЗУ ПОСЛЕ 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 app.services.scrapers.base, см. tests/test_scraper_kit_pricehistory_session_parity.py).
# record_yandex_price_history reads lot.* via duck-typing only — no isinstance checks —
# так что kit- и legacy-скрапперы (оба конструируют 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