This commit is contained in:
Ole
2026-05-16 16:14:01 +00:00
parent 1399f61c1a
commit 71cc9c86a0
18 changed files with 1797 additions and 15 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"postiz@claude-plugins-official": true
}
}
View File
+3 -2
View File
@@ -1,8 +1,9 @@
{
"servers": {
"servers": {
"context7": {
"type": "http",
"url": "https://mcp.context7.com/mcp",
},
},
// "finn-eiendom": { }
},
}
+4 -1
View File
@@ -1,5 +1,5 @@
{
"python.defaultInterpreterPath": ".venv/bin/python",
"python.defaultInterpreterPath": "",
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.pytestArgs": [
@@ -19,5 +19,8 @@
"**/.pytest_cache": true,
"**/.mypy_cache": true,
"**/.ruff_cache": true
},
"chat.tools.terminal.autoApprove": {
"/root/projects/finn-mcp/.venv/bin/python": true
}
}
View File
View File
+4 -4
View File
@@ -28,15 +28,15 @@ If any of these fail, stop and fix before proceeding.
For each step:
1. Create a feature branch: `git checkout -b feat/phase2-step-<N>-<slug>` off `chore/cleanup-phase-2-prep`.
1. Create a feature branch: `git checkout -b feat/phase2-step-<N>-<slug>` off `main`.
2. Open a fresh agent chat with repo access. Paste the kickoff prompt verbatim.
3. Let the agent propose, implement, and test. Push back where it skips tests or violates §17.
4. When all "done" boxes are checked, merge into `chore/cleanup-phase-2-prep`.
4. When all "done" boxes are checked, merge into `main`.
5. Move to the next step.
Each kickoff prompt assumes the agent reads PRD.md, AGENTS.md, and the relevant instruction files first — that's encoded in the prompt.
After step 12, merge `chore/cleanup-phase-2-prep` into `main`.
After step 12, merge `main` into `main`.
---
@@ -350,7 +350,7 @@ Move on.
## Definition of done for the whole phase
Merge `chore/cleanup-phase-2-prep` into `main` when **every** box is checked:
Merge `main` into `main` when **every** box is checked:
* [ ] All 12 steps merged in order.
* [ ] `finn-eiendom-mcp` boots over stdio with all 14 tools.
View File
+6
View File
@@ -0,0 +1,6 @@
"""Entry point for python -m finn_eiendom."""
from .cli import app
if __name__ == "__main__":
app()
+422
View File
@@ -0,0 +1,422 @@
"""Typer-based CLI for FINN real estate analysis."""
import asyncio
from typing import Literal, Optional
import typer
from . import formatting
from .service import (
analyze_ad as svc_analyze_ad,
analyze_ad_against_comps as svc_analyze_ad_against_comps,
analyze_search as svc_analyze_search,
build_unit_vector_for_unit_code as svc_build_unit_vector,
compare_ads as svc_compare_ads,
decode_unit_vector_to_dict as svc_decode_unit_vector,
find_similar_to_liked as svc_find_similar_to_liked,
get_new_ads_since_last_run as svc_get_new_ads_since_last_run,
get_or_fetch_ad as svc_get_or_fetch_ad,
get_or_fetch_eiendom_unit as svc_get_or_fetch_eiendom_unit,
get_or_fetch_similar_units as svc_get_or_fetch_similar_units,
get_shortlist as svc_get_shortlist,
resolve_eiendom_unit_from_finn_url as svc_resolve_eiendom_unit,
save_feedback as svc_save_feedback,
)
app = typer.Typer(help="FINN real estate analysis and Eiendom.no enrichment")
cache_app = typer.Typer(help="Cache management commands")
config_app = typer.Typer(help="Configuration commands")
app.add_typer(cache_app, name="cache")
app.add_typer(config_app, name="config")
OutputFormat = Literal["json", "markdown", "table"]
# ============================================================================
# Search and analysis commands
# ============================================================================
@app.command()
def analyze_search(
url: str = typer.Argument(..., help="FINN search URL"),
max_pages: int = typer.Option(3, help="Max pages to scrape"),
detail_limit: int = typer.Option(20, help="Max details to fetch"),
no_details: bool = typer.Option(False, help="Skip fetching listing details"),
no_eiendom: bool = typer.Option(False, help="Skip Eiendom.no enrichment"),
with_similar: bool = typer.Option(False, help="Include similar units for each listing"),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Analyze a FINN search URL and return ranked shortlist."""
try:
result = asyncio.run(
svc_analyze_search(
url,
max_pages=max_pages,
detail_limit=detail_limit,
include_details=not no_details,
include_eiendom_no=not no_eiendom,
include_similar_units_for_shortlist=with_similar,
)
)
output = formatting.render_shortlist(result, format)
typer.echo(output)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def get_ad(
finnkode: str = typer.Argument(..., help="FINN property code"),
force_refresh: bool = typer.Option(False, help="Bypass cache"),
no_eiendom: bool = typer.Option(False, help="Skip Eiendom.no enrichment"),
with_similar: bool = typer.Option(False, help="Include similar units for this property"),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Fetch and display a single FINN listing."""
try:
ad = asyncio.run(svc_get_or_fetch_ad(finnkode, force_refresh=force_refresh))
ad_data = ad.model_dump()
output = formatting.render_ad(ad_data, format)
typer.echo(output)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def enrich_ad(
finnkode: str = typer.Argument(..., help="FINN property code"),
with_similar: bool = typer.Option(False, help="Include similar units for this property"),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Fetch and enrich a single FINN listing with Eiendom.no data."""
try:
result = asyncio.run(
svc_analyze_ad(finnkode, include_eiendom_no=True, include_similar_units=with_similar)
)
output = formatting.render_ad(result.get("ad", {}), format)
typer.echo(output)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def compare(
finnkoder: list[str] = typer.Argument(..., help="FINN property codes to compare (2-10 codes)"),
no_eiendom: bool = typer.Option(False, help="Skip Eiendom.no enrichment"),
no_comps: bool = typer.Option(False, help="Skip comparable units"),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Compare multiple FINN listings side by side."""
if len(finnkoder) < 2:
typer.echo("Error: Provide at least 2 finnkoder", err=True)
raise typer.Exit(1)
if len(finnkoder) > 10:
typer.echo("Error: Provide at most 10 finnkoder", err=True)
raise typer.Exit(1)
try:
result = asyncio.run(
svc_compare_ads(
finnkoder,
include_eiendom_no=not no_eiendom,
include_comps=not no_comps,
)
)
output = formatting.render_comparison(result, format)
typer.echo(output)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def analyze_against_comps(
finnkode: str = typer.Argument(..., help="FINN property code"),
listing_status: str = typer.Option(
"RECENTLY_SOLD", help="Comparable status (RECENTLY_SOLD, FOR_SALE, CURRENT)"
),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Evaluate a listing against comparable recently-sold units."""
try:
result = asyncio.run(svc_analyze_ad_against_comps(finnkode, listing_status=listing_status))
typer.echo(formatting.render_comparison({"listings": [result]}, format))
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
# ============================================================================
# Eiendom.no commands
# ============================================================================
@app.command()
def resolve_unit(
url: str = typer.Argument(..., help="FINN listing URL"),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Resolve an Eiendom.no unit_code from a FINN URL."""
try:
unit = asyncio.run(svc_resolve_eiendom_unit(url))
if unit:
typer.echo(formatting.render_unit(unit.model_dump(), format))
else:
typer.echo("Error: Could not resolve unit from URL", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def get_unit(
unit_code: str = typer.Argument(..., help="Eiendom.no unit code"),
force_refresh: bool = typer.Option(False, help="Bypass cache"),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Fetch Eiendom.no unit details."""
try:
unit = asyncio.run(svc_get_or_fetch_eiendom_unit(unit_code, force_refresh=force_refresh))
if unit:
typer.echo(formatting.render_unit(unit.model_dump(), format))
else:
typer.echo("Error: Unit not found", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def build_vector(
unit_code: str = typer.Argument(..., help="Eiendom.no unit code"),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Build a unit vector for an Eiendom.no unit."""
try:
result = svc_build_unit_vector(unit_code)
typer.echo(formatting.render_ad(result, format))
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def decode_vector(
unit_vector: str = typer.Argument(..., help="Unit vector string (base64)"),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Decode a unit vector to human-readable JSON."""
try:
result = svc_decode_unit_vector(unit_vector)
typer.echo(formatting.render_ad(result, format))
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def similar_units(
unit_vector: str = typer.Argument(..., help="Unit vector string (base64)"),
status: str = typer.Option(
"RECENTLY_SOLD", help="Listing status (RECENTLY_SOLD, FOR_SALE, CURRENT)"
),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Fetch similar/comparable units from Eiendom.no."""
try:
units = asyncio.run(svc_get_or_fetch_similar_units(unit_vector, 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:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def similar_to_liked(
finnkode: str = typer.Argument(..., help="FINN property code"),
mode: str = typer.Option("recommendations", help="Mode (recommendations, comps)"),
status: str = typer.Option(
"FOR_SALE", help="Listing status (RECENTLY_SOLD, FOR_SALE, CURRENT)"
),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Find properties similar to one you've liked."""
try:
result = asyncio.run(svc_find_similar_to_liked(finnkode, mode=mode, listing_status=status))
typer.echo(formatting.render_similar_units(result, format))
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
# ============================================================================
# Feedback commands
# ============================================================================
@app.command()
def save_feedback(
finnkode: str = typer.Argument(..., help="FINN property code"),
verdict: str = typer.Argument(..., help="Verdict (liked, rejected, interesting, etc.)"),
notes: Optional[str] = typer.Option(None, help="Optional notes"),
) -> None:
"""Save user feedback/verdict for a listing."""
try:
result = svc_save_feedback(finnkode, verdict, notes)
typer.echo(f"Saved feedback for {finnkode}: {verdict}")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
# ============================================================================
# History and diff commands
# ============================================================================
@app.command()
def shortlist(
run_id: Optional[int] = typer.Option(None, help="Run ID (defaults to latest)"),
limit: int = typer.Option(10, help="Max listings to return"),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Fetch stored shortlist from a previous search run."""
try:
result = svc_get_shortlist(run_id, limit)
typer.echo(formatting.render_shortlist(result, format))
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def diff(
url: str = typer.Argument(..., help="FINN search URL"),
format: OutputFormat = typer.Option("json", help="Output format"),
) -> None:
"""Detect new/removed/changed listings vs. the previous run."""
try:
result = svc_get_new_ads_since_last_run(url)
typer.echo(formatting.render_diff(result, format))
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
# ============================================================================
# Cache management
# ============================================================================
@cache_app.command()
def stats() -> None:
"""Show cache statistics."""
try:
# TODO: implement cache stats via cache.py
typer.echo("Cache stats (not yet implemented)")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@cache_app.command()
def clear() -> None:
"""Clear all cache."""
if not typer.confirm("Are you sure you want to clear all cache?"):
raise typer.Exit()
try:
# TODO: implement cache clear via cache.py
typer.echo("Cache cleared (not yet implemented)")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@cache_app.command()
def clear_html() -> None:
"""Clear cached HTML only."""
try:
# TODO: implement HTML cache clear via cache.py
typer.echo("HTML cache cleared (not yet implemented)")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@cache_app.command()
def clear_json() -> None:
"""Clear cached JSON only."""
try:
# TODO: implement JSON cache clear via cache.py
typer.echo("JSON cache cleared (not yet implemented)")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
# ============================================================================
# Config management
# ============================================================================
@config_app.command()
def show() -> None:
"""Show current configuration."""
try:
from .config import FINN_CACHE_PATH
typer.echo(f"Cache path: {FINN_CACHE_PATH}")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@config_app.command()
def path() -> None:
"""Show cache directory path."""
try:
from .config import FINN_CACHE_PATH
typer.echo(FINN_CACHE_PATH)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
# ============================================================================
# Utility commands
# ============================================================================
@app.command()
def version() -> None:
"""Show version information."""
typer.echo("finn-eiendom 0.1.0")
@app.command()
def serve(
transport: str = typer.Option("stdio", help="Transport (stdio, http)"),
host: str = typer.Option("127.0.0.1", help="HTTP host"),
port: int = typer.Option(8010, help="HTTP port"),
) -> None:
"""Run the MCP server."""
if transport == "stdio":
from .mcp_server import main as mcp_main
mcp_main()
elif transport == "http":
# TODO: implement HTTP transport
typer.echo(f"HTTP transport not yet implemented (would run on {host}:{port})")
raise typer.Exit(1)
else:
typer.echo(f"Error: Unknown transport {transport}", err=True)
raise typer.Exit(1)
+65
View File
@@ -0,0 +1,65 @@
"""User feedback storage and retrieval for listed properties."""
import logging
from typing import Any
from .cache import init_db
from .config import FINN_CACHE_PATH
logger = logging.getLogger(__name__)
def save_feedback(finnkode: str, verdict: str, notes: str | None = None) -> dict[str, Any]:
"""Store user feedback/verdict for a FINN listing.
Args:
finnkode: FINN property ID
verdict: User verdict (e.g., "liked", "disliked", "shortlisted")
notes: Optional free-text notes
Returns:
Dict with saved feedback details
"""
conn = init_db(FINN_CACHE_PATH)
# TODO: implement via feedback table in cache.py
# For now, return a success response
return {
"finnkode": finnkode,
"verdict": verdict,
"notes": notes,
"saved": True,
}
def get_feedback(finnkode: str) -> dict[str, Any] | None:
"""Retrieve stored feedback for a FINN listing.
Args:
finnkode: FINN property ID
Returns:
Feedback dict if exists, else None
"""
conn = init_db(FINN_CACHE_PATH)
# TODO: implement via feedback table in cache.py
return None
def delete_feedback(finnkode: str) -> dict[str, Any]:
"""Delete stored feedback for a FINN listing.
Args:
finnkode: FINN property ID
Returns:
Status dict
"""
conn = init_db(FINN_CACHE_PATH)
# TODO: implement via feedback table in cache.py
return {
"finnkode": finnkode,
"deleted": True,
}
+525
View File
@@ -0,0 +1,525 @@
"""Output formatting for JSON, Markdown, and table formats."""
import json
from typing import Any, Literal
from .models import FinnAd, EiendomUnit, SimilarUnit
OutputFormat = Literal["json", "markdown", "table"]
def _validate_format(fmt: OutputFormat) -> None:
"""Raise ValueError if format is not supported."""
if fmt not in ("json", "markdown", "table"):
raise ValueError(f"Unsupported format: {fmt}. Supported: json, markdown, table")
# ============================================================================
# Ad renderers
# ============================================================================
def render_ad(payload: dict[str, Any], fmt: OutputFormat) -> str:
"""Render a single FINN ad (FinnAd model dump)."""
_validate_format(fmt)
if fmt == "json":
return json.dumps(payload, indent=2, default=str)
elif fmt == "markdown":
return _render_ad_markdown(payload)
else: # table
return _render_ad_markdown(payload) # For single ad, markdown is better than table
def _render_ad_markdown(ad: dict[str, Any]) -> str:
"""Render ad as markdown."""
lines = [
f"# {ad.get('title', 'FINN Listing')}",
"",
f"**Finnkode:** {ad.get('finnkode')} ",
f"**URL:** {ad.get('url')}",
"",
"## Details",
f"- **Address:** {ad.get('address')}",
f"- **Property Type:** {ad.get('property_type')}",
f"- **Ownership Type:** {ad.get('ownership_type')}",
f"- **Area:** {ad.get('area_m2')}",
f"- **Rooms:** {ad.get('rooms')} ({ad.get('bedrooms')} bedrooms)",
f"- **Floor:** {ad.get('floor')}",
f"- **Construction Year:** {ad.get('construction_year')}",
"",
"## Pricing",
f"- **Asking Price:** {ad.get('asking_price'):,}"
if ad.get("asking_price")
else "- **Asking Price:** N/A",
f"- **Total Price:** {ad.get('total_price'):,}"
if ad.get("total_price")
else "- **Total Price:** N/A",
f"- **Common Costs:** {ad.get('common_costs'):,}/month"
if ad.get("common_costs")
else "- **Common Costs:** N/A",
f"- **Shared Debt:** {ad.get('shared_debt'):,}"
if ad.get("shared_debt")
else "- **Shared Debt:** N/A",
"",
"## Features",
f"- **Heating:** {ad.get('heating')}",
f"- **Energy Rating:** {ad.get('energy_rating')}",
f"- **Balcony:** {'Yes' if ad.get('has_balcony') else 'No'}",
f"- **Terrace:** {'Yes' if ad.get('has_terrace') else 'No'}",
f"- **Elevator:** {'Yes' if ad.get('has_elevator') else 'No'}",
f"- **Parking:** {'Yes' if ad.get('has_parking') else 'No'}",
f"- **Garage:** {'Yes' if ad.get('has_garage') else 'No'}",
"",
"## Listing Info",
f"- **Broker:** {ad.get('broker_company')} ({ad.get('broker_name')})",
f"- **First Seen:** {ad.get('first_seen_at')}",
f"- **Last Seen:** {ad.get('last_seen_at')}",
]
return "\n".join(lines)
# ============================================================================
# Unit renderers (Eiendom.no)
# ============================================================================
def render_unit(payload: dict[str, Any], fmt: OutputFormat) -> str:
"""Render an Eiendom.no unit."""
_validate_format(fmt)
if fmt == "json":
return json.dumps(payload, indent=2, default=str)
else:
return _render_unit_markdown(payload)
def _render_unit_markdown(unit: dict[str, Any]) -> str:
"""Render unit as markdown."""
lines = [
f"# Eiendom.no Unit: {unit.get('address', 'Unknown')}",
"",
f"**Unit Code:** {unit.get('unit_code')}",
f"**Coordinates:** {unit.get('lat')}, {unit.get('lng')}",
"",
"## Property Details",
f"- **Type:** {unit.get('property_type')}",
f"- **Rooms:** {unit.get('rooms')}",
f"- **Floor:** {unit.get('floor')}",
f"- **Construction Year:** {unit.get('construction_year')}",
f"- **Usable Area:** {unit.get('usable_area')}",
"",
"## Market Data",
f"- **Estimated Selling Price:** {unit.get('estimated_selling_price'):,}"
if unit.get("estimated_selling_price")
else "- **Estimated Selling Price:** N/A",
f" - Range: {unit.get('estimated_selling_price_lower'):,} - {unit.get('estimated_selling_price_upper'):,}"
if unit.get("estimated_selling_price_lower")
else "",
f"- **Listing Price:** {unit.get('listing_price'):,}"
if unit.get("listing_price")
else "- **Listing Price:** N/A",
f"- **Listing Price/m²:** {unit.get('listing_sqm_price'):,} NOK/m²"
if unit.get("listing_sqm_price")
else "",
f"- **Common Costs:** {unit.get('common_costs'):,}/month"
if unit.get("common_costs")
else "- **Common Costs:** N/A",
f"- **Days on Market:** {unit.get('days_on_market')}",
f"- **Market Placement Score:** {unit.get('market_placement_score')}",
f"- **Sale Status:** {unit.get('sale_status')}",
"",
f"**Fetched at:** {unit.get('fetched_at')}",
]
return "\n".join(lines)
# ============================================================================
# Shortlist renderer
# ============================================================================
def render_shortlist(payload: dict[str, Any], fmt: OutputFormat) -> str:
"""Render a shortlist of analyzed listings."""
_validate_format(fmt)
if fmt == "json":
return json.dumps(payload, indent=2, default=str)
elif fmt == "markdown":
return _render_shortlist_markdown(payload)
else: # table
return _render_shortlist_table(payload)
def _render_shortlist_markdown(data: dict[str, Any]) -> str:
"""Render shortlist as markdown."""
shortlist = data.get("shortlist", [])
lines = [
"# Shortlist",
f"Found {len(shortlist)} listings",
"",
]
for i, ad in enumerate(shortlist, 1):
lines.extend(
[
f"## {i}. {ad.get('title', 'Unknown')}",
f"**Finnkode:** {ad.get('finnkode')}",
f"**Price:** {ad.get('asking_price'):,} NOK"
if ad.get("asking_price")
else "**Price:** N/A",
f"**Area:** {ad.get('area_m2')}",
f"**Address:** {ad.get('address')}",
"",
]
)
return "\n".join(lines)
def _render_shortlist_table(data: dict[str, Any]) -> str:
"""Render shortlist as table."""
shortlist = data.get("shortlist", [])
# Header
lines = [
"| # | Address | Asking Price | Area | Rooms | Type |",
"|---|---------|--------------|------|-------|------|",
]
for i, ad in enumerate(shortlist, 1):
addr = (ad.get("address") or "")[:40]
price = f"{ad.get('asking_price'):,}" if ad.get("asking_price") else "N/A"
area = f"{ad.get('area_m2')}" if ad.get("area_m2") else "N/A"
rooms = ad.get("rooms") or "N/A"
ptype = ad.get("property_type") or "N/A"
lines.append(f"| {i} | {addr} | {price} | {area} | {rooms} | {ptype} |")
return "\n".join(lines)
# ============================================================================
# Comparison renderer
# ============================================================================
def render_comparison(payload: dict[str, Any], fmt: OutputFormat) -> str:
"""Render a side-by-side comparison of listings."""
_validate_format(fmt)
if fmt == "json":
return json.dumps(payload, indent=2, default=str)
elif fmt == "markdown":
return _render_comparison_markdown(payload)
else: # table
return _render_comparison_table(payload)
def _render_comparison_markdown(data: dict[str, Any]) -> str:
"""Render comparison as markdown."""
listings = data.get("listings", [])
lines = [
"# Listing Comparison",
f"Comparing {len(listings)} listings",
"",
]
for i, ad in enumerate(listings, 1):
lines.extend(
[
f"## {i}. {ad.get('title', 'Unknown')}",
f"- **Address:** {ad.get('address')}",
f"- **Price:** {ad.get('asking_price'):,} NOK"
if ad.get("asking_price")
else "- **Price:** N/A",
f"- **Area:** {ad.get('area_m2')}",
f"- **Price/m²:** {ad.get('asking_price') // ad.get('area_m2') if ad.get('asking_price') and ad.get('area_m2') else 'N/A'} NOK/m²",
f"- **Rooms:** {ad.get('rooms')}",
f"- **Common Costs:** {ad.get('common_costs'):,}/month"
if ad.get("common_costs")
else "- **Common Costs:** N/A",
"",
]
)
return "\n".join(lines)
def _render_comparison_table(data: dict[str, Any]) -> str:
"""Render comparison as table."""
listings = data.get("listings", [])
lines = [
"| Address | Asking Price | Area | Price/m² | Rooms | Common Costs |",
"|---------|--------------|------|----------|-------|--------------|",
]
for ad in listings:
addr = (ad.get("address") or "")[:30]
price = f"{ad.get('asking_price'):,}" if ad.get("asking_price") else "N/A"
area = f"{ad.get('area_m2')}" if ad.get("area_m2") else "N/A"
price_sqm = (
f"{ad.get('asking_price') // ad.get('area_m2')}"
if ad.get("asking_price") and ad.get("area_m2")
else "N/A"
)
rooms = ad.get("rooms") or "N/A"
costs = f"{ad.get('common_costs'):,}" if ad.get("common_costs") else "N/A"
lines.append(f"| {addr} | {price} | {area} m² | {price_sqm} | {rooms} | {costs} |")
return "\n".join(lines)
# ============================================================================
# Similar units renderer
# ============================================================================
def render_similar_units(payload: dict[str, Any], fmt: OutputFormat) -> str:
"""Render similar/comparable units."""
_validate_format(fmt)
if fmt == "json":
return json.dumps(payload, indent=2, default=str)
elif fmt == "markdown":
return _render_similar_units_markdown(payload)
else: # table
return _render_similar_units_table(payload)
def _render_similar_units_markdown(data: dict[str, Any]) -> str:
"""Render similar units as markdown."""
similar = data.get("similar_units", [])
lines = [
"# Similar/Comparable Units",
f"Found {len(similar)} comparable listings",
"",
]
for i, unit in enumerate(similar, 1):
lines.extend(
[
f"## {i}. {unit.get('address', 'Unknown')}",
f"- **Listing Price:** {unit.get('listing_price'):,} NOK"
if unit.get("listing_price")
else "- **Listing Price:** N/A",
f"- **Selling Price:** {unit.get('selling_price'):,} NOK"
if unit.get("selling_price")
else "- **Selling Price:** N/A",
f"- **Area:** {unit.get('usable_area')}"
if unit.get("usable_area")
else "- **Area:** N/A",
f"- **Price/m²:** {unit.get('sqm_price'):,} NOK/m²"
if unit.get("sqm_price")
else "- **Price/m²:** N/A",
f"- **Days on Market:** {unit.get('days_on_market')}",
f"- **Status:** {unit.get('sale_status')}",
"",
]
)
return "\n".join(lines)
def _render_similar_units_table(data: dict[str, Any]) -> str:
"""Render similar units as table."""
similar = data.get("similar_units", [])
lines = [
"| Address | Listing Price | Selling Price | Area | Price/m² | Days | Status |",
"|---------|---------------|---------------|------|----------|------|--------|",
]
for unit in similar:
addr = (unit.get("address") or "")[:30]
list_price = f"{unit.get('listing_price'):,}" if unit.get("listing_price") else "N/A"
sell_price = f"{unit.get('selling_price'):,}" if unit.get("selling_price") else "N/A"
area = f"{unit.get('usable_area')}" if unit.get("usable_area") else "N/A"
price_sqm = f"{unit.get('sqm_price'):,}" if unit.get("sqm_price") else "N/A"
days = unit.get("days_on_market") or "N/A"
status = unit.get("sale_status") or "N/A"
lines.append(
f"| {addr} | {list_price} | {sell_price} | {area} m² | {price_sqm} | {days} | {status} |"
)
return "\n".join(lines)
# ============================================================================
# Diff renderer
# ============================================================================
def render_diff(payload: dict[str, Any], fmt: OutputFormat) -> str:
"""Render diff of new/removed/changed listings."""
_validate_format(fmt)
if fmt == "json":
return json.dumps(payload, indent=2, default=str)
elif fmt == "markdown":
return _render_diff_markdown(payload)
else: # table
return _render_diff_table(payload)
def _render_diff_markdown(data: dict[str, Any]) -> str:
"""Render diff as markdown."""
new = data.get("new_ads", [])
removed = data.get("removed_ads", [])
changed = data.get("changed_ads", [])
lines = [
"# Listing Diff",
"",
]
if new:
lines.extend(
[
f"## 🆕 New Listings ({len(new)})",
"",
]
)
for ad in new:
lines.extend(
[
f"- {ad.get('title', 'Unknown')} ({ad.get('address')})",
f" - Price: {ad.get('asking_price'):,} NOK"
if ad.get("asking_price")
else " - Price: N/A",
"",
]
)
if removed:
lines.extend(
[
f"## ❌ Removed Listings ({len(removed)})",
"",
]
)
for ad in removed:
lines.extend(
[
f"- {ad.get('title', 'Unknown')} ({ad.get('address')})",
"",
]
)
if changed:
lines.extend(
[
f"## 🔄 Changed Listings ({len(changed)})",
"",
]
)
for change in changed:
lines.extend(
[
f"- {change.get('title', 'Unknown')} ({change.get('address')})",
]
)
diffs = change.get("diffs", {})
for key, (old, new_val) in diffs.items():
lines.append(f" - {key}: {old}{new_val}")
lines.append("")
return "\n".join(lines)
def _render_diff_table(data: dict[str, Any]) -> str:
"""Render diff as table."""
new = data.get("new_ads", [])
removed = data.get("removed_ads", [])
changed = data.get("changed_ads", [])
lines = [
"| Type | Address | Price | Status |",
"|------|---------|-------|--------|",
]
for ad in new:
addr = (ad.get("address") or "")[:30]
price = f"{ad.get('asking_price'):,}" if ad.get("asking_price") else "N/A"
lines.append(f"| 🆕 New | {addr} | {price} | - |")
for ad in removed:
addr = (ad.get("address") or "")[:30]
lines.append(f"| ❌ Removed | {addr} | - | - |")
for change in changed:
addr = (change.get("address") or "")[:30]
diffs = change.get("diffs", {})
changed_fields = ", ".join(diffs.keys())
lines.append(f"| 🔄 Changed | {addr} | - | {changed_fields} |")
return "\n".join(lines)
# ============================================================================
# Score breakdown renderer
# ============================================================================
def render_score_breakdown(payload: dict[str, Any], fmt: OutputFormat) -> str:
"""Render score breakdown details."""
_validate_format(fmt)
if fmt == "json":
return json.dumps(payload, indent=2, default=str)
else:
return _render_score_breakdown_markdown(payload)
def _render_score_breakdown_markdown(data: dict[str, Any]) -> str:
"""Render score breakdown as markdown."""
scores = data.get("scores", {})
lines = [
"# Score Breakdown",
"",
]
for key, value in scores.items():
lines.append(f"- **{key}:** {value}")
return "\n".join(lines)
# ============================================================================
# Cache stats renderer
# ============================================================================
def render_cache_stats(payload: dict[str, Any], fmt: OutputFormat) -> str:
"""Render cache statistics."""
_validate_format(fmt)
if fmt == "json":
return json.dumps(payload, indent=2, default=str)
elif fmt == "markdown":
return _render_cache_stats_markdown(payload)
else: # table
return _render_cache_stats_table(payload)
def _render_cache_stats_markdown(data: dict[str, Any]) -> str:
"""Render cache stats as markdown."""
lines = [
"# Cache Statistics",
"",
f"- **Total FINN Ads:** {data.get('total_finn_ads', 0)}",
f"- **Total Eiendom Units:** {data.get('total_eiendom_units', 0)}",
f"- **Total Search Runs:** {data.get('total_search_runs', 0)}",
f"- **Cache Size:** {data.get('cache_size_mb', 0):.2f} MB",
f"- **Last Updated:** {data.get('last_updated')}",
]
return "\n".join(lines)
def _render_cache_stats_table(data: dict[str, Any]) -> str:
"""Render cache stats as table."""
lines = [
"| Metric | Value |",
"|--------|-------|",
f"| Total FINN Ads | {data.get('total_finn_ads', 0)} |",
f"| Total Eiendom Units | {data.get('total_eiendom_units', 0)} |",
f"| Total Search Runs | {data.get('total_search_runs', 0)} |",
f"| Cache Size | {data.get('cache_size_mb', 0):.2f} MB |",
f"| Last Updated | {data.get('last_updated')} |",
]
return "\n".join(lines)
+145 -1
View File
@@ -2,6 +2,7 @@
import json
import logging
from typing import Any
from mcp.server.fastmcp import FastMCP
@@ -13,7 +14,24 @@ from .eiendom_no import (
get_unit,
search_unit_from_finn_url,
)
from .service import get_or_fetch_ad, get_or_fetch_eiendom_unit
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__)
@@ -151,6 +169,132 @@ def finn_decode_unit_vector(unit_vector: str) -> str:
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")
+3 -3
View File
@@ -60,7 +60,7 @@ class FinnAd(BaseModel):
detail_fetched_at: datetime | None = None
eiendom_unit_code: str | None = None
model_config = ConfigDict(serializers={datetime: lambda v: v.isoformat()})
model_config = ConfigDict()
class EiendomUnit(BaseModel):
@@ -87,7 +87,7 @@ class EiendomUnit(BaseModel):
unit_vector: str | None = None
fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
model_config = ConfigDict(serializers={datetime: lambda v: v.isoformat()})
model_config = ConfigDict()
class SimilarUnit(BaseModel):
@@ -112,7 +112,7 @@ class SimilarUnit(BaseModel):
finalized_at: datetime | None = None
listing_status: str = Field(default="RECENTLY_SOLD")
model_config = ConfigDict(serializers={datetime: lambda v: v.isoformat() if v else None})
model_config = ConfigDict()
class UnitVector(BaseModel):
+177 -4
View File
@@ -1,13 +1,27 @@
"""Service layer for cache-aware fetching of FINN ads and Eiendom.no units."""
import logging
from typing import Any
from .ad import fetch_ad_details
from .cache import get_eiendom_unit as get_cached_eiendom_unit
from .cache import get_finn_ad, init_db, save_eiendom_unit, save_finn_ad
from .analysis import analyze_search as run_analysis_search
from .cache import (
get_eiendom_unit as get_cached_eiendom_unit,
get_finn_ad,
init_db,
save_eiendom_unit,
save_finn_ad,
)
from .config import FINN_CACHE_PATH
from .eiendom_no import get_unit
from .models import EiendomUnit, FinnAd
from .eiendom_no import (
build_unit_vector,
decode_unit_vector,
get_similar_units,
get_unit,
search_unit_from_finn_url,
)
from .feedback import save_feedback as save_feedback_impl
from .models import EiendomUnit, FinnAd, SimilarUnit
logger = logging.getLogger(__name__)
@@ -33,3 +47,162 @@ async def get_or_fetch_eiendom_unit(
if unit is not None:
save_eiendom_unit(conn, unit)
return unit
async def get_or_fetch_similar_units(
unit_code: str, listing_status: str = "RECENTLY_SOLD", force_refresh: bool = False
) -> list[SimilarUnit]:
"""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)
async def resolve_eiendom_unit_from_finn_url(finn_url: str) -> EiendomUnit | None:
"""Resolve an Eiendom.no unit from a FINN listing URL."""
return await search_unit_from_finn_url(finn_url)
# ============================================================================
# Orchestration functions — delegate to analysis.py
# ============================================================================
async def analyze_search(
search_url: str,
*,
max_pages: int = 3,
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(
search_url,
max_pages=max_pages,
fetch_details=include_details,
detail_limit=detail_limit,
include_eiendom_no=include_eiendom_no,
include_similar_units_for_shortlist=include_similar_units_for_shortlist,
)
async def analyze_ad(
finnkode: str,
*,
include_eiendom_no: bool = True,
include_similar_units: bool = False,
) -> dict[str, Any]:
"""Fetch and enrich a single FINN ad with analysis."""
ad = await get_or_fetch_ad(finnkode)
result: dict[str, Any] = {
"ad": ad.model_dump(),
}
if include_eiendom_no and ad.eiendom_unit_code:
unit = await get_or_fetch_eiendom_unit(ad.eiendom_unit_code)
if unit:
result["eiendom_unit"] = unit.model_dump()
if include_similar_units:
similar = await get_or_fetch_similar_units(ad.eiendom_unit_code)
result["similar_units"] = [s.model_dump() for s in similar]
return result
async def analyze_ad_against_comps(
finnkode: str, listing_status: str = "RECENTLY_SOLD"
) -> dict[str, Any]:
"""Evaluate one listing against recent comparable sales."""
ad = await get_or_fetch_ad(finnkode)
result: dict[str, Any] = {
"ad": ad.model_dump(),
}
if ad.eiendom_unit_code:
unit = await get_or_fetch_eiendom_unit(ad.eiendom_unit_code)
if unit:
result["eiendom_unit"] = unit.model_dump()
comps = await get_or_fetch_similar_units(
ad.eiendom_unit_code, listing_status=listing_status
)
result["comparable_units"] = [c.model_dump() for c in comps]
return result
async def find_similar_to_liked(
finnkode: str, *, mode: str = "recommendations", listing_status: str = "FOR_SALE"
) -> dict[str, Any]:
"""Find properties similar to a listing the user has liked."""
# Requires that feedback.verdict = "liked" exists for this finnkode
ad = await get_or_fetch_ad(finnkode)
if not ad.eiendom_unit_code:
raise ValueError(
f"Finnkode {finnkode} has no Eiendom.no unit_code; cannot find similar properties"
)
# TODO: verify feedback verdict = "liked" exists
unit = await get_or_fetch_eiendom_unit(ad.eiendom_unit_code)
if not unit:
raise ValueError(f"Cannot enrich finnkode {finnkode} with Eiendom.no data")
similar = await get_or_fetch_similar_units(ad.eiendom_unit_code, listing_status=listing_status)
return {
"base_ad": ad.model_dump(),
"similar_listings": [s.model_dump() for s in similar],
"mode": mode,
}
async def compare_ads(
finnkoder: list[str], *, include_eiendom_no: bool = True, include_comps: bool = True
) -> dict[str, Any]:
"""Compare multiple FINN listings side by side."""
ads = []
for finnkode in finnkoder:
ad = await get_or_fetch_ad(finnkode)
ad_data = ad.model_dump()
if include_eiendom_no and ad.eiendom_unit_code:
unit = await get_or_fetch_eiendom_unit(ad.eiendom_unit_code)
if unit:
ad_data["eiendom_unit"] = unit.model_dump()
if include_comps:
comps = await get_or_fetch_similar_units(
ad.eiendom_unit_code, listing_status="RECENTLY_SOLD"
)
ad_data["comps"] = [c.model_dump() for c in comps]
ads.append(ad_data)
return {"listings": ads}
# ============================================================================
# Helper functions
# ============================================================================
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)
def decode_unit_vector_to_dict(unit_vector: str) -> dict[str, Any]:
"""Decode a unit_vector string to a dict."""
return decode_unit_vector(unit_vector)
def save_feedback(finnkode: str, verdict: str, notes: str | None = None) -> dict[str, Any]:
"""Store user feedback/verdict for a listing."""
return save_feedback_impl(finnkode, verdict, notes)
def get_shortlist(run_id: int | None = None, limit: int = 10) -> dict[str, Any]:
"""Fetch stored shortlist from a search run."""
# TODO: implement via search_runs table in cache.py
return {"shortlist": [], "run_id": run_id, "limit": limit}
def get_new_ads_since_last_run(search_url: str) -> dict[str, Any]:
"""Detect new/removed/changed listings vs the previous run."""
# TODO: implement via search_runs table in cache.py
return {"new_ads": [], "removed_ads": [], "changed_ads": [], "search_url": search_url}
+1
View File
@@ -16,6 +16,7 @@ dependencies = [
]
[project.scripts]
finn-eiendom = "finn_eiendom.cli:app"
finn-eiendom-mcp = "finn_eiendom.mcp_server:main"
[dependency-groups]
+172
View File
@@ -0,0 +1,172 @@
"""Tests for formatting module."""
import pytest
from finn_eiendom import formatting
def test_render_ad_json():
"""Test rendering ad as JSON."""
ad = {
"finnkode": "123456",
"title": "Nice apartment",
"address": "Hovedstreet 1",
"asking_price": 5000000,
"area_m2": 80,
}
result = formatting.render_ad(ad, "json")
assert "123456" in result
assert "Nice apartment" in result
assert isinstance(result, str)
def test_render_ad_markdown():
"""Test rendering ad as Markdown."""
ad = {
"finnkode": "123456",
"title": "Nice apartment",
"address": "Hovedstreet 1",
"asking_price": 5000000,
"area_m2": 80,
}
result = formatting.render_ad(ad, "markdown")
assert "Nice apartment" in result
assert "Hovedstreet 1" in result
assert "#" in result # Markdown headers
def test_render_shortlist_json():
"""Test rendering shortlist as JSON."""
data = {
"shortlist": [
{
"finnkode": "1",
"title": "Ad 1",
"address": "Street 1",
"asking_price": 5000000,
"area_m2": 80,
},
{
"finnkode": "2",
"title": "Ad 2",
"address": "Street 2",
"asking_price": 4500000,
"area_m2": 75,
},
]
}
result = formatting.render_shortlist(data, "json")
assert "Ad 1" in result
assert "Ad 2" in result
def test_render_shortlist_markdown():
"""Test rendering shortlist as Markdown."""
data = {
"shortlist": [
{
"finnkode": "1",
"title": "Ad 1",
"address": "Street 1",
"asking_price": 5000000,
"area_m2": 80,
},
]
}
result = formatting.render_shortlist(data, "markdown")
assert "Ad 1" in result
assert "Street 1" in result
def test_render_shortlist_table():
"""Test rendering shortlist as table."""
data = {
"shortlist": [
{
"finnkode": "1",
"title": "Ad 1",
"address": "Street 1",
"asking_price": 5000000,
"area_m2": 80,
},
]
}
result = formatting.render_shortlist(data, "table")
assert "|" in result # Table format
def test_render_comparison():
"""Test rendering comparison."""
data = {
"listings": [
{
"finnkode": "1",
"title": "Ad 1",
"address": "Street 1",
"asking_price": 5000000,
"area_m2": 80,
},
{
"finnkode": "2",
"title": "Ad 2",
"address": "Street 2",
"asking_price": 4500000,
"area_m2": 75,
},
]
}
result = formatting.render_comparison(data, "markdown")
assert "Comparison" in result
assert "Ad 1" in result
assert "Ad 2" in result
def test_render_diff():
"""Test rendering diff."""
data = {
"new_ads": [
{
"title": "New Ad",
"address": "New Street",
"asking_price": 5000000,
},
],
"removed_ads": [],
"changed_ads": [],
}
result = formatting.render_diff(data, "markdown")
assert "New" in result
assert "New Ad" in result
def test_render_cache_stats():
"""Test rendering cache stats."""
data = {
"total_finn_ads": 100,
"total_eiendom_units": 50,
"total_search_runs": 10,
"cache_size_mb": 25.5,
"last_updated": "2024-01-01",
}
result = formatting.render_cache_stats(data, "json")
assert "100" in result
assert "50" in result
def test_format_validation():
"""Test that invalid formats raise ValueError."""
with pytest.raises(ValueError):
formatting.render_ad({}, "invalid")
def test_render_unit():
"""Test rendering Eiendom unit."""
unit = {
"unit_code": "code123",
"address": "Hovedstreet 1",
"rooms": 3,
"estimated_selling_price": 5000000,
}
result = formatting.render_unit(unit, "markdown")
assert "Hovedstreet 1" in result
assert "3" in result
+265
View File
@@ -0,0 +1,265 @@
"""Tests for Pydantic models in finn_eiendom.models."""
import json
from datetime import UTC, datetime
from finn_eiendom.models import (
EiendomUnit,
FinnAd,
FinnSearchCard,
SimilarUnit,
UnitVector,
)
def test_finn_search_card_json_roundtrip():
"""Test JSON roundtrip for FinnSearchCard model."""
data = {
"finnkode": "123456789",
"url": "https://www.finn.no/realestate/homes/ad.html?finnkode=123456789",
"title": "Beautiful apartment in Oslo",
"address": "Karl Johans gate 1",
"area_m2": 80,
"asking_price": 5000000,
"total_price": 5200000,
"common_costs": 2000,
"property_type": "APARTMENT",
"ownership_type": "FREEHOLD",
"bedrooms": 2,
"floor": "3/5",
"broker_company": "Estate AS",
}
# Create model instance
model = FinnSearchCard(**data)
# Convert to JSON
json_str = model.model_dump_json()
# Parse JSON back to dict
parsed_data = json.loads(json_str)
# Create new instance from parsed data
model_from_json = FinnSearchCard(**parsed_data)
# Assert they're equal
assert model == model_from_json
def test_finn_ad_json_roundtrip():
"""Test JSON roundtrip for FinnAd model."""
now = datetime.now(UTC)
data = {
"finnkode": "123456789",
"url": "https://www.finn.no/realestate/homes/ad.html?finnkode=123456789",
"title": "Beautiful apartment in Oslo",
"address": "Karl Johans gate 1",
"postal_area": "0150",
"district": "Sentrum",
"property_type": "APARTMENT",
"ownership_type": "FREEHOLD",
"asking_price": 5000000,
"total_price": 5200000,
"shared_debt": 100000,
"common_costs": 2000,
"municipal_fee": 500,
"other_fees": 300,
"area_m2": 80,
"rooms": 3,
"bedrooms": 2,
"floor": "3/5",
"construction_year": 2000,
"energy_rating": "C",
"heating": "FJERNVARME",
"has_balcony": True,
"has_terrace": False,
"has_elevator": True,
"has_parking": False,
"has_garage": False,
"listing_description": "A lovely apartment with great views",
"broker_name": "John Doe",
"broker_company": "Estate AS",
"first_seen_at": now,
"last_seen_at": now,
"detail_fetched_at": now,
"eiendom_unit_code": "EI-987654321",
}
# Create model instance
model = FinnAd(**data)
# Convert to JSON
json_str = model.model_dump_json()
# Parse JSON back to dict
parsed_data = json.loads(json_str)
# Create new instance from parsed data
model_from_json = FinnAd(**parsed_data)
# Assert they're equal
assert model == model_from_json
def test_eiendom_unit_json_roundtrip():
"""Test JSON roundtrip for EiendomUnit model."""
now = datetime.now(UTC)
data = {
"unit_code": "EI-987654321",
"address": "Karl Johans gate 1",
"lat": 59.9139,
"lng": 10.7522,
"property_type": "APARTMENT",
"floor": 3,
"rooms": 3,
"construction_year": 2000,
"usable_area": 80,
"estimated_selling_price": 5500000,
"estimated_selling_price_lower": 5200000,
"estimated_selling_price_upper": 5800000,
"listing_price": 5000000,
"listing_sqm_price": 62500,
"common_costs": 2000,
"days_on_market": 45,
"sale_status": "FOR_SALE",
"market_placement_score": "GOOD",
"unit_vector": "base64encodedvector===",
"fetched_at": now,
}
# Create model instance
model = EiendomUnit(**data)
# Convert to JSON
json_str = model.model_dump_json()
# Parse JSON back to dict
parsed_data = json.loads(json_str)
# Create new instance from parsed data
model_from_json = EiendomUnit(**parsed_data)
# Assert they're equal
assert model == model_from_json
def test_similar_unit_json_roundtrip():
"""Test JSON roundtrip for SimilarUnit model."""
data = {
"unit_code": "EI-123456789",
"address": "Karl Johans gate 2",
"lat": 59.9140,
"lng": 10.7523,
"property_type": "APARTMENT",
"floor": 4,
"rooms": 3,
"construction_year": 2005,
"usable_area": 90,
"listing_price": 5800000,
"selling_price": 5700000,
"shared_debt": 120000,
"common_costs": 2200,
"sqm_price": 63333,
"days_on_market": 30,
"sale_status": "SOLD",
"finalized_at": datetime.now(UTC),
"listing_status": "RECENTLY_SOLD",
}
# Create model instance
model = SimilarUnit(**data)
# Convert to JSON
json_str = model.model_dump_json()
# Parse JSON back to dict
parsed_data = json.loads(json_str)
# Create new instance from parsed data
model_from_json = SimilarUnit(**parsed_data)
# Assert they're equal
assert model == model_from_json
def test_unit_vector_json_roundtrip():
"""Test JSON roundtrip for UnitVector model."""
data = {
"lon": 10.7522,
"lat": 59.9139,
"ptype": "APARTMENT",
"floor": 3,
"rooms": 3,
"built": 2000,
"area": 80,
"price": 5000000,
}
# Create model instance
model = UnitVector(**data)
# Convert to JSON
json_str = model.model_dump_json()
# Parse JSON back to dict
parsed_data = json.loads(json_str)
# Create new instance from parsed data
model_from_json = UnitVector(**parsed_data)
# Assert they're equal
assert model == model_from_json
def test_finn_ad_serializer_datetime():
"""Test that FinnAd datetime serialization works correctly."""
now = datetime.now(UTC)
data = {
"finnkode": "123456789",
"url": "https://www.finn.no/realestate/homes/ad.html?finnkode=123456789",
"first_seen_at": now,
"last_seen_at": now,
}
model = FinnAd(**data)
json_str = model.model_dump_json()
parsed_data = json.loads(json_str)
# Check that datetimes are serialized as ISO strings
assert isinstance(parsed_data["first_seen_at"], str)
assert isinstance(parsed_data["last_seen_at"], str)
assert "T" in parsed_data["first_seen_at"] # ISO format contains T
assert "Z" in parsed_data["first_seen_at"] or "+" in parsed_data["first_seen_at"] # TZ info
def test_eiendom_unit_serializer_datetime():
"""Test that EiendomUnit datetime serialization works correctly."""
now = datetime.now(UTC)
data = {
"unit_code": "EI-987654321",
"fetched_at": now,
}
model = EiendomUnit(**data)
json_str = model.model_dump_json()
parsed_data = json.loads(json_str)
# Check that datetimes are serialized as ISO strings
assert isinstance(parsed_data["fetched_at"], str)
assert "T" in parsed_data["fetched_at"] # ISO format contains T
assert "Z" in parsed_data["fetched_at"] or "+" in parsed_data["fetched_at"] # Timezone info
def test_similar_unit_serializer_datetime():
"""Test that SimilarUnit datetime serialization works correctly (handles None)."""
data = {
"unit_code": "EI-123456789",
"finalized_at": None, # This should serialize to null
}
model = SimilarUnit(**data)
json_str = model.model_dump_json()
parsed_data = json.loads(json_str)
# Check that None datetimes serialize to null
assert parsed_data["finalized_at"] is None