feat(yandex): map rich entity fields (predictedPrice, seller, listing_date, trend, metro) #1725

Merged
lekss361 merged 1 commit from feat/yandex-rich-fields into main 2026-06-17 20:03:26 +00:00
5 changed files with 443 additions and 5 deletions

View file

@ -101,7 +101,9 @@ class ScrapedLot(BaseModel):
# Метаданные
listing_date: date | None = None
publish_date: date | None = None
days_on_market: int | None = None
description: str | None = None
photo_urls: list[str] = Field(default_factory=list)
raw_payload: dict[str, Any] | None = None
@ -119,6 +121,15 @@ class ScrapedLot(BaseModel):
bargain_allowed: bool | None = None
sale_type: str | None = None
metro_stations: list[dict] = Field(default_factory=list)
agency_name: str | None = None
# Yandex gate-API rich fields
yandex_offer_id: str | None = None # numeric string offerId (kept as text)
predicted_price_rub: int | None = None # Yandex per-offer valuation (predictedPrice.value)
predicted_price_min: int | None = None
predicted_price_max: int | None = None
price_trend: str | None = None # INCREASED / DECREASED / UNCHANGED / ...
price_previous_rub: int | None = None # price.previous
def compute_dedup_hash(self) -> str:
"""SHA256(source + stable_key) — стабильный uniqueness key.
@ -252,11 +263,15 @@ def save_listings(
house_type, repair_state, has_balcony,
house_source, house_ext_id, house_url, listing_segment,
price_rub, price_per_m2,
listing_date, days_on_market, photo_urls, raw_payload,
listing_date, publish_date, days_on_market, description,
photo_urls, raw_payload,
living_area_m2, bedrooms_count, balconies_count, loggias_count,
description_minhash, cadastral_number, building_cadastral_number,
phones, is_homeowner, is_pro_seller,
bargain_allowed, sale_type, metro_stations,
bargain_allowed, sale_type, metro_stations, agency_name,
yandex_offer_id, predicted_price_rub,
predicted_price_min, predicted_price_max,
price_trend, price_previous_rub,
geo_precision,
scraped_at, last_seen_at
) VALUES (
@ -266,13 +281,16 @@ def save_listings(
:house_type, :repair_state, :has_balcony,
:house_source, :house_ext_id, :house_url, :listing_segment,
:price_rub, :ppm2,
:listing_date, :days_on_market,
:listing_date, :publish_date, :days_on_market, :description,
CAST(:photos AS jsonb),
CAST(:raw AS jsonb),
:living_area_m2, :bedrooms_count, :balconies_count, :loggias_count,
:description_minhash, :cadastral_number, :building_cadastral_number,
CAST(:phones AS jsonb), :is_homeowner, :is_pro_seller,
:bargain_allowed, :sale_type, CAST(:metro_stations AS jsonb),
:bargain_allowed, :sale_type, CAST(:metro_stations AS jsonb), :agency_name,
:yandex_offer_id, :predicted_price_rub,
:predicted_price_min, :predicted_price_max,
:price_trend, :price_previous_rub,
:geo_precision,
NOW(), NOW()
)
@ -297,7 +315,32 @@ def save_listings(
sale_type = EXCLUDED.sale_type,
metro_stations = EXCLUDED.metro_stations,
listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date),
area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2)
area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2),
-- Yandex rich fields (+ shared description/agency/publish_date):
-- COALESCE so a source that does not provide them never wipes
-- a value previously written by another source / scrape.
publish_date = COALESCE(EXCLUDED.publish_date, listings.publish_date),
days_on_market = COALESCE(
EXCLUDED.days_on_market, listings.days_on_market
),
description = COALESCE(EXCLUDED.description, listings.description),
agency_name = COALESCE(EXCLUDED.agency_name, listings.agency_name),
yandex_offer_id = COALESCE(
EXCLUDED.yandex_offer_id, listings.yandex_offer_id
),
predicted_price_rub = COALESCE(
EXCLUDED.predicted_price_rub, listings.predicted_price_rub
),
predicted_price_min = COALESCE(
EXCLUDED.predicted_price_min, listings.predicted_price_min
),
predicted_price_max = COALESCE(
EXCLUDED.predicted_price_max, listings.predicted_price_max
),
price_trend = COALESCE(EXCLUDED.price_trend, listings.price_trend),
price_previous_rub = COALESCE(
EXCLUDED.price_previous_rub, listings.price_previous_rub
)
RETURNING id, (xmax = 0) AS inserted
"""
),
@ -324,7 +367,9 @@ def save_listings(
"price_rub": lot.price_rub,
"ppm2": ppm2,
"listing_date": lot.listing_date,
"publish_date": lot.publish_date,
"days_on_market": lot.days_on_market,
"description": lot.description,
"photos": _to_json(lot.photo_urls),
"raw": _to_json(lot.raw_payload) if lot.raw_payload else None,
# Cian-specific columns — None for non-Cian scrapers (→ SQL NULL)
@ -341,6 +386,13 @@ def save_listings(
"bargain_allowed": lot.bargain_allowed,
"sale_type": lot.sale_type,
"metro_stations": _to_json(lot.metro_stations) if lot.metro_stations else None,
"agency_name": lot.agency_name,
"yandex_offer_id": lot.yandex_offer_id,
"predicted_price_rub": lot.predicted_price_rub,
"predicted_price_min": lot.predicted_price_min,
"predicted_price_max": lot.predicted_price_max,
"price_trend": lot.price_trend,
"price_previous_rub": lot.price_previous_rub,
"geo_precision": lot.geo_precision,
},
).fetchone()

View file

@ -32,6 +32,7 @@ import logging
import math
import re
from dataclasses import dataclass
from datetime import UTC, date, datetime
from typing import Any
from urllib.parse import urlencode
@ -134,6 +135,91 @@ def _segment_for(new_flat: str) -> str:
return "novostroyki" if new_flat == "YES" else "vtorichka"
def _parse_creation_date(raw: Any) -> tuple[date | None, int | None]:
"""Parse gate-API creationDate ('2026-05-24T15:10:27Z') → (date, days_on_market).
days_on_market = floor((now - creationDate).days), clamped to >= 0.
Returns (None, None) when raw is absent or unparseable (never raises).
"""
if not raw or not isinstance(raw, str):
return None, None
txt = raw.strip()
# datetime.fromisoformat handles 'Z' only from py3.11+, but normalise defensively.
if txt.endswith("Z"):
txt = txt[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(txt)
except ValueError:
return None, None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
listing_date = dt.date()
delta_days = (datetime.now(UTC) - dt).days
days_on_market = max(delta_days, 0)
return listing_date, days_on_market
def _author_fields(author: dict[str, Any]) -> tuple[bool | None, bool | None, str | None]:
"""Map entity.author → (is_homeowner, is_pro_seller, agency_name).
author.category {OWNER, AGENCY, AGENT, ...}.
OWNER is_homeowner=True
AGENCY / AGENT is_pro_seller=True, agency_name = organization or agentName
Returns (None, None, None) when author/category absent.
"""
category = (author.get("category") or "").upper()
if not category:
return None, None, None
if category == "OWNER":
return True, None, None
if category in {"AGENCY", "AGENT"}:
agency_name = author.get("organization") or author.get("agentName") or None
return None, True, agency_name
return None, None, None
def _to_bigint(raw: Any) -> int | None:
"""Coerce a gate-API numeric value (str or number) to int. None when unparseable."""
if raw is None:
return None
try:
return int(float(raw))
except (TypeError, ValueError):
return None
def _predicted_prices(entity: dict[str, Any]) -> tuple[int | None, int | None, int | None]:
"""Extract (value, min, max) from entity.predictedPrice.predictedPrice.
Yandex's own per-offer valuation. Fields are strings (e.g. "7554000").
Returns (None, None, None) when the nested path is absent.
"""
outer = entity.get("predictedPrice") or {}
inner = outer.get("predictedPrice") or {}
if not isinstance(inner, dict):
return None, None, None
return (
_to_bigint(inner.get("value")),
_to_bigint(inner.get("min")),
_to_bigint(inner.get("max")),
)
def _metro_stations(location: dict[str, Any]) -> list[dict[str, Any]]:
"""Build metro_stations [{name, min}] from location.metroList[] (+ location.metro)."""
stations: list[dict[str, Any]] = []
raw_list = location.get("metroList")
if isinstance(raw_list, list):
for m in raw_list:
if isinstance(m, dict) and m.get("name"):
stations.append({"name": m.get("name"), "min": m.get("timeToMetro")})
if not stations:
single = location.get("metro")
if isinstance(single, dict) and single.get("name"):
stations.append({"name": single.get("name"), "min": single.get("timeToMetro")})
return stations
def _parse_gate_json(
payload: dict[str, Any], page_param: int = 1, new_flat: str = "NO"
) -> list[ScrapedLot]:
@ -158,6 +244,13 @@ def _parse_gate_json(
mainImages[] -> photo_urls (prepend "https:", up to 5)
building.siteId -> house_ext_id (JK linkage)
listing_segment -> "novostroyki" (newFlat=YES) | "vtorichka" (newFlat=NO)
creationDate (ISO Z) -> listing_date / publish_date + days_on_market
description -> description
author.category/organization -> is_homeowner / is_pro_seller / agency_name
predictedPrice.predictedPrice.* -> predicted_price_rub / _min / _max
price.trend / price.previous -> price_trend / price_previous_rub
location.metroList[] -> metro_stations [{name, min}]
offerId -> yandex_offer_id
"""
result = _extract_gate_data(payload)
if result is None:
@ -245,6 +338,20 @@ def _entity_to_lot(
address = location.get("geocoderAddress") or location.get("streetAddress") or None
# ── Rich gate-API fields ───────────────────────────────────────────
listing_date, days_on_market = _parse_creation_date(entity.get("creationDate"))
description = entity.get("description") or None
author = entity.get("author") or {}
is_homeowner, is_pro_seller, agency_name = _author_fields(author)
predicted_value, predicted_min, predicted_max = _predicted_prices(entity)
price_trend = price_dict.get("trend") or None
price_previous_rub = _to_bigint(price_dict.get("previous"))
metro_stations = _metro_stations(location)
photo_urls: list[str] = []
for img in entity.get("mainImages") or []:
if img:
@ -273,6 +380,20 @@ def _entity_to_lot(
house_ext_id=house_ext_id,
house_url=None,
listing_segment=_segment_for(new_flat),
listing_date=listing_date,
publish_date=listing_date,
days_on_market=days_on_market,
description=description,
is_homeowner=is_homeowner,
is_pro_seller=is_pro_seller,
agency_name=agency_name,
metro_stations=metro_stations,
yandex_offer_id=offer_id,
predicted_price_rub=predicted_value,
predicted_price_min=predicted_min,
predicted_price_max=predicted_max,
price_trend=price_trend,
price_previous_rub=price_previous_rub,
raw_payload={
"page_param": page_param,
"ceiling_height": ceiling_height,

View file

@ -0,0 +1,30 @@
-- Migration 121: Yandex gate-API rich entity fields the parser previously dropped.
--
-- Источник: realty.yandex.ru/gate/react-page/get/ entity object содержит ~90 полей,
-- из которых маппилось ~11. Эта миграция добавляет колонки под high-value поля:
--
-- predictedPrice.predictedPrice.{value,min,max} → собственная оценка Яндекса
-- per-offer (рыночная цена) — headline-выигрыш для estimator/анализа bargain.
-- price.trend / price.previous → динамика цены объявления.
--
-- description / listing_date / publish_date / days_on_market / metro_stations /
-- is_homeowner / is_pro_seller / agency_name / yandex_offer_id — уже существуют
-- в listings (миграции 011 / 033 и др.); эта миграция их НЕ трогает, парсер просто
-- начинает их наполнять (см. base.py save_listings + yandex_realty._entity_to_lot).
--
-- Idempotent: ADD COLUMN IF NOT EXISTS. Авто-применяется на деплое.
BEGIN;
ALTER TABLE listings ADD COLUMN IF NOT EXISTS predicted_price_rub bigint;
ALTER TABLE listings ADD COLUMN IF NOT EXISTS predicted_price_min bigint;
ALTER TABLE listings ADD COLUMN IF NOT EXISTS predicted_price_max bigint;
ALTER TABLE listings ADD COLUMN IF NOT EXISTS price_trend text;
ALTER TABLE listings ADD COLUMN IF NOT EXISTS price_previous_rub bigint;
COMMENT ON COLUMN listings.predicted_price_rub IS
'Yandex per-offer valuation (predictedPrice.predictedPrice.value), RUB';
COMMENT ON COLUMN listings.price_trend IS
'Yandex price.trend: INCREASED / DECREASED / UNCHANGED / ...';
COMMIT;

View file

@ -594,3 +594,104 @@ def test_save_listings_null_area_and_date_pass_none_in_params():
params = _get_execute_params(db)
assert params["area_m2"] is None
assert params["listing_date"] is None
# ── Tests 18-20: Yandex rich fields (predictedPrice, trend, description, agency) ──
def _yandex_rich_lot(**overrides) -> ScrapedLot:
"""ScrapedLot с заполненными Yandex rich-полями."""
import datetime
base = {
"source": "yandex",
"source_url": "https://realty.yandex.ru/offer/5500000000000000001/",
"source_id": "5500000000000000001",
"price_rub": 7_200_000,
"address": "Екатеринбург, улица Мира, 10",
"rooms": 2,
"area_m2": 48.0,
"listing_date": datetime.date(2026, 5, 24),
"publish_date": datetime.date(2026, 5, 24),
"days_on_market": 24,
"description": "Светлая квартира.",
"is_homeowner": True,
"agency_name": None,
"metro_stations": [{"name": "Динамо", "min": 7}],
"yandex_offer_id": "5500000000000000001",
"predicted_price_rub": 7_554_000,
"predicted_price_min": 7_100_000,
"predicted_price_max": 8_000_000,
"price_trend": "DECREASED",
"price_previous_rub": 7_500_000,
}
base.update(overrides)
return ScrapedLot(**base)
def test_save_listings_persists_yandex_rich_fields():
"""Yandex rich-поля попадают в params INSERT."""
db = _mock_db(inserted=True)
lot = _yandex_rich_lot()
save_listings(db, [lot])
params = _get_execute_params(db)
assert params["description"] == "Светлая квартира."
assert params["days_on_market"] == 24
assert params["yandex_offer_id"] == "5500000000000000001"
assert params["predicted_price_rub"] == 7_554_000
assert params["predicted_price_min"] == 7_100_000
assert params["predicted_price_max"] == 8_000_000
assert params["price_trend"] == "DECREASED"
assert params["price_previous_rub"] == 7_500_000
assert params["agency_name"] is None
metro = json.loads(params["metro_stations"])
assert metro == [{"name": "Динамо", "min": 7}]
def test_save_listings_yandex_rich_cols_in_sql():
"""INSERT + ON CONFLICT SET содержат новые Yandex rich колонки."""
db = _mock_db(inserted=False)
lot = _yandex_rich_lot(agency_name="Агентство «Диал»", is_pro_seller=True, is_homeowner=None)
save_listings(db, [lot])
sql_text = _get_listings_insert_sql(db)
for col in (
"description",
"publish_date",
"agency_name",
"yandex_offer_id",
"predicted_price_rub",
"predicted_price_min",
"predicted_price_max",
"price_trend",
"price_previous_rub",
):
assert col in sql_text, f"Column '{col}' missing from INSERT SQL"
# COALESCE backfill in DO UPDATE SET (never wipe a prior value)
assert "description = COALESCE(EXCLUDED.description, listings.description)" in sql_text
assert "agency_name = COALESCE(EXCLUDED.agency_name, listings.agency_name)" in sql_text
assert "predicted_price_rub = COALESCE(" in sql_text
assert "price_trend = COALESCE(EXCLUDED.price_trend, listings.price_trend)" in sql_text
def test_save_listings_non_yandex_rich_cols_are_null():
"""Avito lot не имеет Yandex rich-полей → params = None (backward compat)."""
db = _mock_db(inserted=True)
lot = _base_lot(source="avito", source_id="987654")
save_listings(db, [lot])
params = _get_execute_params(db)
assert params["description"] is None
assert params["publish_date"] is None
assert params["agency_name"] is None
assert params["yandex_offer_id"] is None
assert params["predicted_price_rub"] is None
assert params["predicted_price_min"] is None
assert params["predicted_price_max"] is None
assert params["price_trend"] is None
assert params["price_previous_rub"] is None

View file

@ -100,6 +100,58 @@ _ENTITY_NO_OFFER_ID: dict = {
}
# Entity carrying the rich gate-API fields (creationDate, description, author,
# predictedPrice, price.trend/previous, location.metroList) — verified live 2026-06-17.
_ENTITY_RICH_OWNER: dict = {
"offerId": 5500000000000000001,
"url": "//realty.yandex.ru/offer/5500000000000000001",
"creationDate": "2026-05-24T15:10:27Z",
"description": "Продаётся светлая квартира с видом на парк.",
"author": {"category": "OWNER", "agentName": "Иван", "organization": None},
"price": {
"value": 7200000,
"valuePerPart": 150000,
"trend": "DECREASED",
"previous": 7500000,
},
"predictedPrice": {"predictedPrice": {"value": "7554000", "min": "7100000", "max": "8000000"}},
"area": {"value": 48.0},
"roomsTotal": 2,
"floorsOffered": [5],
"floorsTotal": 12,
"building": {"builtYear": 2018, "buildingType": "MONOLIT"},
"location": {
"geocoderAddress": "Ekaterinburg, ulitsa Mira, 10",
"point": {"latitude": 56.84, "longitude": 60.61},
"metroList": [
{"name": "Ploshchad 1905 goda", "timeToMetro": 7},
{"name": "Geologicheskaya", "timeToMetro": 12},
],
},
"mainImages": [],
}
_ENTITY_RICH_AGENCY: dict = {
"offerId": 5500000000000000002,
"url": "//realty.yandex.ru/offer/5500000000000000002",
"creationDate": "2026-06-01T09:00:00Z",
"description": "Агентская продажа.",
"author": {"category": "AGENCY", "agentName": "Петров", "organization": "Агентство «Диал»"},
"price": {"value": 9900000, "trend": "UNCHANGED"},
"area": {"value": 60.0},
"roomsTotal": 3,
"floorsOffered": [3],
"floorsTotal": 9,
"building": {"builtYear": 2012, "buildingType": "BRICK"},
"location": {
"geocoderAddress": "Ekaterinburg, ulitsa Lenina, 1",
"point": {"latitude": 56.83, "longitude": 60.6},
"metro": {"name": "Dinamo", "timeToMetro": 4},
},
"mainImages": [],
}
def _make_gate_payload(entities: list, pager: dict | None = None) -> dict:
"""Wrap entities + pager into gate-API response shape."""
if pager is None:
@ -271,6 +323,88 @@ def test_entity_to_lot_page_param_in_raw():
assert lot.raw_payload["page_param"] == 3
# ---------------------------------------------------------------------------
# PART B2: rich gate-API fields (predictedPrice, seller, dates, trend, metro)
# ---------------------------------------------------------------------------
def test_entity_to_lot_rich_owner_fields():
lot = _entity_to_lot(_ENTITY_RICH_OWNER)
assert lot is not None
# creationDate → listing_date / publish_date / days_on_market
assert lot.listing_date is not None
assert lot.listing_date.isoformat() == "2026-05-24"
assert lot.publish_date == lot.listing_date
assert lot.days_on_market is not None
assert lot.days_on_market >= 0
# description
assert lot.description == "Продаётся светлая квартира с видом на парк."
# author OWNER
assert lot.is_homeowner is True
assert lot.is_pro_seller is None
assert lot.agency_name is None
# predictedPrice
assert lot.predicted_price_rub == 7_554_000
assert lot.predicted_price_min == 7_100_000
assert lot.predicted_price_max == 8_000_000
# price trend / previous
assert lot.price_trend == "DECREASED"
assert lot.price_previous_rub == 7_500_000
# metro_stations from metroList
assert lot.metro_stations == [
{"name": "Ploshchad 1905 goda", "min": 7},
{"name": "Geologicheskaya", "min": 12},
]
# yandex_offer_id
assert lot.yandex_offer_id == "5500000000000000001"
def test_entity_to_lot_rich_agency_fields():
lot = _entity_to_lot(_ENTITY_RICH_AGENCY)
assert lot is not None
# AGENCY → pro seller + agency_name from organization
assert lot.is_pro_seller is True
assert lot.is_homeowner is None
assert lot.agency_name == "Агентство «Диал»"
# price.trend present, no previous → price_previous_rub None
assert lot.price_trend == "UNCHANGED"
assert lot.price_previous_rub is None
# no predictedPrice path → all None
assert lot.predicted_price_rub is None
assert lot.predicted_price_min is None
assert lot.predicted_price_max is None
# single location.metro fallback
assert lot.metro_stations == [{"name": "Dinamo", "min": 4}]
def test_entity_to_lot_no_rich_fields_are_none():
"""Base fixture lacks rich fields → all new fields default to None/[] (no crash)."""
lot = _entity_to_lot(_ENTITY_FULL)
assert lot is not None
assert lot.listing_date is None
assert lot.days_on_market is None
assert lot.description is None
assert lot.is_homeowner is None
assert lot.is_pro_seller is None
assert lot.agency_name is None
assert lot.predicted_price_rub is None
assert lot.price_trend is None
assert lot.price_previous_rub is None
assert lot.metro_stations == []
# yandex_offer_id is always populated from offerId
assert lot.yandex_offer_id == "4740475460451078271"
def test_entity_to_lot_bad_creation_date_is_safe():
"""Unparseable creationDate → listing_date/days_on_market None, no exception."""
entity = dict(_ENTITY_RICH_OWNER)
entity["creationDate"] = "not-a-date"
lot = _entity_to_lot(entity)
assert lot is not None
assert lot.listing_date is None
assert lot.days_on_market is None
# ---------------------------------------------------------------------------
# PART C: _parse_gate_json (list-level)
# ---------------------------------------------------------------------------