gendesign/tradein-mvp/backend/app/services/scrapers/cian_detail.py
lekss361 2e345acb82
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m38s
Deploy Trade-In / build-backend (push) Successful in 1m28s
Deploy Trade-In / deploy (push) Successful in 1m45s
fix(tradein/scraper-kit): клампить diff_percent перед INSERT offer_price_history (#2404)
2026-07-04 10:44:26 +00:00

464 lines
19 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.

"""Cian.ru detail page scraper — single offer enrichment.
Different from SERP (cian.py):
- URL: https://ekb.cian.ru/sale/flat/<offer_id>/ or https://www.cian.ru/sale/flat/<offer_id>/
- MFE: 'frontend-offer-card'
- State KEY: 'defaultState' (NOT 'initialState' — SERP uses initialState)
- Sister containers in same _cianConfig: bti, priceChanges, stats, agent, newObject
Parses ~88 offer fields + sister data → DetailEnrichment dataclass.
Stage 5 of CianScraper v1.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from curl_cffi.requests import AsyncSession
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services.scrapers.cian_state_parser import extract_all_states, extract_state
from app.services.scrapers.repair_state_normalizer import (
infer_repair_state_from_text,
normalize_repair_state,
)
from app.services.scrapers.snapshot_writer import upsert_listing_snapshot
if TYPE_CHECKING:
from app.services.scrapers.browser_fetcher import BrowserFetcher
logger = logging.getLogger(__name__)
# offer_price_history.diff_percent — NUMERIC(8,2) (миграция
# 131_fix_diff_percent_overflow.sql). Прямой INSERT ниже читает diff_percent из
# скрапленного Cian JSON и не проходит через БД-триггер record_listing_price_change()
# (тот срабатывает только на UPDATE listings) — гарбадж/экстремальное значение
# с редизайна source-страницы иначе переполняет колонку.
_DIFF_PERCENT_BOUND = 999999.99
def _clamp_diff_percent(value: float | int | None) -> float | None:
"""Клампит diff_percent в границы NUMERIC(8,2) (зеркало migration 131 trigger-clamp)."""
if value is None or isinstance(value, bool):
return None
try:
numeric = float(value)
except (TypeError, ValueError):
return None
return min(max(numeric, -_DIFF_PERCENT_BOUND), _DIFF_PERCENT_BOUND)
@dataclass
class DetailEnrichment:
"""Result of cian_detail.fetch_detail() — fields для UPDATE listings + sister rows."""
# Core listing enrichment (extend existing listings row)
cian_id: int | None = None
windows_view_type: str | None = None # 'yard' / 'street' / 'yardAndStreet'
separate_wcs_count: int | None = None
combined_wcs_count: int | None = None
ceiling_height: float | None = None # meters — offer.building.ceilingHeight (#1791)
repair_type: str | None = None # raw Cian: offer.repairType OR offer.decoration (#1791)
repair_state: str | None = None # enum: needs_repair/standard/good/excellent
kitchen_area_m2: float | None = None # offer.kitchenArea (м²)
description: str | None = None # offer.description (текст объявления)
views_total: int | None = None # Cian stats.totalViewsFormattedString → int
views_today: int | None = None
# BTI (вторичка only — primary buildings don't have БТИ)
bti_data: dict[str, Any] | None = None # raw bti.houseData snapshot
# Price history (offer.priceChanges)
price_changes: list[dict[str, Any]] = field(default_factory=list)
# Agent profile (offer.agent.* expanded)
agent_profile: dict[str, Any] | None = None
# Newbuilding link (if offer is from a newbuilding)
newbuilding_data: dict[str, Any] | None = None
# Raw payload для debugging / future-extraction
raw_offer: dict[str, Any] | None = None
raw_sister_states: dict[str, Any] = field(default_factory=dict)
async def fetch_detail(
offer_url: str,
*,
session: AsyncSession | None = None,
browser_fetcher: BrowserFetcher | None = None,
) -> DetailEnrichment | None:
"""Fetch detail page and extract all containers.
Args:
offer_url: e.g. 'https://ekb.cian.ru/sale/flat/327830237/'
session: optional shared curl_cffi session (для batched scrapes).
Ignored when browser_fetcher is provided.
browser_fetcher: optional BrowserFetcher instance (camoufox HTTP service).
When provided, fetches JS-rendered HTML via the browser service instead of
curl_cffi — enables priceChanges extraction which requires JS rendering.
Caller is responsible for the context-manager lifecycle of the fetcher.
Returns: DetailEnrichment, or None если fetch / parse failed.
"""
if browser_fetcher is not None:
# Browser path: get fully JS-rendered HTML; same parse path follows.
try:
html = await browser_fetcher.fetch(offer_url)
except Exception as exc:
logger.warning("Cian detail browser fetch failed %s: %s", offer_url, exc)
return None
else:
# curl_cffi path (legacy, back-compat).
close_session = False
if session is None:
# proxies: mobile-proxy egress (#806) — без прокси datacenter-IP блокируется Cian.
# Пусто (env не задан) → прямое подключение (dev/no-op).
_proxy_url = settings.cian_proxy_url
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
session = AsyncSession(
impersonate="chrome120",
timeout=25.0,
proxies=_proxies,
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
},
)
close_session = True
try:
resp = await session.get(offer_url, allow_redirects=True)
if resp.status_code != 200:
logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code)
return None
html = resp.text
finally:
if close_session:
await session.close()
# ── Shared parse path (identical regardless of HTML source) ──────────────
# Primary state container — defaultState in frontend-offer-card MFE
# NOTE: detail pages use 'defaultState', SERP uses 'initialState'
offer_state = extract_state(html, mfe="frontend-offer-card", key="defaultState")
if offer_state is None:
logger.warning("Cian detail %s: defaultState extraction failed", offer_url)
return None
offer_data = offer_state.get("offerData", {})
offer = offer_data.get("offer", {})
result = DetailEnrichment(
cian_id=offer.get("cianId") or offer.get("id"),
windows_view_type=_extract_windows_view(offer),
separate_wcs_count=offer.get("separateWcsCount"),
combined_wcs_count=offer.get("combinedWcsCount"),
ceiling_height=_extract_ceiling_height(offer),
repair_type=_extract_repair_type(offer),
repair_state=_extract_repair_state(offer),
kitchen_area_m2=_parse_float(offer.get("kitchenArea")),
description=offer.get("description") or None,
raw_offer=offer,
)
# Stats (views) — from stats key in offerData or top-level state
stats = offer_data.get("stats") or offer_state.get("stats") or {}
result.views_total = _parse_views(stats.get("totalViewsFormattedString"))
result.views_today = _parse_views(stats.get("todayViewsFormattedString"))
# Price changes — browser HTML: offerData.priceChanges (priceData.price format)
# curl_cffi HTML: offer.priceChanges or state.priceChanges (flat price field)
result.price_changes = _extract_price_changes(offer, offer_data, offer_state)
# Agent profile expansion
result.agent_profile = _extract_agent_profile(offer)
# Sister state containers: bti, newObject, plus raw stash of everything else
all_states = extract_all_states(html)
offer_card_states = all_states.get("frontend-offer-card", {})
bti_state = offer_card_states.get("bti")
if bti_state:
result.bti_data = bti_state.get("houseData")
result.raw_sister_states["bti"] = bti_state
# Newbuilding link (if applicable)
newbuilding = offer.get("newbuilding")
if newbuilding and newbuilding.get("id"):
result.newbuilding_data = newbuilding
# Stash all non-defaultState sister containers for debugging / future-extraction
for k, v in offer_card_states.items():
if k != "defaultState":
result.raw_sister_states[k] = v
return result
# ── Field extractors ──────────────────────────────────────────────────────────
def _extract_windows_view(offer: dict[str, Any]) -> str | None:
return offer.get("windowsViewType")
def _extract_ceiling_height(offer: dict[str, Any]) -> float | None:
"""Высота потолков (м).
На detail-странице (defaultState) значение лежит в ``offer.building.ceilingHeight``,
а НЕ в ``offer.ceilingHeight`` (последнее всегда null) — #1791. Top-level оставлен
как fallback на случай иной структуры карточки.
"""
building = offer.get("building") or {}
return _parse_float(building.get("ceilingHeight") or offer.get("ceilingHeight"))
def _extract_repair_type(offer: dict[str, Any]) -> str | None:
# Cian: 'cosmetic' / 'design' / 'no' / 'euro' / ...
# На detail-странице структурное поле чаще приходит как `decoration`
# ('preFine'/'fine'/...), а `repairType` пустое (#1791) — читаем оба.
return offer.get("repairType") or offer.get("decoration")
def _extract_repair_state(offer: dict[str, Any]) -> str | None:
"""Нормализованный enum из repairType/decoration → needs_repair/standard/good/excellent.
Если структурное поле пустое — fallback на инференс из описания (#622).
Структурное значение всегда в приоритете.
"""
state = normalize_repair_state(offer.get("repairType") or offer.get("decoration"))
if state is None:
state = infer_repair_state_from_text(offer.get("description"))
return state
def _parse_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (ValueError, TypeError):
return None
def _parse_views(formatted_str: str | None) -> int | None:
"""Cian's stats.totalViewsFormattedString — '1 234' → 1234."""
if not formatted_str:
return None
try:
return int(str(formatted_str).replace(" ", "").replace(",", ""))
except (ValueError, TypeError):
return None
def _extract_price_changes(
offer: dict[str, Any],
offer_data: dict[str, Any],
state: dict[str, Any],
) -> list[dict[str, Any]]:
"""Normalize priceChanges from any of the known Cian locations/formats.
Lookup priority:
1. offer.priceChanges — curl_cffi flat format: {changeTime, price, diffPercent}
2. offerData.priceChanges — browser-rendered format: {changeTime, priceData:{price}}
3. state.priceChanges — top-level fallback (rare)
Normalizes to: {change_time, price_rub, diff_percent}.
"""
raw = (
offer.get("priceChanges")
or offer_data.get("priceChanges")
or state.get("priceChanges")
or []
)
if not isinstance(raw, list):
return []
result = []
for entry in raw:
if not isinstance(entry, dict):
continue
# price_rub: flat field (curl_cffi) OR nested priceData.price (browser)
price_data = entry.get("priceData") or {}
price_rub = entry.get("price") or entry.get("priceRub") or price_data.get("price")
result.append(
{
"change_time": entry.get("changeTime") or entry.get("date"),
"price_rub": price_rub,
"diff_percent": entry.get("diffPercent") or entry.get("diff_percent"),
}
)
return result
def _extract_agent_profile(offer: dict[str, Any]) -> dict[str, Any] | None:
"""offer.agent or offer.agency or offer.user — extract canonical profile shape."""
agent = offer.get("agent") or {}
user = offer.get("user") or {}
if not agent and not user:
return None
return {
"ext_agent_id": agent.get("id") or user.get("cianUserId"),
"company_name": agent.get("companyName") or user.get("companyName"),
"is_pro": agent.get("isPro") or user.get("isPro"),
"skills": agent.get("skills") or [],
"account_type": agent.get("accountType") or user.get("accountType"),
}
# ── Save helpers ──────────────────────────────────────────────────────────────
def save_detail_enrichment(db: Session, listing_id: int, enrichment: DetailEnrichment) -> None:
"""Persist DetailEnrichment to DB:
- UPDATE listings SET windows_view_type, ceiling_height, ... WHERE id = listing_id
- INSERT INTO offer_price_history rows (if price_changes non-empty)
- INSERT INTO agents (if agent_profile) and link via agent_id_fk
- bti_data не сохраняется здесь — это задача Stage 6 (houses), где есть house_id
Idempotency: re-running безопасно (UPSERT / ON CONFLICT DO NOTHING semantics).
"""
# Extend listings row with detail-enriched fields
db.execute(
text("""
UPDATE listings SET
windows_view_type = COALESCE(:wvt, windows_view_type),
separate_wcs_count = COALESCE(:swc, separate_wcs_count),
combined_wcs_count = COALESCE(:cwc, combined_wcs_count),
ceiling_height = COALESCE(:ch, ceiling_height),
repair_type = COALESCE(:rt, repair_type),
repair_state = COALESCE(:rs, repair_state),
kitchen_area_m2 = COALESCE(CAST(:ka AS double precision), kitchen_area_m2),
description = COALESCE(:descr, description),
views_total = COALESCE(:vt, views_total),
views_today = COALESCE(:vd, views_today),
detail_enriched_at = NOW()
WHERE id = CAST(:lid AS bigint)
"""),
{
"lid": listing_id,
"wvt": enrichment.windows_view_type,
"swc": enrichment.separate_wcs_count,
"cwc": enrichment.combined_wcs_count,
"ch": enrichment.ceiling_height,
"rt": enrichment.repair_type,
"rs": enrichment.repair_state,
"ka": enrichment.kitchen_area_m2,
"descr": enrichment.description,
"vt": enrichment.views_total,
"vd": enrichment.views_today,
},
)
# Snapshot: point-in-time observation в listings_snapshots.
# Берём price_rub / price_per_m2 из listings строки (только что обновлённой).
# Используем COALESCE — на случай если listings.price_rub NULL (не должно быть, но safe).
_snap_row = db.execute(
text("""
SELECT price_rub, price_per_m2
FROM listings
WHERE id = CAST(:lid AS bigint)
"""),
{"lid": listing_id},
).fetchone()
_snap_written = False
if _snap_row is not None and _snap_row.price_rub is not None:
try:
with db.begin_nested():
upsert_listing_snapshot(
db,
listing_id=listing_id,
price_rub=int(_snap_row.price_rub),
price_per_m2=int(_snap_row.price_per_m2) if _snap_row.price_per_m2 else None,
run_id=None,
status="active",
)
_snap_written = True
except Exception as exc:
logger.warning(
"save_detail_enrichment: snapshot write failed listing_id=%s: %s",
listing_id,
exc,
)
# Price changes — INSERT new rows, de-dup by listing_id + change_time
for change in enrichment.price_changes:
if not change.get("change_time") or not change.get("price_rub"):
continue
db.execute(
text("""
INSERT INTO offer_price_history
(listing_id, change_time, price_rub, diff_percent, source)
VALUES (
CAST(:lid AS bigint),
CAST(:ct AS timestamptz),
CAST(:price AS numeric),
CAST(:diff AS numeric),
'cian'
)
ON CONFLICT ON CONSTRAINT offer_price_history_listing_change_uq DO NOTHING
"""),
{
"lid": listing_id,
"ct": change["change_time"],
"price": change["price_rub"],
"diff": _clamp_diff_percent(change.get("diff_percent")),
},
)
# Agent profile UPSERT — store agent row and link to listing
if enrichment.agent_profile and enrichment.agent_profile.get("ext_agent_id"):
ap = enrichment.agent_profile
row = db.execute(
text("""
INSERT INTO agents
(ext_source, ext_agent_id, company_name, is_pro, skills, account_type)
VALUES (
'cian',
:eai,
:cn,
:ip,
CAST(:sk AS jsonb),
:at
)
ON CONFLICT (ext_source, ext_agent_id) DO UPDATE SET
company_name = COALESCE(EXCLUDED.company_name, agents.company_name),
is_pro = COALESCE(EXCLUDED.is_pro, agents.is_pro),
skills = COALESCE(EXCLUDED.skills, agents.skills),
account_type = COALESCE(EXCLUDED.account_type, agents.account_type)
RETURNING id
"""),
{
"eai": str(ap["ext_agent_id"]),
"cn": ap.get("company_name"),
"ip": ap.get("is_pro"),
"sk": json.dumps(ap.get("skills") or []),
"at": ap.get("account_type"),
},
)
agent_db_id = row.scalar_one_or_none()
if agent_db_id is not None:
db.execute(
text("""
UPDATE listings
SET agent_id_fk = CAST(:aid AS bigint)
WHERE id = CAST(:lid AS bigint)
AND agent_id_fk IS NULL
"""),
{"aid": agent_db_id, "lid": listing_id},
)
db.commit()
logger.info(
"Cian detail enrichment saved for listing_id=%s (price_changes=%d, agent=%s, snapshot=%s)",
listing_id,
len(enrichment.price_changes),
bool(enrichment.agent_profile),
_snap_written,
)