176 lines
6.3 KiB
Python
176 lines
6.3 KiB
Python
"""Orchestration for FINN search + Eiendom.no enrichment + scoring."""
|
|
|
|
import logging
|
|
|
|
from . import ad as ad_module
|
|
from . import cache, eiendom_no, scoring, search
|
|
from .config import (
|
|
FINN_CACHE_PATH,
|
|
FINN_CACHE_TTL_AD_HOURS,
|
|
FINN_DETAIL_LIMIT,
|
|
FINN_MAX_SEARCH_PAGES,
|
|
)
|
|
from .models import EiendomUnit, FinnAd, SimilarUnit
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _normalize_description(text: str | None) -> str:
|
|
return text.lower() if text else ""
|
|
|
|
|
|
def _build_ad_summary(
|
|
ad: FinnAd,
|
|
enriched: EiendomUnit | None,
|
|
similar_units: list[SimilarUnit],
|
|
scores: dict,
|
|
categories: list[str],
|
|
) -> dict:
|
|
description = _normalize_description(ad.listing_description)
|
|
reasons = []
|
|
risks = []
|
|
next_steps = [
|
|
"Open the FINN listing and condition report.",
|
|
"Review the Eiendom.no estimate and comparable sales.",
|
|
"Ask the broker about renovation status and approvals.",
|
|
]
|
|
|
|
if enriched and enriched.estimated_selling_price and ad.total_price:
|
|
if ad.total_price < enriched.estimated_selling_price:
|
|
reasons.append("Listing price is below Eiendom.no estimate.")
|
|
elif ad.total_price <= enriched.estimated_selling_price_upper:
|
|
reasons.append("Price sits within the local estimate range.")
|
|
else:
|
|
reasons.append("Listing price is above the estimate range.")
|
|
else:
|
|
reasons.append("Eiendom.no enrichment is unavailable or incomplete.")
|
|
|
|
if "utsikt" in description or ad.has_balcony or ad.has_terrace:
|
|
reasons.append("Outdoor space or view potential is positive.")
|
|
if "hybel" in description or "leie" in description:
|
|
reasons.append("Potential hybel/rental opportunity is mentioned.")
|
|
if "potensial" in description or "renover" in description:
|
|
reasons.append("Renovation or improvement potential is highlighted.")
|
|
|
|
if scores.get("risk", 0.0) < 0:
|
|
risks.append("Risk flags are detected in description or metadata.")
|
|
if ad.common_costs and ad.common_costs > 5000:
|
|
risks.append("Common costs are relatively high and should be reviewed.")
|
|
if enriched and enriched.sale_status and enriched.sale_status.upper() != "FOR_SALE":
|
|
risks.append("Eiendom.no sale status does not indicate an active sale.")
|
|
if not enriched:
|
|
risks.append("Missing Eiendom.no data increases uncertainty.")
|
|
|
|
if not any("Eiendom.no" in step for step in next_steps):
|
|
next_steps.append("Verify the property on Eiendom.no and reconcile any mismatches.")
|
|
|
|
if similar_units:
|
|
next_steps.append("Review the comparable units and average sqm prices.")
|
|
else:
|
|
next_steps.append("Comparable sales are unavailable; treat valuation with caution.")
|
|
|
|
return {
|
|
"why_interesting": reasons,
|
|
"risks": risks,
|
|
"next_steps": next_steps,
|
|
"shortlist_reason": ", ".join(reasons[:3])
|
|
if reasons
|
|
else "Review details and seller disclosures.",
|
|
}
|
|
|
|
|
|
async def analyze_ad(
|
|
finn_ad: FinnAd,
|
|
unit_code: str | None = None,
|
|
) -> dict:
|
|
"""Enrich a FinnAd and compute score summary."""
|
|
conn = cache.init_db(FINN_CACHE_PATH)
|
|
enriched: EiendomUnit | None = None
|
|
similar_units: list[SimilarUnit] = []
|
|
|
|
if unit_code:
|
|
enriched = cache.get_eiendom_unit(conn, unit_code)
|
|
if enriched is None:
|
|
enriched = await eiendom_no.enrich_ad_with_eiendom_no(finn_ad, unit_code)
|
|
if enriched is not None:
|
|
cache.save_eiendom_unit(conn, enriched)
|
|
|
|
if enriched and enriched.unit_vector:
|
|
similar_units = cache.get_similar_units(conn, enriched.unit_code, "RECENTLY_SOLD")
|
|
if not similar_units:
|
|
similar_units = await eiendom_no.get_similar_units(enriched.unit_vector)
|
|
if similar_units:
|
|
cache.save_similar_units(conn, enriched.unit_code, "RECENTLY_SOLD", similar_units)
|
|
|
|
scores = scoring.score_ad(finn_ad, enriched, similar_units)
|
|
categories = scoring.classify_ad(scores)
|
|
summary = _build_ad_summary(finn_ad, enriched, similar_units, scores, categories)
|
|
|
|
result = {
|
|
"finnkode": finn_ad.finnkode,
|
|
"title": finn_ad.title,
|
|
"address": finn_ad.address,
|
|
"score": scores,
|
|
"categories": categories,
|
|
"summary": summary,
|
|
"eiendom_unit": enriched.model_dump() if enriched else None,
|
|
"similar_units": [unit.model_dump() for unit in similar_units],
|
|
}
|
|
cache.save_finn_ad(conn, finn_ad)
|
|
return result
|
|
|
|
|
|
async def analyze_search(
|
|
search_url: str,
|
|
max_pages: int = FINN_MAX_SEARCH_PAGES,
|
|
fetch_details: bool = True,
|
|
detail_limit: int = FINN_DETAIL_LIMIT,
|
|
include_eiendom_no: bool = True,
|
|
client=None,
|
|
use_cache: bool = True,
|
|
) -> dict:
|
|
"""Analyze a FINN search URL and enrich matching listings."""
|
|
conn = cache.init_db(FINN_CACHE_PATH)
|
|
cards = await search.fetch_search_pages(
|
|
search_url,
|
|
max_pages=max_pages,
|
|
client=client,
|
|
use_cache=use_cache,
|
|
)
|
|
results = []
|
|
enriched_count = 0
|
|
|
|
if fetch_details:
|
|
for card in cards[:detail_limit]:
|
|
finn_ad = cache.get_finn_ad(conn, card.finnkode, ttl_hours=FINN_CACHE_TTL_AD_HOURS)
|
|
if finn_ad is None:
|
|
finn_ad = await ad_module.fetch_ad_details(card.finnkode, client=client)
|
|
unit_code = None
|
|
if include_eiendom_no:
|
|
try:
|
|
matched_unit = await eiendom_no.search_unit_from_finn_url(card.url)
|
|
except Exception as exc:
|
|
logger.warning("Eiendom.no unit search failed: %s", exc)
|
|
matched_unit = None
|
|
unit_code = (
|
|
matched_unit.unit_code
|
|
if matched_unit
|
|
else eiendom_no.resolve_unit_from_finn_url(card.url)
|
|
)
|
|
result = await analyze_ad(finn_ad, unit_code=unit_code)
|
|
if result.get("eiendom_unit"):
|
|
enriched_count += 1
|
|
results.append(result)
|
|
|
|
results.sort(key=lambda item: item["score"].get("total", 0.0), reverse=True)
|
|
return {
|
|
"search_url": search_url,
|
|
"search_cards": [card.model_dump() for card in cards],
|
|
"analysis": results,
|
|
"summary": {
|
|
"total_listings": len(cards),
|
|
"analyzed_listings": len(results),
|
|
"eiendom_enriched": enriched_count,
|
|
},
|
|
}
|