526 lines
18 KiB
Python
526 lines
18 KiB
Python
"""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')} m²",
|
|
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')} m²",
|
|
"",
|
|
"## 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')} m²",
|
|
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')} m²" 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')} m²",
|
|
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')} m²"
|
|
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)
|