Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Ole
2026-05-18 21:31:52 +00:00
parent 6eedfffa4d
commit c9383788de
22 changed files with 1614 additions and 42 deletions
+4
View File
@@ -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,
+2 -6
View File
@@ -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
View File
@@ -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:
+2 -10
View File
@@ -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,
+55
View File
@@ -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"![Unit Photo {i}]({img_url})")
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
# ============================================================================
+10
View File
@@ -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="*")
+36 -5
View File
@@ -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")
+1
View File
@@ -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))
-1
View File
@@ -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:
+6 -3
View File
@@ -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
View File
@@ -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]: