gendesign/backend/app/services/etl/newbuilding_crossload.py
lekss361 1c184959a8
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m15s
Deploy / build-worker (push) Successful in 3m12s
Deploy / deploy (push) Successful in 1m34s
feat(etl): read tradein data via market.v_* contract views (#2138 step 2/3)
2026-07-02 15:00:38 +00:00

253 lines
9.3 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.

"""Cross-load ETL: tradein.houses → gendesign.newbuilding_listings (#976 950-E5).
Читает market.v_houses + market.v_house_sources из tradein-БД (gendesign_reader role),
UPSERT-ит в gendesign newbuilding_listings по (source, ext_house_id).
Соединение к tradein через psycopg v3 (прямой коннект, НЕ SQLAlchemy).
Источник — contract-view market.v_* (#2138), а не сырые public.*-таблицы: явный
column-контракт, устойчивый к scraper-driven ALTER TABLE на стороне tradein.
Запускается:
- ночью Celery beat (03:30 МСК, tasks/etl_newbuilding_crossload.py)
- вручную через POST /api/v1/admin/scrape/newbuilding-crossload
Если settings.tradein_database_url пусто — сразу возвращает {"disabled": True}.
"""
from __future__ import annotations
import json
import logging
from typing import Any
import psycopg
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
_BATCH_SIZE = 500
# ── SELECT из tradein ────────────────────────────────────────────────────────
_SOURCE_SQL = """
SELECT
h.source,
h.ext_house_id,
h.url,
h.slug,
h.address,
h.full_address,
h.lat,
h.lon,
h.house_class,
h.developer_name,
h.developer_key,
h.year_built,
h.total_floors,
h.total_units,
h.rating,
h.reviews_count,
h.houses_by_turn,
h.cian_internal_house_id,
h.raw_payload,
h.last_scraped_at,
-- yandex_jk_id из house_sources (LEFT JOIN — может отсутствовать)
hs_y.ext_id AS yandex_jk_id
-- Читаем через contract-view market.v_* (#2138 step 2/3), НЕ сырые public.* —
-- стабильный явный column-контракт, не ломается на scraper-driven ALTER TABLE.
FROM market.v_houses h
LEFT JOIN market.v_house_sources hs_y
ON hs_y.house_id = h.id
AND hs_y.ext_source = 'yandex'
WHERE h.lat IS NOT NULL
AND h.lon IS NOT NULL
"""
# ── UPSERT в gendesign ───────────────────────────────────────────────────────
_UPSERT_SQL = text("""
INSERT INTO newbuilding_listings (
source, ext_house_id,
url, slug,
address, full_address,
lat, lon,
geom,
house_class,
developer_name, developer_key,
year_built,
total_floors, total_units,
rating, reviews_count,
houses_by_turn,
cian_internal_house_id,
yandex_jk_id,
raw_payload,
source_last_scraped_at,
crossloaded_at
)
VALUES (
:source, :ext_house_id,
:url, :slug,
:address, :full_address,
:lat, :lon,
CASE
WHEN :lat2 IS NOT NULL AND :lon2 IS NOT NULL
THEN ST_SetSRID(ST_MakePoint(CAST(:lon2 AS double precision),
CAST(:lat2 AS double precision)), 4326)
ELSE NULL
END,
:house_class,
:developer_name, :developer_key,
CAST(:year_built AS integer),
CAST(:total_floors AS integer), CAST(:total_units AS integer),
CAST(:rating AS numeric), CAST(:reviews_count AS integer),
CAST(:houses_by_turn AS jsonb),
CAST(:cian_internal_house_id AS bigint),
:yandex_jk_id,
CAST(:raw_payload AS jsonb),
CAST(:source_last_scraped_at AS timestamptz),
now()
)
ON CONFLICT (source, ext_house_id) DO UPDATE SET
url = EXCLUDED.url,
slug = EXCLUDED.slug,
address = EXCLUDED.address,
full_address = EXCLUDED.full_address,
lat = EXCLUDED.lat,
lon = EXCLUDED.lon,
geom = EXCLUDED.geom,
house_class = EXCLUDED.house_class,
developer_name = EXCLUDED.developer_name,
developer_key = EXCLUDED.developer_key,
year_built = EXCLUDED.year_built,
total_floors = EXCLUDED.total_floors,
total_units = EXCLUDED.total_units,
rating = EXCLUDED.rating,
reviews_count = EXCLUDED.reviews_count,
houses_by_turn = EXCLUDED.houses_by_turn,
cian_internal_house_id = COALESCE(
EXCLUDED.cian_internal_house_id,
newbuilding_listings.cian_internal_house_id),
yandex_jk_id = COALESCE(
EXCLUDED.yandex_jk_id,
newbuilding_listings.yandex_jk_id),
raw_payload = EXCLUDED.raw_payload,
source_last_scraped_at = EXCLUDED.source_last_scraped_at,
crossloaded_at = now()
""")
def _row_to_params(row: Any) -> dict[str, Any]:
"""Преобразовать строку из tradein-курсора в dict параметров для UPSERT.
Публичная функция — тестируется отдельно (без БД).
row — объект с атрибутами (psycopg RealDictRow / dict / named row).
"""
if hasattr(row, "_asdict"):
r: dict[str, Any] = row._asdict()
elif hasattr(row, "keys"):
r = dict(row)
else:
r = dict(row)
houses_by_turn = r.get("houses_by_turn")
raw_payload = r.get("raw_payload")
return {
"source": r["source"],
"ext_house_id": r["ext_house_id"],
"url": r.get("url"),
"slug": r.get("slug"),
"address": r.get("address"),
"full_address": r.get("full_address"),
"lat": r.get("lat"),
"lon": r.get("lon"),
# дублируем lat/lon для CASE-ветки geom (psycopg не позволяет один bind два раза)
"lat2": r.get("lat"),
"lon2": r.get("lon"),
"house_class": r.get("house_class"),
"developer_name": r.get("developer_name"),
"developer_key": r.get("developer_key"),
"year_built": r.get("year_built"),
"total_floors": r.get("total_floors"),
"total_units": r.get("total_units"),
"rating": str(r["rating"]) if r.get("rating") is not None else None,
"reviews_count": r.get("reviews_count"),
"houses_by_turn": json.dumps(houses_by_turn, ensure_ascii=False)
if houses_by_turn is not None
else None,
"cian_internal_house_id": r.get("cian_internal_house_id"),
"yandex_jk_id": r.get("yandex_jk_id"),
"raw_payload": json.dumps(raw_payload, ensure_ascii=False)
if raw_payload is not None
else None,
"source_last_scraped_at": r.get("last_scraped_at"),
}
def run_crossload(db: Session | None = None) -> dict[str, Any]:
"""Выполнить cross-load ETL tradein→gendesign.
Если settings.tradein_database_url пуст — возвращает {"disabled": True}, не коннектится.
Returns:
dict с ключами: source_rows, upserted, skipped — или {"disabled": True}.
"""
if not settings.tradein_database_url:
logger.warning("etl_newbuilding_crossload: TRADEIN_DATABASE_URL не задан — ETL отключён")
return {"disabled": True}
close_db = db is None
if db is None:
db = SessionLocal()
source_rows = 0
upserted = 0
skipped = 0
logger.info("etl_newbuilding_crossload: start (batch_size=%d)", _BATCH_SIZE)
try:
connstr = settings.tradein_database_url
with psycopg.connect(connstr, row_factory=psycopg.rows.dict_row) as src:
with src.cursor(name="crossload_cur") as cur: # server-side cursor
cur.execute(_SOURCE_SQL)
while True:
batch = cur.fetchmany(_BATCH_SIZE)
if not batch:
break
source_rows += len(batch)
params_list = [_row_to_params(r) for r in batch]
for params in params_list:
try:
with db.begin_nested():
db.execute(_UPSERT_SQL, params)
upserted += 1
except Exception as exc:
skipped += 1
logger.warning(
"etl_newbuilding_crossload: upsert failed "
"source=%s ext_id=%s: %s",
params.get("source"),
params.get("ext_house_id"),
exc,
)
db.commit()
except Exception:
if not close_db:
db.rollback()
raise
finally:
if close_db:
db.close()
logger.info(
"etl_newbuilding_crossload: done — source_rows=%d upserted=%d skipped=%d",
source_rows,
upserted,
skipped,
)
return {"source_rows": source_rows, "upserted": upserted, "skipped": skipped}