@@ -143,6 +143,9 @@ def scrape_ad(html: str, url: str | None = None) -> FinnAd:
|
||||
has_parking = (
|
||||
bool(properties.get("parkering/garasje"))
|
||||
or "parkering" in feature_text
|
||||
)
|
||||
has_garage = (
|
||||
bool(properties.get("parkering/garasje"))
|
||||
or "garasje" in feature_text
|
||||
)
|
||||
broker_company = None
|
||||
@@ -177,6 +180,7 @@ def scrape_ad(html: str, url: str | None = None) -> FinnAd:
|
||||
has_terrace=has_terrace,
|
||||
has_elevator=has_elevator,
|
||||
has_parking=has_parking,
|
||||
has_garage=has_garage,
|
||||
listing_description=listing_description,
|
||||
broker_name=None,
|
||||
broker_company=broker_company,
|
||||
|
||||
@@ -149,14 +149,10 @@ async def analyze_search(
|
||||
if include_eiendom_no:
|
||||
try:
|
||||
matched_unit = await eiendom_no.search_unit_from_finn_url(card.url)
|
||||
unit_code = matched_unit.unit_code if matched_unit else None
|
||||
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)
|
||||
)
|
||||
unit_code = None
|
||||
result = await analyze_ad(finn_ad, unit_code=unit_code)
|
||||
if result.get("eiendom_unit"):
|
||||
enriched_count += 1
|
||||
|
||||
+3
-3
@@ -200,7 +200,7 @@ def build_vector(
|
||||
) -> None:
|
||||
"""Build a unit vector for an Eiendom.no unit."""
|
||||
try:
|
||||
result = svc_build_unit_vector(unit_code)
|
||||
result = asyncio.run(svc_build_unit_vector(unit_code))
|
||||
typer.echo(formatting.render_ad(result, format))
|
||||
except Exception as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
@@ -223,7 +223,7 @@ def decode_vector(
|
||||
|
||||
@app.command()
|
||||
def similar_units(
|
||||
unit_vector: str = typer.Argument(..., help="Unit vector string (base64)"),
|
||||
unit_code: str = typer.Argument(..., help="Eiendom.no unit code"),
|
||||
status: str = typer.Option(
|
||||
"RECENTLY_SOLD", help="Listing status (RECENTLY_SOLD, FOR_SALE, CURRENT)"
|
||||
),
|
||||
@@ -231,7 +231,7 @@ def similar_units(
|
||||
) -> None:
|
||||
"""Fetch similar/comparable units from Eiendom.no."""
|
||||
try:
|
||||
units = asyncio.run(svc_get_or_fetch_similar_units(unit_vector, listing_status=status))
|
||||
units = asyncio.run(svc_get_or_fetch_similar_units(unit_code, listing_status=status))
|
||||
result = {"similar_units": [u.model_dump() for u in units]}
|
||||
typer.echo(formatting.render_similar_units(result, format))
|
||||
except Exception as e:
|
||||
|
||||
@@ -34,6 +34,7 @@ def parse_eiendom_unit_json(unit_data: dict) -> EiendomUnit:
|
||||
specification = unit_data.get("specification", {})
|
||||
valuation = unit_data.get("valuation", {})
|
||||
market = unit_data.get("latestMarketData", {})
|
||||
unit_images = market.get("unitImages") or unit_data.get("unitImages") or []
|
||||
|
||||
return EiendomUnit(
|
||||
unit_code=unit_data.get("unitCode", ""),
|
||||
@@ -62,6 +63,7 @@ def parse_eiendom_unit_json(unit_data: dict) -> EiendomUnit:
|
||||
sale_status=market.get("saleStatus") or unit_data.get("saleStatus"),
|
||||
market_placement_score=market.get("marketPlacementScore")
|
||||
or unit_data.get("marketPlacementScore"),
|
||||
unit_images=unit_images if unit_images else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -212,16 +214,6 @@ async def get_similar_units(
|
||||
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,
|
||||
|
||||
@@ -135,6 +135,61 @@ def _render_unit_markdown(unit: dict[str, Any]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit images renderer
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def render_unit_images(payload: dict[str, Any], fmt: OutputFormat) -> str:
|
||||
"""Render unit images for visual assessment."""
|
||||
_validate_format(fmt)
|
||||
|
||||
if fmt == "json":
|
||||
return json.dumps(payload, indent=2, default=str)
|
||||
else:
|
||||
return _render_unit_images_markdown(payload)
|
||||
|
||||
|
||||
def _render_unit_images_markdown(data: dict[str, Any]) -> str:
|
||||
"""Render unit images as markdown with image references for Claude."""
|
||||
unit_code = data.get("unit_code", "Unknown")
|
||||
address = data.get("address", "Unknown")
|
||||
images = data.get("unit_images") or []
|
||||
|
||||
lines = [
|
||||
f"# Unit Images: {address}",
|
||||
"",
|
||||
f"**Unit Code:** {unit_code}",
|
||||
f"**Total Photos:** {len(images)}",
|
||||
"",
|
||||
"## Property Photos",
|
||||
"",
|
||||
]
|
||||
|
||||
if not images:
|
||||
lines.append("No images available for this unit.")
|
||||
else:
|
||||
lines.append("Below are the property images for visual assessment:")
|
||||
lines.append("")
|
||||
for i, img_url in enumerate(images, 1):
|
||||
lines.append(f"### Photo {i}")
|
||||
lines.append(f"")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("**Analysis Notes:**")
|
||||
lines.append("Review the above photos to assess:")
|
||||
lines.append("- View quality (street, landscape, water, etc.)")
|
||||
lines.append("- Space and layout (openness, ceiling height, etc.)")
|
||||
lines.append("- Lighting and window placement")
|
||||
lines.append("- Condition and maintenance state")
|
||||
lines.append("- Kitchen and bathroom features")
|
||||
lines.append("- Overall atmosphere and livability")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Shortlist renderer
|
||||
# ============================================================================
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import uvicorn
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from finn_eiendom.mcp_server import mcp
|
||||
|
||||
mcp.transport_security = TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||
|
||||
app = mcp.sse_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8010, forwarded_allow_ips="*")
|
||||
@@ -3,10 +3,10 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import os
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from .analysis import analyze_search
|
||||
from .eiendom_no import (
|
||||
build_unit_vector,
|
||||
decode_unit_vector,
|
||||
@@ -20,22 +20,37 @@ from .formatting import (
|
||||
render_diff,
|
||||
render_shortlist,
|
||||
render_similar_units,
|
||||
render_unit_images,
|
||||
)
|
||||
from .service import (
|
||||
analyze_ad,
|
||||
analyze_ad_against_comps,
|
||||
analyze_search,
|
||||
compare_ads,
|
||||
find_similar_to_liked,
|
||||
get_new_ads_since_last_run,
|
||||
get_or_fetch_ad,
|
||||
get_or_fetch_eiendom_unit,
|
||||
get_shortlist,
|
||||
get_unit_images,
|
||||
save_feedback,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
mcp = FastMCP("finn_eiendom_mcp")
|
||||
|
||||
def _build_transport_security() -> TransportSecuritySettings:
|
||||
allowed = os.getenv("MCP_ALLOWED_HOSTS", "")
|
||||
if allowed:
|
||||
hosts = [h.strip() for h in allowed.split(",")]
|
||||
return TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=True,
|
||||
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"] + hosts,
|
||||
)
|
||||
return TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||
|
||||
|
||||
mcp = FastMCP("finn_eiendom_mcp", transport_security=_build_transport_security())
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
@@ -56,7 +71,7 @@ async def finn_analyze_search(
|
||||
result = await analyze_search(
|
||||
search_url,
|
||||
max_pages=max_pages,
|
||||
fetch_details=include_details,
|
||||
include_details=include_details,
|
||||
detail_limit=detail_limit,
|
||||
include_eiendom_no=include_eiendom_no,
|
||||
)
|
||||
@@ -125,6 +140,22 @@ async def finn_get_eiendom_unit(unit_code: str, force_refresh: bool = False) ->
|
||||
return json.dumps({"error": True, "message": str(e)})
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description=(
|
||||
"Fetch and analyze unit images for visual assessment of a property. "
|
||||
"Returns property photos with metadata for evaluating views, condition, and layout."
|
||||
)
|
||||
)
|
||||
async def finn_analyze_unit_images(unit_code: str, force_refresh: bool = False) -> str:
|
||||
"""Fetch and return unit images for visual analysis."""
|
||||
try:
|
||||
result = await get_unit_images(unit_code, force_refresh=force_refresh)
|
||||
return render_unit_images(result, "markdown")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching unit images for {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."
|
||||
@@ -296,7 +327,7 @@ async def finn_get_new_ads_since_last_run(search_url: str) -> str:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the FastMCP stdio server."""
|
||||
"""Run the FastMCP server over stdio (standard MCP transport)."""
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ class EiendomUnit(BaseModel):
|
||||
days_on_market: int | None = None
|
||||
sale_status: str | None = None
|
||||
market_placement_score: str | None = None
|
||||
unit_images: list[str] | None = None
|
||||
unit_vector: str | None = None
|
||||
fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ def normalize_number(num_str: str | None) -> int | None:
|
||||
if not num_str:
|
||||
return None
|
||||
cleaned = re.sub(r"[^\d,\.]", "", num_str)
|
||||
cleaned = cleaned.replace(" ", "")
|
||||
if "," in cleaned:
|
||||
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||||
else:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
@@ -161,12 +162,14 @@ def extract_search_cards(html: str) -> list[FinnSearchCard]:
|
||||
return cards
|
||||
|
||||
|
||||
def find_next_page_url(html: str) -> str | None:
|
||||
def find_next_page_url(html: str, base_url: str = "https://www.finn.no") -> str | None:
|
||||
"""Return the FINN search next page URL if present."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
next_link = soup.select_one("a[rel='next']")
|
||||
if next_link and next_link.get("href"):
|
||||
return clean_text(next_link.get("href"))
|
||||
href = clean_text(next_link.get("href"))
|
||||
if href:
|
||||
return urljoin(base_url, href)
|
||||
return None
|
||||
|
||||
|
||||
@@ -185,7 +188,7 @@ async def fetch_search_pages(
|
||||
for _ in range(max_pages):
|
||||
html = await fetch_search_page_cached(url, client=client, conn=conn, use_cache=use_cache)
|
||||
all_cards.extend(extract_search_cards(html))
|
||||
next_url = find_next_page_url(html)
|
||||
next_url = find_next_page_url(html, base_url=start_url)
|
||||
if not next_url:
|
||||
break
|
||||
url = next_url
|
||||
|
||||
+28
-6
@@ -55,7 +55,27 @@ async def get_or_fetch_similar_units(
|
||||
"""Get similar units (comps) from cache or fetch fresh."""
|
||||
# Similar units don't have a separate cache table; fetch fresh each time per PRD
|
||||
# (or cache them in search_runs if doing diff detection)
|
||||
return await get_similar_units(unit_code, listing_status=listing_status)
|
||||
unit = await get_or_fetch_eiendom_unit(unit_code)
|
||||
if unit is None:
|
||||
return []
|
||||
vector = build_unit_vector(unit)
|
||||
return await get_similar_units(vector, listing_status=listing_status)
|
||||
|
||||
|
||||
async def get_unit_images(unit_code: str, force_refresh: bool = False) -> dict[str, Any]:
|
||||
"""Fetch unit images for visual assessment."""
|
||||
unit = await get_or_fetch_eiendom_unit(unit_code, force_refresh=force_refresh)
|
||||
if unit is None:
|
||||
raise ValueError(f"Could not fetch Eiendom.no unit {unit_code}")
|
||||
|
||||
return {
|
||||
"unit_code": unit.unit_code,
|
||||
"address": unit.address,
|
||||
"unit_images": unit.unit_images or [],
|
||||
"property_type": unit.property_type,
|
||||
"rooms": unit.rooms,
|
||||
"usable_area": unit.usable_area,
|
||||
}
|
||||
|
||||
|
||||
async def resolve_eiendom_unit_from_finn_url(finn_url: str) -> EiendomUnit | None:
|
||||
@@ -75,7 +95,6 @@ async def analyze_search(
|
||||
detail_limit: int = 20,
|
||||
include_details: bool = True,
|
||||
include_eiendom_no: bool = True,
|
||||
include_similar_units_for_shortlist: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Analyze a FINN search URL and return a ranked shortlist."""
|
||||
return await run_analysis_search(
|
||||
@@ -84,7 +103,6 @@ async def analyze_search(
|
||||
fetch_details=include_details,
|
||||
detail_limit=detail_limit,
|
||||
include_eiendom_no=include_eiendom_no,
|
||||
include_similar_units_for_shortlist=include_similar_units_for_shortlist,
|
||||
)
|
||||
|
||||
|
||||
@@ -181,9 +199,13 @@ async def compare_ads(
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def build_unit_vector_for_unit_code(unit_code: str) -> dict[str, Any]:
|
||||
"""Build a unit_vector dict for a unit_code (msgpack-encoded)."""
|
||||
return build_unit_vector(unit_code)
|
||||
async def build_unit_vector_for_unit_code(unit_code: str) -> dict[str, Any]:
|
||||
"""Build a unit_vector for a unit_code by fetching and encoding the unit data."""
|
||||
unit = await get_or_fetch_eiendom_unit(unit_code)
|
||||
if unit is None:
|
||||
raise ValueError(f"Could not fetch Eiendom.no unit {unit_code}")
|
||||
vector = build_unit_vector(unit)
|
||||
return {"unit_code": unit_code, "unit_vector": vector}
|
||||
|
||||
|
||||
def decode_unit_vector_to_dict(unit_vector: str) -> dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user