65 KiB
PRD: finn-eiendom-mcp — Personal Real Estate Scout
Private, self-hosted property analysis platform built around a FINN scraper, an Eiendom.no enrichment layer, a scoring engine, and a SQLite cache. Exposed through three coordinated entry points: a Python library (
finn_eiendom), an MCP server (FastMCP, stdio + optional HTTP), and a CLI (finn-eiendom). The Python library is the source of truth — MCP and CLI are thin, parallel front ends over the same service layer.
1. Summary
finn-eiendom-mcp analyzes a FINN real-estate search URL and returns a ranked shortlist of properties enriched with Eiendom.no estimates, comparable recently-sold units, scoring, risk flags, and broker questions. The same domain code powers:
- MCP tools for Claude Desktop / AI clients / n8n / agents.
- A CLI for terminal-driven manual analysis and shell scripting.
- A Python library that tests and notebooks can call directly.
FINN search URL
→ listings (search cards)
→ FINN details
→ Eiendom.no enrichment (unit search + unit detail)
→ unit_vector (built locally)
→ similar-units / comps
→ scoring + categorization
→ shortlist + risks + next steps + broker questions
This is a private, low-frequency decision-support tool. Not a SaaS, not a crawler, not a bidding tool, not legal/technical/financial advice.
2. Why three entry points
| Layer | Audience | Transport | Purpose |
|---|---|---|---|
| Python library | tests, notebooks, custom scripts | in-process | Source of truth. Pure functions + async I/O. No global state beyond SQLite path. |
| MCP server | Claude Desktop, n8n, AI agents | stdio + streamableHttp | LLM-driven analysis, shortlisting, broker prep. |
| CLI | terminal, cron, ad-hoc debugging | stdio | Quick checks, smoke tests, scripted runs, demonstrations of new behavior. |
The architectural rule: all three layers call the same service functions. MCP tools and CLI commands are thin wrappers around service.py. If a change goes into one, equivalent behavior appears in the others.
3. User context & preferences
User and partner are searching for a home in the Oslo area, roughly 9–12 MNOK depending on total monthly cost, rental/hybel potential, and property quality. Important preferences:
- Good location and quality of life.
- Enough space and strong floor plan.
- Minimum 2 bedrooms, preferably more.
- Balcony, terrace, views, sun, sea/nature proximity.
- Hybel/rental potential or flexible layout.
- Willing to renovate themselves if the price is right.
- Renovation need is not automatically negative.
- Strong interest in bargain candidates where competition may be lower due to older standard or poor presentation.
- Avoid uncontrolled technical/legal risk: moisture, rot, illegal hybel, unapproved changes, severe TG3, unclear housing-association finances.
4. Problem
FINN search results are not ranked by the user's actual decision criteria. Manually triaging dozens of listings is slow and inconsistent. The current process lacks:
- Automated extraction of FINN search and listing data.
- Linking FINN listings to structured Eiendom.no units.
- Price evaluation against Eiendom.no estimates and comparable sales.
- Similar-property discovery from listings the user already likes.
- Consistent scoring of price, location, layout, risk, renovation upside, hybel potential.
- Local history of seen listings, changes, scores, and feedback.
- Integration with AI clients and shell tooling.
5. Goals
The system shall:
- Accept a FINN real estate search URL via library, MCP tool, or CLI command.
- Parse FINN search pages and extract listing cards, URLs, and finnkoder.
- Fetch FINN listing detail pages and parse into a structured
FinnAd. - Normalize Norwegian numbers, areas, currencies, dates, URLs.
- Resolve each FINN URL to an Eiendom.no
unitCodeand fetch the unit detail. - Build a base64url-encoded
unit_vectorfrom unit detail and fetch similar-units / comps. - Score each listing using FINN data, Eiendom.no estimates, comps, user preferences, and risk signals.
- Return a ranked shortlist with reasons, risks, next steps, and broker questions.
- Cache HTML, JSON, parsed ads, units, comps, scores, and feedback in SQLite.
- Detect new/removed/changed listings between runs of the same search URL.
- Store user feedback (
liked,rejected,interesting,risk,viewing_candidate, etc.) and surface it in subsequent runs. - Expose all of the above through MCP tools, CLI commands, and Python functions with consistent semantics.
- Run locally in a project-local virtualenv. Docker is supported but optional.
6. Non-goals
MVP shall not:
- Crawl all of FINN or Eiendom.no.
- Bypass rate limits, bot protection, authentication, or access controls.
- Bulk-harvest or redistribute data.
- Contact brokers automatically.
- Place bids automatically.
- Interpret full PDF condition reports.
- Provide official valuation, legal advice, technical inspection, or mortgage advice.
- Expose a public SaaS service.
- Build a web UI.
7. Primary use cases
| ID | Use case | Description |
|---|---|---|
| UC1 | Analyze FINN search | Paste a FINN search URL → ranked shortlist with reasons/risks/next steps. |
| UC2 | Find bargain candidates | Surface listings with renovation need or weak presentation that may be underpriced. |
| UC3 | Separate renovation from risk | Treat cosmetic renovation as upside; flag technical/legal risk. |
| UC4 | Compare listings | Side-by-side comparison of multiple finnkoder. |
| UC5 | Save feedback | Mark listings as liked, rejected, interesting, risk, viewing candidate, etc. |
| UC6 | Find new listings since last run | Show new/removed/changed listings vs the prior run of the same search URL. |
| UC7 | Broker questions | Generate concrete questions based on risks, deviations, hybel status, comps. |
| UC8 | Eiendom.no enrichment | Add estimates, coordinates, area, rooms, floor, year, market data. |
| UC9 | Price fairness | Classify price as cheap / fair / expensive vs estimate and comps. |
| UC10 | Similar to liked | Find properties similar to listings the user has explicitly liked. |
| UC11 | Comparable sales | Fetch similar recently sold units to support valuation and bargain scoring. |
8. Inputs
Supported inputs across all three layers:
- FINN search URL.
- FINN listing URL.
- Finnkode (string of digits).
- List of finnkoder.
- Eiendom.no
unitCode. - Eiendom.no
unit_vector(base64url string). - User feedback verdict + notes.
- Optional scoring/preference overrides (JSON or env).
Example FINN search URL:
https://www.finn.no/realestate/homes/search.html?bbox=...&area_from=60&min_bedrooms=2&price_collective_to=12000000&...
9. External endpoints
9.1 FINN HTML
Not JSON. Parse HTML, cache aggressively, run at low frequency.
| Method | URL pattern | Purpose |
|---|---|---|
| GET | https://www.finn.no/realestate/homes/search.html?{query_params} |
Parse search result cards, listing URLs, finnkoder. |
| GET | https://www.finn.no/realestate/homes/search.html?{query_params}&page={N} |
Pagination. |
| GET | https://www.finn.no/realestate/homes/ad.html?finnkode={finnkode} |
Parse listing detail page. |
| GET | {calendar_ics_url_from_listing_html} |
Optional: parse viewing times (prefer parsing from listing HTML first). |
Important search params: bbox, location, area_from, area_to, price_collective_to, price_collective_from, min_bedrooms, facilities, floor_navigator, lifecycle, page, stored-id.
9.2 Eiendom.no
Real JSON API. Used for enrichment, valuation, and similar-units.
9.2.1 Resolve FINN listing → Eiendom.no unitCode
GET https://api.eiendom.no/api/v1/geodata/units/search/?search={url_encoded_finn_listing_url_or_address}
Returns:
{
"units": [
{
"unitCode": "c-gxw-xmyum-s2a",
"address": "Gunnar Schjelderups v. 11D H0502, Oslo",
"geometry": { "type": "Point", "coordinates": [10.77, 59.95] }
}
],
"summary": { "totalUnitsFound": 1, "totalCitiesFound": 1 }
}
9.2.2 Fetch unit detail
GET https://api.eiendom.no/api/v1/geodata/units/{unitCode}/
Important response fields: unitCode, address, unitName, streetAddress, postalName, registrationCode, geometry.coordinates, specification.{propertyType, floor, rooms, constructionYear, usableArea}, valuation.{estimatedSellingPrice, estimatedSellingPriceLower, estimatedSellingPriceUpper}, latestMarketData.{listingPrice, monthlyCosts, squareMeterPrice, daysOnMarket, saleStatus, marketPlacementScore}.
9.2.3 Build unit_vector (local, not HTTP)
Encoding step before similar-units. Generated from unit detail data:
{
"lon": 10.7803,
"lat": 59.9287,
"ptype": "APARTMENT",
"floor": 8,
"rooms": 5,
"built": 2005,
"area": 80,
"price": 8491082
}
Encoding: unit_vector = base64url_without_padding(msgpack(payload)).
Library functions (in eiendom_no.py only):
build_unit_vector(unit) -> strdecode_unit_vector(unit_vector) -> dict
9.2.4 Fetch similar-units
GET https://api.eiendom.no/api/v1/geodata/units/similar/?unit_vector={unit_vector}
Returns a list of comparable units with unitCode, address, geometry, specification, and marketData.{listingPrice, jointDebt, monthlyCosts, sellingPrice, squareMeterPrice, daysOnMarket, saleStatus, finalizedAt}.
listing_status (RECENTLY_SOLD / FOR_SALE / CURRENT) is implemented as a local filter over the returned marketData.saleStatus and finalizedAt. Only pass it to the API if later experimentation confirms server-side support.
9.3 Optional Hjemla (disabled by default)
GET https://consumer-service-hjemla-prod.propcloud.no/public/market/address-list
Params: marketType, period, marketStates, unittypes, bbox (swLat, neLat, swLng, neLng), limit, randomize.
Useful for bbox-level market snapshots. Disabled in MVP via HJEMLA_ENABLED=false.
9.4 MCP server endpoint
stdio is the default. Optional Streamable HTTP on POST http://{host}:8010/mcp. Operational endpoints when running HTTP: GET /health, GET /version, GET /debug/config.
10. Functional requirements
10.1 FINN search extraction
Fetch and parse FINN search pages. Extract and deduplicate by finnkode. Support pagination via page=N and respect FINN_MAX_SEARCH_PAGES. Search-card fields when available: finnkode, URL, title, address/area, area, asking_price, total_price, common_costs, ownership_type, property_type, bedrooms, floor, viewing time, broker.
10.2 FINN listing detail extraction
Fetch and parse individual listing pages. Fields when available: finnkode, URL, title, address, postal_area, district, property_type, ownership_type, asking_price, total_price, shared_debt, common_costs, fees, municipal_fees, BRA/BRA-i/BRA-e/BRA-b, P-room, rooms, bedrooms, floor, construction_year, energy_rating, heating, balcony/terrace, elevator, parking/garage, viewings, listing_description, broker_name, broker_company, document_links.
10.3 Normalization
- Norwegian formatted numbers:
7 200 991 kr→7200991. - Areas:
77 m²→77. - Dates/viewings → ISO 8601.
- URLs → absolute.
- Missing values →
null. - Finnkode and Eiendom.no unitCode as strings.
10.4 Eiendom.no enrichment
Enabled by default. Flow: FINN listing URL → unit search → unitCode → unit detail → structured market data. Store: unit_code, address, coordinates, registration code, property_type, floor, rooms, construction_year, usable_area, estimated_selling_price + lower/upper, latest market data (listing_price, sqm_price, monthly_costs, days_on_market, sale_status), market_placement, raw JSON.
If enrichment fails, the analysis continues with FINN data only and marks enrichment as unavailable.
10.5 Similar-units / unit_vector
Required functions: build_unit_vector(unit), decode_unit_vector(unit_vector), get_similar_units(unit_vector, listing_status). Supported listing statuses: RECENTLY_SOLD (default for comps), FOR_SALE (active recommendations), CURRENT (if confirmed). Similar-unit fields when available: unit_code, address, coordinates, property_type, floor, rooms, construction_year, area, listing_price, selling_price, shared_debt, common_costs, sqm_price, days_on_market, sale_status, finalized_at, raw JSON.
10.6 Cache and history
SQLite. Default TTLs:
| Data | Default TTL |
|---|---|
| Search results | 30–60 minutes |
| FINN listing details | 6–24 hours |
| Eiendom.no unit data | 24 hours |
| Similar-units | 24 hours |
| Feedback/history | Permanent until deleted |
10.7 Feedback
Verdict vocabulary: liked, rejected, interesting, bargain_candidate, risk_object, viewing_candidate, viewed, too_expensive, too_small, too_far_out, too_high_risk, likes_location, likes_layout, dislikes_area. Stored permanently. liked listings are used as seeds for similar-to-liked recommendations. Feedback can be used as a soft scoring signal.
10.8 Diffs between runs
For a normalized search URL, the system shall compare finnkoder against the previous run and report new_ads, removed_ads, and changed_ads (price, common costs, status). Optionally re-fetch only new or changed details.
11. Scoring and classification
11.1 Score model (clamped to 0–100)
| Category | Range |
|---|---|
| Economy / total cost | 0–20 |
| Eiendom.no estimate / market position | 0–20 |
| Comparable sales / similar-units | 0–20 |
| Location | 0–15 |
| Layout and potential | 0–20 |
| Outdoor space / view / sun | 0–15 |
| Hybel / rental potential | 0–10 |
| Renovation / bargain upside | 0–15 |
| Technical / legal risk | -20–0 |
11.2 Categories
bargain_candidate, safe_candidate, lifestyle_candidate, hybel_candidate, renovation_candidate, similar_to_liked, comparable_sale_match, risk_object, too_expensive, not_interesting, manual_review_required.
11.3 Bargain candidate logic
A listing may be a bargain candidate when several of these are true: low sqm price vs comps, listing price below estimate, price near lower estimate interval, sqm price below similar recently sold, older standard / renovation need / weak presentation, strong underlying location/layout, suitable size, risk appears controllable.
11.4 Renovation logic
Renovation need is not automatically negative.
- Opportunity: older standard, modernization need, renovation object, cosmetic wear, outdated kitchen/surfaces, weak presentation, layout improvement potential.
- Risk: moisture, rot, mold, drainage issues, load-bearing concerns, illegal/unapproved changes, non-approved hybel, serious electrical/wet-room deviations, TG3 with high cost or safety implications.
11.5 Hybel / rental logic
- Positive: hybel, rental unit, separate entrance, extra bathroom/kitchenette, basement/sokkel, secondary section, stated rental income.
- Risk: not approved, not applied for, not building-reported, only "disposable room", not approved for permanent residence, board approval required.
Output classifies as: documented legal hybel / possible hybel potential / unclear/risky hybel / not relevant.
11.6 Market and comparable outputs
Market estimate: market_score, price_vs_estimate_pct, price_position (below_estimate / within_estimate_range / above_estimate / unknown), sqm_price_position (cheap / normal / expensive / unknown).
Comparable: comparable_score, comps_count, avg_selling_price, median_selling_price (where possible), avg_sqm_price, sqm_price_delta_pct, price_delta_pct, confidence (low / medium / high).
Risk factors: too few comps, comps too far away, large differences in area/rooms/floor/year, old sale dates, low confidence.
12. Technical architecture
AI client / Claude Desktop / n8n / agent ← MCP layer
↓
FastMCP (stdio | streamable HTTP)
User in a terminal ← CLI layer
↓
finn-eiendom CLI (typer)
Python tests / notebooks / custom scripts ← Library layer
↓
import finn_eiendom
──────── all three above share ────────
finn_eiendom.formatting ← render_* for json/markdown/table
↓
finn_eiendom.service ← orchestration: get_or_fetch, analyze_*
↓
finn_eiendom.analysis ← shortlist + summary building
↓
search / ad / eiendom_no / scoring / feedback
↓
finn_eiendom.cache (SQLite) ← html, json, ads, units, comps, scores, feedback
↓
finn_eiendom.http (httpx) ← delay, retry, user-agent
↓
FINN HTML + Eiendom.no JSON (+ optional Hjemla)
12.1 Module layout
finn_eiendom/
__init__.py
config.py # env / defaults / TTLs
models.py # Pydantic v2 models
parser.py # number/area/date/URL/finnkode normalization
http.py # async HTTP with delay, retry, user-agent
cache.py # SQLite schema + persistence
search.py # FINN search HTML parsing + pagination
ad.py # FINN listing HTML parsing
eiendom_no.py # unit search/detail, unit_vector, similar-units
scoring.py # score model + classifications
feedback.py # verdicts + soft preference signal
analysis.py # orchestration + shortlist + summary
service.py # get_or_fetch_* + thin facade for MCP and CLI
formatting.py # render_* helpers shared by MCP and CLI
mcp_server.py # FastMCP wrappers around service
cli.py # typer-based CLI wrappers around service
__main__.py # python -m finn_eiendom → CLI entry
12.2 Layering rules
mcp_server.pyandcli.pyare thin. They translate inputs to service calls and format outputs viaformatting.py.service.pyorchestrates cache + fetch. Every read should consult the cache first; every fresh fetch should write back.analysis.pyorchestrates the full shortlist run: search → details → enrichment → comps → scoring → summary.- Domain modules (
search,ad,eiendom_no,scoring,feedback) are pure or only depend onhttp/cache. - No layer above the service may call
httpxorsqlite3directly.
13. Data model
SQLite. Existing schema already implements finn_ads, eiendom_units, similar_units, and cache_meta. MVP additions: search_runs, scores, feedback.
CREATE TABLE finn_ads (
finnkode TEXT PRIMARY KEY,
url TEXT,
payload TEXT NOT NULL, -- JSON-serialized FinnAd
fetched_at TEXT NOT NULL
);
CREATE TABLE eiendom_units (
unit_code TEXT PRIMARY KEY,
payload TEXT NOT NULL, -- JSON-serialized EiendomUnit
fetched_at TEXT NOT NULL
);
CREATE TABLE similar_units (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unit_code TEXT NOT NULL,
listing_status TEXT NOT NULL,
payload TEXT NOT NULL, -- JSON array of SimilarUnit
fetched_at TEXT NOT NULL
);
CREATE TABLE cache_meta (
key TEXT PRIMARY KEY, -- e.g. search_page:{url}, search_cards:{url}
value TEXT NOT NULL,
expires_at TEXT
);
CREATE TABLE search_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
search_url TEXT NOT NULL,
normalized_url TEXT NOT NULL,
created_at TEXT NOT NULL,
total_found INTEGER,
total_parsed INTEGER,
total_scored INTEGER,
result_json TEXT -- shortlist snapshot
);
CREATE TABLE scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finnkode TEXT NOT NULL,
search_run_id INTEGER,
total_score REAL,
economy REAL,
market_position REAL,
comparable_sales REAL,
location REAL,
layout REAL,
outdoor REAL,
rental_potential REAL,
renovation REAL,
risk REAL,
categories_json TEXT,
explanation_json TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finnkode TEXT NOT NULL,
verdict TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL
);
14. MCP design
14.1 Tools
All tool names use the finn_ prefix to avoid collisions when the server runs alongside others.
| Tool | Purpose | Read-only |
|---|---|---|
finn_analyze_search |
Analyze a FINN search URL and return a ranked shortlist. | yes |
finn_get_ad |
Fetch structured data for one finnkode. | yes |
finn_compare_ads |
Compare multiple listings side by side. | yes |
finn_save_feedback |
Store feedback/verdict/notes. | no |
finn_get_shortlist |
Fetch stored shortlist from a search run. | yes |
finn_get_new_ads_since_last_run |
Detect new/removed/changed listings vs the previous run. | yes |
finn_resolve_eiendom_unit |
Map FINN URL → Eiendom.no unitCode. |
yes |
finn_get_eiendom_unit |
Fetch Eiendom.no unit detail by unitCode. |
yes |
finn_enrich_ad |
Combine FINN listing and Eiendom.no enrichment. | yes |
finn_build_unit_vector |
Build a base64url unit_vector from a unitCode. |
yes |
finn_decode_unit_vector |
Decode a unit_vector for inspection/debugging. |
yes |
finn_get_similar_units |
Fetch comps/recommendations from unit_vector. |
yes |
finn_find_similar_to_liked_ad |
Find properties similar to a listing the user has liked. | yes |
finn_analyze_ad_against_comps |
Evaluate one listing against RECENTLY_SOLD comps. |
yes |
All read-only tools set readOnlyHint=True, destructiveHint=False, openWorldHint=True. finn_save_feedback sets readOnlyHint=False, destructiveHint=False, idempotentHint=False.
14.2 Tool input schemas (Pydantic v2)
class AnalyzeSearchInput(BaseModel):
search_url: str = Field(..., description="Full FINN search URL")
max_pages: int = Field(default=3, ge=1, le=10)
detail_limit: int = Field(default=20, ge=1, le=100)
include_details: bool = True
include_eiendom_no: bool = True
include_similar_units_for_shortlist: bool = False
response_format: Literal["json", "markdown"] = "json"
class GetAdInput(BaseModel):
finnkode: str = Field(..., pattern=r"^\d+$")
force_refresh: bool = False
include_eiendom_no: bool = True
include_similar_units: bool = False
class ResolveUnitInput(BaseModel):
finn_url: str
class GetUnitInput(BaseModel):
unit_code: str
force_refresh: bool = False
class BuildUnitVectorInput(BaseModel):
unit_code: str
class DecodeUnitVectorInput(BaseModel):
unit_vector: str
class SimilarUnitsInput(BaseModel):
unit_vector: str
listing_status: Literal["RECENTLY_SOLD", "FOR_SALE", "CURRENT"] = "RECENTLY_SOLD"
force_refresh: bool = False
class FindSimilarToLikedInput(BaseModel):
finnkode: str
mode: Literal["recommendations", "comps"] = "recommendations"
listing_status: Literal["RECENTLY_SOLD", "FOR_SALE", "CURRENT"] = "FOR_SALE"
class AnalyzeAgainstCompsInput(BaseModel):
finnkode: str
listing_status: Literal["RECENTLY_SOLD"] = "RECENTLY_SOLD"
class SaveFeedbackInput(BaseModel):
finnkode: str
verdict: str
notes: Optional[str] = None
class CompareAdsInput(BaseModel):
finnkoder: List[str] = Field(..., min_length=2, max_length=10)
include_eiendom_no: bool = True
include_comps: bool = True
14.3 Tool response convention
Every tool body wraps execution in try/except and returns a JSON string. Errors return:
return json.dumps({"error": True, "code": "<error_code>", "message": str(e)})
This keeps the protocol layer happy and lets the LLM react to recoverable failures.
When response_format="markdown", return human-readable formatted text instead of JSON — produced by formatting.py, never inline.
14.4 Resources
finn://preferences/current
finn://search-runs/latest
finn://search-runs/{id}
finn://ads/{finnkode}
finn://ads/{finnkode}/enriched
finn://shortlist/latest
finn://feedback/{finnkode}
finn://eiendom-units/{unitCode}
finn://eiendom-units/{unitCode}/similar/{listingStatus}
14.5 Prompts
evaluate_property_for_usercompare_properties_for_userrefine_search_from_feedbackfind_more_like_this
Evaluation prompt template output: category, score, short assessment, why interesting, Eiendom.no estimate, comparable sales, main risks, bargain potential, questions for broker, should we view it.
14.6 Entry point
# finn_eiendom/mcp_server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("finn_eiendom_mcp")
# ... tools defined here ...
def main() -> None:
mcp.run(transport="stdio")
if __name__ == "__main__":
main()
pyproject.toml:
[project.scripts]
finn-eiendom-mcp = "finn_eiendom.mcp_server:main"
finn-eiendom = "finn_eiendom.cli:app"
15. CLI design
Built with typer. Every command maps 1:1 to a service function — same parameters, same defaults, same outputs.
15.1 Commands
finn-eiendom analyze-search <url> [--max-pages 3] [--detail-limit 20] [--no-details] [--no-eiendom] [--with-similar] [--format json|markdown|table]
finn-eiendom get-ad <finnkode> [--force-refresh] [--no-eiendom] [--with-similar] [--format ...]
finn-eiendom compare <finnkode...> [--no-eiendom] [--no-comps] [--format ...]
finn-eiendom save-feedback <finnkode> <verdict> [--notes "..."]
finn-eiendom shortlist [--run-id ID] [--limit 10] [--format ...]
finn-eiendom diff <url> [--format ...] ← new / removed / changed
finn-eiendom resolve-unit <finn_url>
finn-eiendom get-unit <unit_code> [--force-refresh]
finn-eiendom enrich-ad <finnkode> [--with-similar]
finn-eiendom build-vector <unit_code>
finn-eiendom decode-vector <unit_vector>
finn-eiendom similar-units <unit_vector> [--status RECENTLY_SOLD|FOR_SALE|CURRENT]
finn-eiendom similar-to-liked <finnkode> [--mode recommendations|comps] [--status ...]
finn-eiendom analyze-against-comps <finnkode>
finn-eiendom cache stats | clear | clear-html | clear-json
finn-eiendom serve [--transport stdio|http] [--host 127.0.0.1] [--port 8010]
finn-eiendom config show | path
finn-eiendom doctor ← run a few smoke checks: cache reachable, eiendom.no reachable, finn reachable
finn-eiendom version
15.2 Output formats
--format json— full structured output (default for piping intojq).--format markdown— same data, human-readable.--format table— concise terminal table (foranalyze-search,compare,shortlist,diff).
All three are produced by finn_eiendom.formatting. CLI never formats inline.
15.3 Examples
# Triage a search live
finn-eiendom analyze-search 'https://www.finn.no/realestate/homes/search.html?location=...' --format table
# Drill into one listing
finn-eiendom get-ad 462400360 --format markdown
# Compare two finalists
finn-eiendom compare 462400360 461153194 --format markdown
# Mark a listing as liked, then ask for similar
finn-eiendom save-feedback 462400360 liked --notes "great layout, check fellesgjeld"
finn-eiendom similar-to-liked 462400360
# Operate the MCP server in HTTP mode for n8n
finn-eiendom serve --transport http --port 8010
15.4 CLI implementation pattern
# finn_eiendom/cli.py
import asyncio, typer
from . import service, formatting
app = typer.Typer(no_args_is_help=True, add_completion=False)
@app.command()
def analyze_search(
url: str,
max_pages: int = 3,
detail_limit: int = 20,
no_details: bool = typer.Option(False, "--no-details"),
no_eiendom: bool = typer.Option(False, "--no-eiendom"),
with_similar: bool = typer.Option(False, "--with-similar"),
format: str = typer.Option("json", "--format"),
) -> None:
result = asyncio.run(service.analyze_search(
search_url=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,
))
typer.echo(formatting.render_shortlist(result, format))
CLI commands are wrappers — no business logic, no rendering. If you need to add behavior, it goes in service.py and gets a matching MCP tool. If you need to change rendering, edit formatting.py.
16. Service layer
The keystone of the architecture.
# finn_eiendom/service.py — public surface
async def get_or_fetch_ad(finnkode: str, force_refresh: bool = False) -> FinnAd: ...
async def get_or_fetch_eiendom_unit(unit_code: str, force_refresh: bool = False) -> Optional[EiendomUnit]: ...
async def get_or_fetch_similar_units(unit_code: str, listing_status: str = "RECENTLY_SOLD", force_refresh: bool = False) -> list[SimilarUnit]: ...
async def analyze_search(search_url: str, *, max_pages=3, detail_limit=20, include_details=True, include_eiendom_no=True, include_similar_units_for_shortlist=False) -> dict: ...
async def analyze_ad(finnkode: str, *, include_eiendom_no=True, include_similar_units=False) -> dict: ...
async def analyze_ad_against_comps(finnkode: str, listing_status: str = "RECENTLY_SOLD") -> dict: ...
async def find_similar_to_liked(finnkode: str, *, mode="recommendations", listing_status="FOR_SALE") -> dict: ...
async def compare_ads(finnkoder: list[str], *, include_eiendom_no=True, include_comps=True) -> dict: ...
async def resolve_eiendom_unit_from_finn_url(finn_url: str) -> Optional[EiendomUnit]: ...
def build_unit_vector_for_unit_code(unit_code: str) -> dict: ...
def decode_unit_vector_to_dict(unit_vector: str) -> dict: ...
def save_feedback(finnkode: str, verdict: str, notes: Optional[str] = None) -> dict: ...
def get_shortlist(run_id: Optional[int] = None, limit: int = 10) -> dict: ...
def get_new_ads_since_last_run(search_url: str) -> dict: ...
Every function:
- Opens its own SQLite connection via
cache.init_db(FINN_CACHE_PATH). - Reads from cache first, with TTLs from
config.py. - On cache miss (or
force_refresh=True), calls the relevant fetch function inad.py/eiendom_no.py. - Writes the fresh result back to the cache.
- Returns a typed model or dict, never
Noneunexpectedly — failures raise with clear messages.
17. Code ownership and anti-duplication
This section is the constitution. Everything else flexes; this does not. The goal is one home for every piece of logic and one obvious answer to "where does this go?".
17.1 The single-home rule
Every piece of logic has exactly one home. If you're tempted to add it in two places, you're wrong about one of them — push it down a layer and call it from both.
17.2 Decision table — "where does this go?"
| Concern | Lives in | Never in |
|---|---|---|
| Parsing FINN search HTML | search.py |
mcp_server, cli, analysis, scripts |
| Parsing FINN listing HTML | ad.py |
mcp_server, cli, analysis, scripts |
| Norwegian number / date / URL / finnkode normalization | parser.py |
inline anywhere — if you write a regex twice, extract it |
| HTTP requests, retry, delay, user-agent | http.py |
search / ad / eiendom_no using httpx directly |
| SQLite reads/writes | cache.py |
every other module — go through cache helpers |
| Eiendom.no unit search / unit detail | eiendom_no.py |
ad, search, analysis (call eiendom_no, don't reimplement) |
unit_vector encode / decode |
eiendom_no.py |
mcp_server, cli (call it; don't pack msgpack inline) |
| Similar-units fetching + local filtering | eiendom_no.py |
analysis, service (call get_similar_units) |
| Score components | scoring.py |
analysis (use score_ad), mcp_server, cli |
| Category assignment | scoring.py (classify_ad) |
analysis, mcp_server, cli |
| Feedback storage + retrieval | feedback.py |
mcp_server, cli, analysis |
| "Get from cache, else fetch, else save" | service.py (get_or_fetch_*) |
mcp_server, cli, analysis (always go through service) |
| Shortlist + summary assembly | analysis.py |
mcp_server, cli |
| End-to-end orchestration (search → shortlist) | service.py (analyze_search) |
mcp_server, cli (they just call it) |
| MCP tool definitions + annotations | mcp_server.py |
service, cli |
MCP error wrapping {"error": True, ...} |
mcp_server.py only |
service (which raises), cli (which has its own exit codes) |
| CLI command definitions + Typer plumbing | cli.py |
service, mcp_server |
| Output formatting (json / markdown / table) | formatting.py |
inline in mcp_server.py or cli.py |
| Env-var defaults | config.py |
hardcoded anywhere |
| Pydantic models | models.py |
redefined locally; subclass only if needed |
17.3 Layering invariants
The dependency graph is acyclic and points downward:
cli.py ─┐
├──> service.py ──> analysis.py ──> search / ad / eiendom_no / scoring / feedback
mcp_server.py ─┘ │
│ ├──> parser.py
│ └──> http.py / cache.py
└──> formatting.py
Hard rules:
mcp_server.pyandcli.pyare siblings and never call each other.- Neither MCP nor CLI imports from
search,ad,eiendom_no,scoring,feedback,cache, orhttp. They import fromservice,models, andformattingonly. service.pydoes not import frommcp_serverorcli.analysis.pydoes not open SQLite connections directly — it goes throughcache.pyfunctions.search.py,ad.py,eiendom_no.pydo not open SQLite directly — they call cache helpers passed in or imported fromcache.py.- Nothing except
http.pyuseshttpxdirectly. Ifimport httpxappears anywhere else, move it. - Nothing except
cache.pyusessqlite3directly. - Nothing except
parser.pydefines Norwegian-text regexes.
17.4 Anti-duplication checklist
Before merging any change, ask:
- Is this logic already implemented somewhere? (
grepthe function name and obvious keywords.) - If I'm copy-pasting from another file, am I about to duplicate behavior that should live in one shared function?
- Can a new caller use an existing
service.pyfunction instead of writing its own orchestration? - Is the same Pydantic field defined in two models? If yes, factor out a base model.
- Am I formatting output in two places (CLI + MCP)? Move it to
formatting.py. - Am I opening a SQLite connection outside
cache.py? Move it. - Am I building an httpx call outside
http.py? Move it. - Am I writing a Norwegian-number / area / finnkode regex outside
parser.py? Move it. - Am I adding an env-var lookup outside
config.py? Move it. - Did I add a new behavior with only one front end (MCP or CLI)? If it should exist in both, the service function is missing.
17.5 Examples — what NOT to do
Bad: MCP tool reaches into ad.py directly.
# ❌ in mcp_server.py
from .ad import fetch_ad_details
@mcp.tool()
async def finn_get_ad(...):
ad = await fetch_ad_details(...) # bypasses cache!
Good: MCP tool goes through service.py.
# ✅ in mcp_server.py
from .service import get_or_fetch_ad
@mcp.tool()
async def finn_get_ad(...):
ad = await get_or_fetch_ad(finnkode, force_refresh=force_refresh)
return ad.model_dump_json()
Bad: CLI formats output inline that MCP also needs.
# ❌ in cli.py
def _render_shortlist_markdown(result): ... # 80 lines of formatting
# later in mcp_server.py, the same 80 lines copy-pasted
Good: Shared formatter.
# ✅ in finn_eiendom/formatting.py
def render_shortlist(result: dict, fmt: str) -> str: ...
# cli.py and mcp_server.py both call render_shortlist(result, fmt)
Bad: Service inlines parsing or HTTP.
# ❌ in service.py
async def get_or_fetch_ad(...):
html = await httpx.AsyncClient().get(url) # http belongs in http.py
soup = BeautifulSoup(html.text, "html.parser") # parsing belongs in ad.py
Good: Service delegates.
# ✅ in service.py
async def get_or_fetch_ad(finnkode, force_refresh=False):
conn = cache.init_db(FINN_CACHE_PATH)
if not force_refresh:
cached = cache.get_finn_ad(conn, finnkode, ttl_hours=FINN_CACHE_TTL_AD_HOURS)
if cached:
return cached
ad = await ad_module.fetch_ad_details(finnkode)
cache.save_finn_ad(conn, ad)
return ad
17.6 The shared formatting.py module
Output formatting (JSON / markdown / table) is shared between CLI (--format) and MCP (response_format). Centralize all renderers here:
# finn_eiendom/formatting.py
def render_ad(ad: FinnAd, fmt: str) -> str: ...
def render_shortlist(result: dict, fmt: str) -> str: ...
def render_comparison(result: dict, fmt: str) -> str: ...
def render_diff(result: dict, fmt: str) -> str: ...
def render_similar_units(units: list[SimilarUnit], fmt: str) -> str: ...
def render_unit(unit: EiendomUnit, fmt: str) -> str: ...
def render_score_breakdown(scores: dict, fmt: str) -> str: ...
CLI and MCP both call these. Neither has its own renderer. fmt accepts "json", "markdown", "table" (only where table makes sense). Unsupported values raise ValueError with a list of supported formats.
17.7 Adding a new feature — the checklist
For any new tool / command / behavior:
- Decide the home. Use the table in §17.2.
- Write the service function in
service.py(or extendanalysis.pyif it's pure orchestration of existing services). - Add a test for the service function in
tests/test_service.py. - Add the MCP tool in
mcp_server.py— thin wrapper,response_formataware. - Add the CLI command in
cli.py— thin wrapper,--formataware. - Add formatter in
formatting.pyif output is non-trivial. - Add a test for the MCP tool registration in
tests/test_mcp_server.py. - Add a test for the CLI command in
tests/test_cli.py. - Update docs — README and the relevant
.github/instructions/*.mdif new patterns are introduced.
If step 4 or 5 needs more than ~20 lines, you've put logic in the wrong layer. Push it down.
17.8 Acceptable duplication
A few small repetitions are tolerated to keep boundaries clean:
- Trivial
model_dump()/model_dump_json()calls at MCP and CLI boundaries. try/except → format errorblocks at each MCP tool (kept identical via a helper if it grows).- Pydantic input schema declarations at each MCP tool (they document the tool).
Anything beyond a handful of lines is duplication and goes into a helper.
18. Workflows
A. Analyze FINN search
Input: FINN search URL
Steps:
1. Normalize URL.
2. Check search-page cache (TTL 60min).
3. Fetch page 1, parse cards.
4. If max_pages > 1, fetch page 2..N.
5. Deduplicate by finnkode.
6. Record a search_run.
7. Pre-score from card data.
8. Select top N for detail fetch.
9. Run workflow B for each.
10. Score + classify each.
11. Sort by total score.
12. Persist scores; persist shortlist snapshot.
13. Return shortlist + summary.
B. Fetch and parse FINN listing
Input: finnkode
Steps:
1. Build https://www.finn.no/realestate/homes/ad.html?finnkode={n}.
2. Check finn_ads cache (TTL 24h).
3. Fetch HTML, parse with ad.scrape_ad().
4. Normalize numbers/areas/dates via parser.py.
5. save_finn_ad().
Output: FinnAd.
C. Eiendom.no enrichment
Input: FINN listing URL or finnkode
Steps:
1. Build full FINN URL.
2. Cache check on unit search.
3. eiendom_no.search_unit_from_finn_url().
4. Pick best match.
5. Save unitCode on the ad.
6. Cache check on unit detail.
7. eiendom_no.get_unit(unitCode).
8. save_eiendom_unit().
9. Compute FINN-vs-Eiendom.no mismatch warnings.
Output: EiendomUnit + mismatch list (or unavailable).
D. Build unit_vector
Input: EiendomUnit
Steps:
1. Extract lon/lat from geometry.
2. propertyType → ptype.
3. floor / rooms / constructionYear / usableArea.
4. Choose price: listingPrice → estimatedSellingPrice → FINN total_price.
5. msgpack.packb + urlsafe_b64encode (strip "=").
6. Persist unit_vector on eiendom_units.
Output: unit_vector + payload.
E. Fetch similar-units / comps
Input: unitCode, listing_status=RECENTLY_SOLD
Steps:
1. Load EiendomUnit; ensure unit_vector exists.
2. Cache check on similar_units.
3. eiendom_no.get_similar_units(unit_vector).
4. Normalize and filter locally:
RECENTLY_SOLD → saleStatus=SOLD and finalizedAt is set
FOR_SALE → saleStatus=FORSALE
5. Compute summary: count, avg/median selling price, avg sqm price, avg DOM.
6. save_similar_units().
Output: similar_units[] + comps_summary + confidence.
F. Score property
Input: FinnAd, EiendomUnit, similar_units, user_prefs, feedback
Steps:
1. economy / market / comparable / location / layout / outdoor / hybel / renovation / risk.
2. Clamp total to 0–100.
3. Assign categories.
4. Build explanation: why_interesting, risks, next_steps, broker_questions.
Output: scores dict + categories + summary.
G. Find similar to liked
Input: finnkode with verdict=liked
Steps:
1. Load FinnAd.
2. Ensure Eiendom.no enrichment + unit_vector.
3. Fetch similar-units (prefer FOR_SALE).
4. Score candidates against user preferences.
5. Return ranked recommendations.
H. Analyze one listing against comps
Input: finnkode
Steps:
1. workflow B → enrich (C) → comps (E, RECENTLY_SOLD).
2. Compare listing price vs comp avg/median; sqm price vs comp avg.
3. Compute confidence and classify cheap/fair/expensive.
Output: price_position, sqm_price_position, comparable_score, confidence, comps_summary, warnings.
I. Detect new / removed / changed listings
Input: FINN search URL
Steps:
1. workflow A (no detail fetch needed).
2. Compare finnkoder against previous search_run for same normalized_url.
3. For changed ads, diff price/common_costs/status.
4. Optionally workflow B on new + changed only.
Output: new_ads[], removed_ads[], changed_ads[].
J. Feedback loop
Input: finnkode + verdict + notes
Steps:
1. INSERT into feedback.
2. Update ad status.
3. If verdict=liked: mark as seed for similar-to-liked recommendations.
4. If verdict=rejected: store rejection reason.
5. Future analyses use feedback as a soft preference signal.
K. Compare multiple listings
Input: finnkoder[]
Steps:
1. workflow B + C for each.
2. Optionally workflow E.
3. Build comparison table.
4. Identify winners by category: best value / lifestyle / hybel / bargain / safest / highest risk / most overpriced.
Output: comparison_table + winners_by_category + recommendation + risks + broker_questions.
19. Output formats
19.1 Shortlist item
1. [Title/address] – Score 84/100
Category: Bargain candidate
Price: 7,200,000 total / 77 m² / 93,500 NOK per m²
Eiendom.no: Estimate 7,650,000 / range 6,900,000–8,400,000
Comps: 12 similar recently sold / avg 98,000 NOK per m²
Why interesting:
- Good size for price.
- Balcony and view.
- Renovation need may reduce competition.
- Flexible layout.
- Price looks low vs estimate and comps.
Risks:
- Check wet rooms in condition report.
- Common costs need review.
- Hybel potential is not documented.
- Comparable confidence is medium.
Next steps:
- Open listing.
- Read condition report.
- Check FINN vs Eiendom.no mismatches.
- Ask broker about planned cost increases.
- Consider viewing.
19.2 Analysis summary
Analyzed 83 listings.
Fetched details for 20.
Eiendom.no-enriched 18.
Fetched similar-units for 7 shortlisted listings.
Shortlisted 8.
Best bargain candidate: ...
Best safe candidate: ...
Best hybel candidate: ...
Best price vs estimate: ...
Best price vs comps: ...
Highest risk: ...
Most overpriced: ...
20. Configuration
| Variable | Default | Purpose |
|---|---|---|
FINN_CACHE_PATH |
data/finn.sqlite |
SQLite DB path |
FINN_MAX_SEARCH_PAGES |
3 |
Max search pages |
FINN_DETAIL_LIMIT |
20 |
Max detailed listings per run |
FINN_REQUEST_DELAY_SECONDS |
2 |
Delay between FINN requests |
FINN_USER_AGENT |
personal-finn-eiendom-analyzer/0.1 |
HTTP User-Agent |
FINN_CACHE_TTL_SEARCH_MINUTES |
60 |
Search cache TTL |
FINN_CACHE_TTL_AD_HOURS |
24 |
Listing cache TTL |
EIENDOM_NO_ENABLED |
true |
Enable Eiendom.no enrichment |
EIENDOM_NO_BASE_URL |
https://api.eiendom.no/api/v1 |
API base URL |
EIENDOM_NO_CACHE_TTL_HOURS |
24 |
Unit/similar cache TTL |
EIENDOM_NO_REQUEST_DELAY_SECONDS |
1 |
Delay between Eiendom.no calls |
EIENDOM_NO_SIMILAR_UNITS_ENABLED |
true |
Enable similar-units |
EIENDOM_NO_SIMILAR_UNITS_DEFAULT_STATUS |
RECENTLY_SOLD |
Default comps status |
HJEMLA_ENABLED |
false |
Enable optional Hjemla API |
LOG_LEVEL |
INFO |
Logging level |
MCP_TRANSPORT |
stdio |
stdio or streamable_http |
MCP_HTTP_HOST |
127.0.0.1 |
Streamable HTTP bind |
MCP_HTTP_PORT |
8010 |
Streamable HTTP port |
21. Deployment
The default runtime is a project-local virtualenv. Docker is supported but optional.
21.1 Local install (default)
# in the project root
uv venv # or: python3.12 -m venv .venv
source .venv/bin/activate
uv pip install -e ".[dev]" # or: pip install -e ".[dev]"
# now available:
finn-eiendom --help # CLI
finn-eiendom-mcp # MCP server over stdio
finn-eiendom serve --transport http --port 8010 # MCP server over HTTP
pytest # tests
ruff check . # lint
For a global CLI install:
uv tool install .
# or
pipx install .
21.2 Claude Desktop integration (stdio)
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"finn-eiendom": {
"command": "/Users/ole/code/finn-mcp/.venv/bin/finn-eiendom-mcp",
"args": [],
"env": {
"FINN_CACHE_PATH": "/Users/ole/code/finn-mcp/data/finn.sqlite",
"EIENDOM_NO_ENABLED": "true"
}
}
}
}
Or, with uv from the project root:
{
"mcpServers": {
"finn-eiendom": {
"command": "uv",
"args": ["run", "finn-eiendom-mcp"],
"cwd": "/Users/ole/code/finn-mcp"
}
}
}
21.3 Docker Compose (optional)
services:
finn-eiendom-mcp:
build: .
container_name: finn-eiendom-mcp
restart: unless-stopped
ports:
- "8010:8010"
environment:
FINN_CACHE_PATH: /data/finn.sqlite
EIENDOM_NO_ENABLED: "true"
EIENDOM_NO_SIMILAR_UNITS_ENABLED: "true"
MCP_TRANSPORT: streamable_http
MCP_HTTP_HOST: 0.0.0.0
MCP_HTTP_PORT: "8010"
volumes:
- ./data:/data
command: ["finn-eiendom", "serve", "--transport", "http", "--host", "0.0.0.0", "--port", "8010"]
21.4 Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends gcc \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml .
COPY finn_eiendom ./finn_eiendom
RUN pip install --no-cache-dir .
EXPOSE 8010
CMD ["finn-eiendom-mcp"]
22. MVP scope
Must have
- Local venv install (
uv venv+pip install -e .[dev]). - Python core package with all modules listed in §12.1.
service.pywithget_or_fetch_*helpers.formatting.pyshared between CLI and MCP.- SQLite cache/history (existing schema retained,
search_runs+scores+feedbackadded). - FastMCP server with all tools in §14.1 except
finn_compare_ads(deferred to "should have"). - CLI with all commands in §15.1 except
serve --transport httpandcache clear-*variants (deferred). - FINN search + listing extraction.
- Eiendom.no enrichment enabled by default.
unit_vectorbuild + decode.- Similar-units/comps with local filtering.
- Scoring on all nine components with category assignment.
- Feedback storage.
- Shortlist output with reasons, risks, next steps, broker questions.
- Pydantic v2 models with
model_config(no v1Config). - HTTP retry on 5xx in addition to connection errors.
- MCP entry-point registered in
pyproject.toml. - README +
.github/instructions/*.mddescribing the architecture and ownership rules.
Should have
- Pagination.
- Price per m² across the board.
- Component score breakdown in output.
- Generated broker questions.
finn_get_new_ads_since_last_run/finn-eiendom diff.finn_compare_ads/finn-eiendom compare.- Feedback-based scoring adjustment.
finn_find_similar_to_liked_ad/finn-eiendom similar-to-liked.- CLI
--format markdown+--format table. - CLI
serve --transport http. - CLI
cache stats|clear|clear-html|clear-json.
Later
- Web UI / dashboard.
- n8n workflow templates.
- PDF condition-report analysis.
- Geocoding / travel-time / sun / noise overlays.
- Push notifications.
- Price-drop monitoring.
- LLM-based listing-text scoring.
- Optional Hjemla integration.
23. Roadmap
Phase 0 — Spike (largely done)
- Parse one FINN search result, extract finnkoder, parse 3–5 listings.
- Resolve FINN URL → Eiendom.no
unitCode, fetch unit detail, generateunit_vector, fetch similar-units withRECENTLY_SOLD.
Phase 1 — Core MVP (mostly done)
- Stable parser, SQLite cache, Eiendom.no enrichment, similar-units/comps, basic scoring.
- Fixture-based tests for parsers, cache, scoring.
Phase 2 — MCP / CLI MVP (this PRD)
- Replace FastAPI with FastMCP stdio server.
- Add
service.pyandformatting.py. - Add
cli.py(typer) and__main__.py. - Wire MCP tools and CLI commands into the service + formatting layers.
- Pydantic v2
model_configcleanup. - HTTP retry on 5xx.
- New tests:
tests/test_service.py, expandedtests/test_mcp_server.py, newtests/test_cli.py, newtests/test_http.py, newtests/test_formatting.py, newtests/test_architecture.py. - Switch from Docker-only workflow to local venv as default; keep Docker as an optional packaging path.
Phase 3 — Personal scoring v2
- Tighter user-preference weights, stronger bargain/risk/hybel logic, better confidence handling, generated broker questions.
Phase 4 — Agent / workflow
- Cron / scheduled runs, diff notifications, n8n templates, Slack/Discord output.
Phase 5 — Dashboard
- React/TanStack UI for shortlist, feedback, comps, history.
24. Acceptance criteria
A1. MCP server
Given a fresh local venv install, finn-eiendom-mcp starts via mcp.run(transport="stdio") without error. Running mcp dev finn_eiendom/mcp_server.py shows all tools listed in §14.1.
A2. CLI
Given pip install -e ., finn-eiendom --help lists every command in §15.1. Each command runs end-to-end against cached fixtures with no live network calls and produces JSON, markdown, or table output as requested via formatting.py.
A3. Search analysis
Given a valid FINN search URL, service.analyze_search() returns a ranked shortlist sorted by total score, with at least the fields: summary, shortlist, search_url. Cards are deduplicated by finnkode. Identical reruns within the search-cache TTL are served from cache.
A4. Listing detail
Given a valid finnkode, service.get_or_fetch_ad() returns a FinnAd with at least finnkode, url, title, address, total_price, area_m2, listing_description. Missing fields are None, not raised. Subsequent calls within the TTL hit the cache.
A5. Feedback
Given a finnkode and verdict, service.save_feedback() writes a feedback row. liked verdicts are surfaced by service.find_similar_to_liked().
A6. Eiendom.no enrichment
Given a FINN listing URL, the system resolves a unitCode, fetches the unit detail, stores estimate / coordinates / area / rooms / year / market data, and uses them in scoring. Enrichment failures degrade gracefully — the eiendom_unit field is None in the result, no exception escapes the service.
A7. Similar-units
Given a unitCode, the system builds (or loads) a cached unit_vector, calls similar-units with the requested listing_status, returns structured comps, caches the result, and emits a comps summary with count, average price, average sqm price.
A8. Pydantic v2
FinnAd, EiendomUnit, SimilarUnit use model_config = ConfigDict(...). No class Config: blocks remain.
A9. HTTP retry
HTTPClient.get() retries 5xx responses with exponential backoff (1s, 2s, 4s) up to retries attempts, and surfaces 4xx as httpx.HTTPStatusError immediately.
A10. No-duplication / architecture invariants
A static check (tests/test_architecture.py) verifies:
- No
import httpxoutsidefinn_eiendom/http.py. - No
import sqlite3outsidefinn_eiendom/cache.py. - No
BeautifulSoupimport outsidefinn_eiendom/search.pyorfinn_eiendom/ad.py. - No
msgpackimport outsidefinn_eiendom/eiendom_no.py. mcp_server.pyonly imports fromservice,formatting,models,config, and stdlib +mcp.cli.pyonly imports fromservice,formatting,models,config, and stdlib +typer.
A11. Tooling
ruff check . returns zero issues. pytest passes. mypy --strict finn_eiendom passes (or is documented as a known gap).
25. Test strategy
25.1 Unit tests
tests/test_parser.py— number/date/URL/finnkode normalization.tests/test_search.py— FINN search HTML → cards.tests/test_ad.py— FINN listing HTML → FinnAd.tests/test_eiendom_no.py— unit search/detail/similar JSON parsers,unit_vectorencode/decode.tests/test_scoring.py— all scoring components + classifier.tests/test_cache.py— read/write/TTL behavior.
25.2 Service tests (new)
tests/test_service.pytest_get_or_fetch_ad_uses_cachetest_get_or_fetch_ad_fetches_when_cache_misstest_get_or_fetch_ad_force_refreshtest_analyze_search_with_fixturestest_find_similar_to_liked_uses_liked_feedback
25.3 MCP tests
tests/test_mcp_server.pytest_mcp_server_has_correct_toolstest_finn_decode_unit_vector_returns_jsontest_finn_analyze_search_handles_error
25.4 CLI tests (new)
Use Typer's CliRunner.
tests/test_cli.pytest_cli_helptest_cli_analyze_search_table_formattest_cli_get_ad_json_formattest_cli_save_feedback_persists_rowtest_cli_decode_vector
25.5 Formatting tests (new)
tests/test_formatting.pytest_render_shortlist_json_roundtripstest_render_shortlist_markdown_contains_scoretest_render_unsupported_format_raises_valueerror
25.6 HTTP tests (new)
Use respx.
tests/test_http.pytest_get_retries_on_500test_get_raises_on_404test_post_delay_applied
25.7 Architecture tests (new)
tests/test_architecture.py— static import-graph checks listed in A10.
25.8 Manual / smoke tests
finn-eiendom doctorruns.- Real FINN URL run; compare top-3 with manual judgment.
- Save 5 feedback rows; rerun; verify scoring shift.
- Mark one ad liked; run
similar-to-liked; sanity-check candidates.
26. Logging, safety, compliance
Log: start/end of analysis, pages/listings/details fetched, Eiendom.no enrichments attempted/found/failed, similar-units attempted/found/failed, cache hits/misses, parse errors, request errors, debug-level scoring details.
Safety / compliance:
- Private, low-frequency, user-triggered use only.
- Configurable request delays and User-Agent.
- Cache aggressively to minimize requests.
- No public redistribution of FINN/Eiendom.no data.
- No public exposure without auth — prefer LAN / Tailscale / reverse proxy.
- Scores, estimates, and comps are decision support, not official valuation, legal, or technical advice.
- stdio MCP servers must log to stderr only (
logging.basicConfig(stream=sys.stderr, ...)).
27. Risks & mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| FINN HTML changes | Parser breaks | Fixture tests, resilient selectors |
| Eiendom.no API/JSON changes | Enrichment/comps break | JSON fixtures, graceful fallback |
| Unit-vector format changes | Similar-units breaks | Unit tests, fall back to cache, mark unavailable |
| Too many requests | Blocking / unwanted load | Delay, cache, low-frequency use |
| Bad scoring | Poor recommendations | Explain score and uncertainty |
| Legal/technical interpretation wrong | Bad decisions | Present as broker questions, not facts |
| User overtrusts score | Missed risks | Always show risks and next steps |
| Public MCP exposure | Misuse | LAN / Tailscale / auth-only |
| stdio server writes to stdout | Breaks JSON-RPC frame | Configure logging to stderr; architecture test |
| Duplication of logic | Drift between MCP/CLI/library | Code-ownership table + architecture tests |
28. Open questions
- Should
service.pyopen one sharedsqlite3.Connectionper process or one per call? (current code opens per call — fine but worth measuring.) - Store raw HTML permanently or only parsed output? Default: only parsed, raw HTML under TTL.
- How aggressively to detail-fetch in
analyze_search? Default: top 20 cards. - Hardcode scoring weights or expose via YAML / env? Default: hardcoded for MVP; YAML in Phase 3.
- Should feedback affect scoring in MVP, or only be stored? Default: stored only; soft signal in Phase 3.
- Multiple scoring profiles (lifestyle / bargain / hybel / safe)? Default: single profile in MVP.
- Permanently store Eiendom.no data or TTL only? Default: TTL only; review later.
- How to handle FINN-vs-Eiendom.no mismatches (area, price)? Default: store both, surface as warning, never silently overwrite.
- Which
listing_statusvalues does similar-units accept server-side? Verify in spike before relying on it. - Should recommendations use only
likedlistings, or also high-scoring listings without feedback? Default: liked only. - Should
serve --transport httpship in MVP? Default: yes for cron/n8n users; stdio still default for Claude Desktop.
29. First implementation plan (Phase 2)
Step by step, each step independently mergeable.
- Switch dev workflow to local venv. Update
AGENTS.md,copilot-instructions.md,python.instructions.md,tests.instructions.md. Addclean-code.instructions.md,cli.instructions.md, anddocs.instructions.md. - Pydantic v2 cleanup — replace
class Configwithmodel_config = ConfigDict(...)inmodels.py. Add roundtrip test. - Service layer — create
finn_eiendom/service.pywithget_or_fetch_*and orchestration helpers. Addtests/test_service.py. - Formatting layer — create
finn_eiendom/formatting.pywith allrender_*helpers. Addtests/test_formatting.py. - HTTP retry — extend
HTTPClient.get()with 5xx retry + exponential backoff. Addtests/test_http.py. - Replace FastAPI with FastMCP — rewrite
finn_eiendom/mcp_server.pyagainstservice.py+formatting.py. Add stdiomain(). Add[project.scripts]entryfinn-eiendom-mcp. Expandtests/test_mcp_server.py. - CLI — create
finn_eiendom/cli.py(typer) andfinn_eiendom/__main__.py. Add[project.scripts]entryfinn-eiendom. Addtests/test_cli.py. - Diff workflow — implement
search_runstable +service.get_new_ads_since_last_run+ matching MCP tool + CLIdiffcommand. - Compare workflow — implement
service.compare_ads+ MCP tool + CLIcomparecommand. - Similar-to-liked — implement
service.find_similar_to_liked+ MCP tool + CLIsimilar-to-likedcommand. - Architecture tests —
tests/test_architecture.pyenforcing A10. - README + Claude Desktop config — document install paths for both CLI and MCP using local venv.
Definition of done for the whole phase:
finn-eiendom-mcpboots over stdio with all tools listed.finn-eiendom --helplists every command in §15.1.pytestis green, including newtest_service.py,test_cli.py,test_http.py,test_formatting.py,test_architecture.py.ruff check .is clean.- README documents Claude Desktop config and a CLI quickstart using local venv.
- All acceptance criteria in §24 pass.
30. Final product statement
Build a compact, private, self-hosted property analysis platform whose source of truth is a typed Python library, and whose user-facing surfaces are (a) an MCP server for LLM agents, (b) a CLI for terminals and cron, and (c) a Python API for tests and notebooks. All three share the same service layer, the same formatting layer, and the same SQLite cache.
The MVP does one thing well:
FINN search in → relevant property candidates out, enriched with Eiendom.no estimates, similar-units, explanation, risk, and next steps.