305 lines
10 KiB
Python
305 lines
10 KiB
Python
"""FastMCP stdio server for FINN real estate analysis and Eiendom.no enrichment."""
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
from .analysis import analyze_search
|
|
from .eiendom_no import (
|
|
build_unit_vector,
|
|
decode_unit_vector,
|
|
get_similar_units,
|
|
get_unit,
|
|
search_unit_from_finn_url,
|
|
)
|
|
from .formatting import (
|
|
render_ad,
|
|
render_comparison,
|
|
render_diff,
|
|
render_shortlist,
|
|
render_similar_units,
|
|
)
|
|
from .service import (
|
|
analyze_ad,
|
|
analyze_ad_against_comps,
|
|
compare_ads,
|
|
find_similar_to_liked,
|
|
get_new_ads_since_last_run,
|
|
get_or_fetch_ad,
|
|
get_or_fetch_eiendom_unit,
|
|
get_shortlist,
|
|
save_feedback,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
mcp = FastMCP("finn_eiendom_mcp")
|
|
|
|
|
|
@mcp.tool(
|
|
description=(
|
|
"Analyze a FINN.no real estate search URL. Scrapes listing cards,"
|
|
" fetches details, enriches with Eiendom.no data, scores, and ranks."
|
|
)
|
|
)
|
|
async def finn_analyze_search(
|
|
search_url: str,
|
|
max_pages: int = 3,
|
|
detail_limit: int = 20,
|
|
include_details: bool = True,
|
|
include_eiendom_no: bool = True,
|
|
) -> str:
|
|
"""Analyze a FINN search URL and return ranked listing results."""
|
|
try:
|
|
result = await analyze_search(
|
|
search_url,
|
|
max_pages=max_pages,
|
|
fetch_details=include_details,
|
|
detail_limit=detail_limit,
|
|
include_eiendom_no=include_eiendom_no,
|
|
)
|
|
return json.dumps(result)
|
|
except Exception as e:
|
|
logger.error(f"Error analyzing search: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(
|
|
description=(
|
|
"Fetch full detail for a FINN listing by finnkode."
|
|
" Checks cache first; use force_refresh=True to bypass."
|
|
)
|
|
)
|
|
async def finn_get_ad(finnkode: str, force_refresh: bool = False) -> str:
|
|
"""Fetch FINN ad details by finnkode."""
|
|
try:
|
|
ad = await get_or_fetch_ad(finnkode, force_refresh=force_refresh)
|
|
return ad.model_dump_json()
|
|
except Exception as e:
|
|
logger.error(f"Error fetching ad {finnkode}: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(
|
|
description="Resolve an Eiendom.no unit_code from a FINN listing URL. "
|
|
"Returns unit_code, address, lat, lng or an error if not found."
|
|
)
|
|
async def finn_resolve_eiendom_unit(finn_url: str) -> str:
|
|
"""Resolve Eiendom.no unit from FINN URL."""
|
|
try:
|
|
unit = await search_unit_from_finn_url(finn_url)
|
|
if unit is None:
|
|
return json.dumps(
|
|
{
|
|
"error": True,
|
|
"message": "Eiendom.no unit could not be resolved from FINN URL",
|
|
}
|
|
)
|
|
return json.dumps(
|
|
{
|
|
"unit_code": unit.unit_code,
|
|
"address": unit.address,
|
|
"lat": unit.lat,
|
|
"lng": unit.lng,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error resolving unit from {finn_url}: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(
|
|
description="Fetch full Eiendom.no unit data by unit_code. Checks SQLite cache (24h TTL)."
|
|
)
|
|
async def finn_get_eiendom_unit(unit_code: str, force_refresh: bool = False) -> str:
|
|
"""Fetch Eiendom.no unit details by unit_code."""
|
|
try:
|
|
unit = await get_or_fetch_eiendom_unit(unit_code, force_refresh=force_refresh)
|
|
if unit is None:
|
|
return json.dumps({"error": True, "message": "Eiendom.no unit not found"})
|
|
return unit.model_dump_json()
|
|
except Exception as e:
|
|
logger.error(f"Error fetching unit {unit_code}: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(
|
|
description="Fetch comparable recently-sold or for-sale units from Eiendom.no using a "
|
|
"base64-encoded unit vector. Returns list of similar units with sale prices."
|
|
)
|
|
async def finn_get_similar_units(unit_vector: str, listing_status: str = "RECENTLY_SOLD") -> str:
|
|
"""Fetch similar units from Eiendom.no."""
|
|
try:
|
|
units = await get_similar_units(unit_vector, listing_status)
|
|
return json.dumps([unit.model_dump() for unit in units])
|
|
except Exception as e:
|
|
logger.error(f"Error fetching similar units: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(
|
|
description="Build a base64-encoded unit vector for a given Eiendom.no unit_code. "
|
|
"The vector is used as input to finn_get_similar_units."
|
|
)
|
|
async def finn_build_unit_vector(unit_code: str) -> str:
|
|
"""Build unit vector for Eiendom.no unit."""
|
|
try:
|
|
unit = await get_unit(unit_code)
|
|
if unit is None:
|
|
return json.dumps({"error": True, "message": "Eiendom.no unit not found"})
|
|
return json.dumps({"unit_code": unit.unit_code, "unit_vector": build_unit_vector(unit)})
|
|
except Exception as e:
|
|
logger.error(f"Error building unit vector for {unit_code}: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(
|
|
description="Decode a base64 unit vector into human-readable JSON (lat, lon, property type, "
|
|
"floor, rooms, construction year, area, price)."
|
|
)
|
|
def finn_decode_unit_vector(unit_vector: str) -> str:
|
|
"""Decode unit vector to readable format."""
|
|
try:
|
|
result = decode_unit_vector(unit_vector)
|
|
return json.dumps(result)
|
|
except Exception as e:
|
|
logger.error(f"Error decoding unit vector: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
# ============================================================================
|
|
# Additional analysis and enrichment tools
|
|
# ============================================================================
|
|
|
|
|
|
@mcp.tool(
|
|
description=(
|
|
"Fetch and enrich a single FINN ad with optional Eiendom.no data and comparable units."
|
|
)
|
|
)
|
|
async def finn_analyze_ad(
|
|
finnkode: str,
|
|
include_eiendom_no: bool = True,
|
|
include_similar_units: bool = False,
|
|
) -> str:
|
|
"""Analyze and enrich a single FINN ad."""
|
|
try:
|
|
result = await analyze_ad(
|
|
finnkode,
|
|
include_eiendom_no=include_eiendom_no,
|
|
include_similar_units=include_similar_units,
|
|
)
|
|
return render_ad(result.get("ad", {}), "json")
|
|
except Exception as e:
|
|
logger.error(f"Error analyzing ad {finnkode}: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(
|
|
description=(
|
|
"Evaluate one FINN listing against comparable recently-sold properties from Eiendom.no."
|
|
)
|
|
)
|
|
async def finn_analyze_ad_against_comps(
|
|
finnkode: str, listing_status: str = "RECENTLY_SOLD"
|
|
) -> str:
|
|
"""Analyze ad against comparable sales."""
|
|
try:
|
|
result = await analyze_ad_against_comps(finnkode, listing_status=listing_status)
|
|
return json.dumps(result, default=str)
|
|
except Exception as e:
|
|
logger.error(f"Error analyzing ad {finnkode} against comps: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(
|
|
description=(
|
|
"Find properties similar to a listing the user has liked. "
|
|
"Requires that the user has marked the listing with verdict='liked'."
|
|
)
|
|
)
|
|
async def finn_find_similar_to_liked_ad(
|
|
finnkode: str, mode: str = "recommendations", listing_status: str = "FOR_SALE"
|
|
) -> str:
|
|
"""Find properties similar to a liked ad."""
|
|
try:
|
|
result = await find_similar_to_liked(finnkode, mode=mode, listing_status=listing_status)
|
|
return render_similar_units(result, "json")
|
|
except Exception as e:
|
|
logger.error(f"Error finding similar to {finnkode}: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(description="Compare multiple FINN listings side by side with optional enrichment.")
|
|
async def finn_compare_ads(
|
|
finnkoder: list[str],
|
|
include_eiendom_no: bool = True,
|
|
include_comps: bool = True,
|
|
) -> str:
|
|
"""Compare multiple ads."""
|
|
try:
|
|
result = await compare_ads(
|
|
finnkoder,
|
|
include_eiendom_no=include_eiendom_no,
|
|
include_comps=include_comps,
|
|
)
|
|
return render_comparison(result, "json")
|
|
except Exception as e:
|
|
logger.error(f"Error comparing ads: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(
|
|
description="Store user feedback (verdict, notes) for a FINN listing. "
|
|
"Enables similar-unit recommendations and shortlist filtering."
|
|
)
|
|
async def finn_save_feedback(finnkode: str, verdict: str, notes: str | None = None) -> str:
|
|
"""Save user feedback for a listing."""
|
|
try:
|
|
result = save_feedback(finnkode, verdict, notes)
|
|
return json.dumps(result, default=str)
|
|
except Exception as e:
|
|
logger.error(f"Error saving feedback for {finnkode}: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(
|
|
description="Fetch the stored shortlist from a previous search run. "
|
|
"Returns the ranked listings with all enrichment data."
|
|
)
|
|
def finn_get_shortlist(run_id: int | None = None, limit: int = 10) -> str:
|
|
"""Get stored shortlist."""
|
|
try:
|
|
result = get_shortlist(run_id, limit)
|
|
return render_shortlist(result, "json")
|
|
except Exception as e:
|
|
logger.error(f"Error fetching shortlist: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
@mcp.tool(
|
|
description=(
|
|
"Detect new, removed, and changed listings in a FINN search URL "
|
|
"compared to the previous run. Shows price/status diffs on changed listings."
|
|
)
|
|
)
|
|
async def finn_get_new_ads_since_last_run(search_url: str) -> str:
|
|
"""Get new/removed/changed listings since last run."""
|
|
try:
|
|
result = get_new_ads_since_last_run(search_url)
|
|
return render_diff(result, "json")
|
|
except Exception as e:
|
|
logger.error(f"Error fetching diff: {e}")
|
|
return json.dumps({"error": True, "message": str(e)})
|
|
|
|
|
|
def main() -> None:
|
|
"""Run the FastMCP stdio server."""
|
|
mcp.run(transport="stdio")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|