initial
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"""Eiendom.no enrichment, unit vector, and similar units client."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import msgpack
|
||||
|
||||
from .config import (
|
||||
EIENDOM_NO_BASE_URL,
|
||||
EIENDOM_NO_ENABLED,
|
||||
EIENDOM_NO_REQUEST_DELAY_SECONDS,
|
||||
EIENDOM_NO_SIMILAR_UNITS_DEFAULT_STATUS,
|
||||
)
|
||||
from .http import HTTPClient
|
||||
from .models import EiendomUnit, SimilarUnit, UnitVector
|
||||
from .parser import extract_finnkode_from_url, normalize_finnkode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_coordinates(geometry: dict) -> tuple[float | None, float | None]:
|
||||
if not isinstance(geometry, dict):
|
||||
return None, None
|
||||
coords = geometry.get("coordinates") or []
|
||||
if isinstance(coords, (list, tuple)) and len(coords) >= 2:
|
||||
return coords[0], coords[1]
|
||||
return None, None
|
||||
|
||||
|
||||
def parse_eiendom_unit_json(unit_data: dict) -> EiendomUnit:
|
||||
geometry = unit_data.get("geometry", {})
|
||||
lon, lat = _extract_coordinates(geometry)
|
||||
specification = unit_data.get("specification", {})
|
||||
valuation = unit_data.get("valuation", {})
|
||||
market = unit_data.get("latestMarketData", {})
|
||||
|
||||
return EiendomUnit(
|
||||
unit_code=unit_data.get("unitCode", ""),
|
||||
address=unit_data.get("address") or unit_data.get("streetAddress"),
|
||||
lat=lat or unit_data.get("lat"),
|
||||
lng=lon or unit_data.get("lon"),
|
||||
property_type=specification.get("propertyType") or unit_data.get("propertyType"),
|
||||
floor=specification.get("floor") or unit_data.get("floor"),
|
||||
rooms=specification.get("rooms") or unit_data.get("rooms"),
|
||||
construction_year=specification.get("constructionYear")
|
||||
or unit_data.get("constructionYear"),
|
||||
usable_area=specification.get("usableArea") or unit_data.get("usableArea"),
|
||||
estimated_selling_price=valuation.get("estimatedSellingPrice")
|
||||
or unit_data.get("estimatedSellingPrice"),
|
||||
estimated_selling_price_lower=valuation.get("estimatedSellingPriceLower")
|
||||
or unit_data.get("estimatedSellingPriceLower"),
|
||||
estimated_selling_price_upper=valuation.get("estimatedSellingPriceUpper")
|
||||
or unit_data.get("estimatedSellingPriceUpper"),
|
||||
listing_price=market.get("listingPrice") or unit_data.get("listingPrice"),
|
||||
listing_sqm_price=market.get("squareMeterPrice")
|
||||
or unit_data.get("listingSquareMeterPrice"),
|
||||
common_costs=market.get("monthlyCosts")
|
||||
or market.get("commonCosts")
|
||||
or unit_data.get("commonCosts"),
|
||||
days_on_market=market.get("daysOnMarket") or unit_data.get("daysOnMarket"),
|
||||
sale_status=market.get("saleStatus") or unit_data.get("saleStatus"),
|
||||
market_placement_score=market.get("marketPlacementScore")
|
||||
or unit_data.get("marketPlacementScore"),
|
||||
)
|
||||
|
||||
|
||||
def parse_similar_units_json(response_data: dict) -> list[SimilarUnit]:
|
||||
units: list[SimilarUnit] = []
|
||||
for item in response_data.get("units", []):
|
||||
geometry = item.get("geometry", {})
|
||||
lon, lat = _extract_coordinates(geometry)
|
||||
specification = item.get("specification", {})
|
||||
market = item.get("marketData", {})
|
||||
units.append(
|
||||
SimilarUnit(
|
||||
unit_code=item.get("unitCode", ""),
|
||||
address=item.get("address"),
|
||||
lat=lat or item.get("lat"),
|
||||
lng=lon or item.get("lon"),
|
||||
property_type=specification.get("propertyType") or item.get("propertyType"),
|
||||
floor=specification.get("floor") or item.get("floor"),
|
||||
rooms=specification.get("rooms") or item.get("rooms"),
|
||||
construction_year=specification.get("constructionYear")
|
||||
or item.get("constructionYear"),
|
||||
usable_area=specification.get("usableArea") or item.get("usableArea"),
|
||||
listing_price=market.get("listingPrice") or item.get("listingPrice"),
|
||||
selling_price=market.get("sellingPrice") or item.get("sellingPrice"),
|
||||
shared_debt=market.get("jointDebt") or item.get("sharedDebt"),
|
||||
common_costs=market.get("monthlyCosts") or item.get("commonCosts"),
|
||||
sqm_price=market.get("squareMeterPrice") or item.get("squareMeterPrice"),
|
||||
days_on_market=market.get("daysOnMarket") or item.get("daysOnMarket"),
|
||||
sale_status=market.get("saleStatus") or item.get("saleStatus"),
|
||||
finalized_at=item.get("finalizedAt") or market.get("finalizedAt"),
|
||||
listing_status=item.get("listingStatus", "RECENTLY_SOLD"),
|
||||
)
|
||||
)
|
||||
return units
|
||||
|
||||
|
||||
def build_unit_vector(unit: EiendomUnit) -> str:
|
||||
"""Build a base64url-encoded unit_vector from EiendomUnit data."""
|
||||
payload = UnitVector(
|
||||
lon=unit.lng or 0.0,
|
||||
lat=unit.lat or 0.0,
|
||||
ptype=unit.property_type or "APARTMENT",
|
||||
floor=unit.floor,
|
||||
rooms=unit.rooms,
|
||||
built=unit.construction_year,
|
||||
area=unit.usable_area,
|
||||
price=unit.listing_price or unit.estimated_selling_price,
|
||||
)
|
||||
packed = msgpack.packb(payload.model_dump(), use_bin_type=True)
|
||||
encoded = base64.urlsafe_b64encode(packed).decode("utf-8").rstrip("=")
|
||||
return encoded
|
||||
|
||||
|
||||
def decode_unit_vector(vector_str: str) -> dict:
|
||||
"""Decode a base64url unit_vector for debugging."""
|
||||
padding = 4 - (len(vector_str) % 4)
|
||||
if padding != 4:
|
||||
vector_str += "=" * padding
|
||||
packed = base64.urlsafe_b64decode(vector_str.encode("utf-8"))
|
||||
return msgpack.unpackb(packed, raw=False)
|
||||
|
||||
|
||||
async def search_unit_from_finn_url(
|
||||
finn_url: str,
|
||||
client: HTTPClient | None = None,
|
||||
) -> EiendomUnit | None:
|
||||
if not EIENDOM_NO_ENABLED or not finn_url:
|
||||
logger.info("Eiendom.no unit search is disabled or finn_url is empty")
|
||||
return None
|
||||
|
||||
client = client or HTTPClient(
|
||||
base_url=EIENDOM_NO_BASE_URL,
|
||||
request_delay_seconds=EIENDOM_NO_REQUEST_DELAY_SECONDS,
|
||||
)
|
||||
response = await client.get(
|
||||
"/geodata/units/search/",
|
||||
params={"search": finn_url},
|
||||
)
|
||||
data = response.json()
|
||||
units = data.get("units", [])
|
||||
if not units:
|
||||
return None
|
||||
return parse_eiendom_unit_json(units[0])
|
||||
|
||||
|
||||
async def get_unit(
|
||||
unit_code: str,
|
||||
client: HTTPClient | None = None,
|
||||
) -> EiendomUnit | None:
|
||||
if not EIENDOM_NO_ENABLED:
|
||||
logger.info("Eiendom.no enrichment is disabled")
|
||||
return None
|
||||
|
||||
client = client or HTTPClient(
|
||||
base_url=EIENDOM_NO_BASE_URL,
|
||||
request_delay_seconds=EIENDOM_NO_REQUEST_DELAY_SECONDS,
|
||||
)
|
||||
path = f"/geodata/units/{unit_code}/"
|
||||
response = await client.get(path)
|
||||
data = response.json()
|
||||
units = data.get("units") or []
|
||||
if not units and isinstance(data, dict) and data.get("unitCode"):
|
||||
return parse_eiendom_unit_json(data)
|
||||
if not units:
|
||||
return None
|
||||
return parse_eiendom_unit_json(units[0])
|
||||
|
||||
|
||||
async def get_eiendom_unit(
|
||||
unit_code: str,
|
||||
client: HTTPClient | None = None,
|
||||
) -> EiendomUnit | None:
|
||||
return await get_unit(unit_code, client=client)
|
||||
|
||||
|
||||
async def get_similar_units(
|
||||
unit_vector: str,
|
||||
listing_status: str = EIENDOM_NO_SIMILAR_UNITS_DEFAULT_STATUS,
|
||||
client: HTTPClient | None = None,
|
||||
) -> list[SimilarUnit]:
|
||||
if not EIENDOM_NO_ENABLED:
|
||||
logger.info("Eiendom.no similar-units disabled")
|
||||
return []
|
||||
|
||||
client = client or HTTPClient(
|
||||
base_url=EIENDOM_NO_BASE_URL,
|
||||
request_delay_seconds=EIENDOM_NO_REQUEST_DELAY_SECONDS,
|
||||
)
|
||||
response = await client.get(
|
||||
"/geodata/units/similar/",
|
||||
params={"unit_vector": unit_vector},
|
||||
)
|
||||
data = response.json()
|
||||
units = parse_similar_units_json(data)
|
||||
|
||||
listing_status = (listing_status or "").upper()
|
||||
if listing_status == "RECENTLY_SOLD":
|
||||
units = [
|
||||
unit
|
||||
for unit in units
|
||||
if unit.sale_status and unit.sale_status.upper() == "SOLD" and unit.finalized_at
|
||||
]
|
||||
elif listing_status == "FOR_SALE":
|
||||
units = [
|
||||
unit for unit in units if unit.sale_status and unit.sale_status.upper() == "FORSALE"
|
||||
]
|
||||
|
||||
return units
|
||||
|
||||
|
||||
def resolve_unit_from_finn_url(finn_url: str) -> str | None:
|
||||
"""Resolve the FINN URL into a unit identifier or unitCode placeholder."""
|
||||
if not finn_url:
|
||||
return None
|
||||
candidate = normalize_finnkode(extract_finnkode_from_url(finn_url))
|
||||
if candidate:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
async def enrich_ad_with_eiendom_no(
|
||||
ad: Any,
|
||||
unit_code: str | None = None,
|
||||
client: HTTPClient | None = None,
|
||||
) -> EiendomUnit | None:
|
||||
if not unit_code:
|
||||
return None
|
||||
unit = await get_eiendom_unit(unit_code, client=client)
|
||||
if unit is None:
|
||||
return None
|
||||
unit.unit_vector = build_unit_vector(unit)
|
||||
return unit
|
||||
Reference in New Issue
Block a user