CLI tool and MCP server that monitors boligportal.dk for new rental listings in Denmark. Polls the same public search API the website uses — no account or API key required.
- Python 3.10+
- CLI mode works with stdlib only (zero required dependencies)
pip install curl_cffi— recommended, bypasses Cloudflare bot protection (see Cloudflare bypass)pip install mcpfor MCP server mode
# Clone the repository
git clone https://github.com/your-username/boligwatch.git
cd boligwatch
# (Optional) Create and activate a virtual environment
python3 -m venv .venv
source .venv/bin/activate
# CLI mode works out of the box with stdlib only
python boligwatch.py --help
# Recommended: install curl_cffi to bypass Cloudflare bot protection
pip install curl_cffi
# For MCP server mode, install the MCP SDK
pip install mcp
# Generate a config file and edit it to match your search
python boligwatch.py --init-config# Run once — shows new listings (no filters unless config file provides them)
python boligwatch.py
# Use a saved config for your regular search
python boligwatch.py --config boligwatch_config.json
# Watch mode — poll every 5 minutes
python boligwatch.py --config boligwatch_config.json --watch
# Ad-hoc search with inline filters
python boligwatch.py --city københavn --rooms-min 3 --max-rent 16000 --balconyBoligWatch fetches listings from boligportal.dk's search API, filters them according to your criteria, and tracks which ones you've already seen in a local JSON file. On each run it only shows new listings — ones not previously seen.
The tracker also detects re-listed apartments: if a landlord re-publishes a listing with the same ID but a newer advertised_date, it resurfaces as new. This prevents missed opportunities when listings are taken down and re-posted. The seen file stores the advertised_date alongside the seen timestamp for comparison. Legacy entries (from older versions) are read transparently — re-listing detection is skipped for entries without a stored advertised_date.
There are three ways to use it:
- CLI — run once or in watch mode, pipe JSON to other tools
- MCP server — expose search and tracking as tools for Claude Code, Claude Desktop, or any MCP client
- Skill — a ready-made skill that teaches Claude to translate natural-language apartment queries into MCP tool calls
| Flag | Description |
|---|---|
| (default) | Run once, print new listings, mark them as seen |
--watch, -w |
Poll continuously (default: every 300s) |
--interval N, -i N |
Poll interval in seconds (used with --watch) |
--json |
Output new listings as a JSON array, mark as seen |
--peek |
Output new listings as JSON, do NOT mark as seen (retry-safe) |
--mcp |
Start as MCP server (stdio transport) |
| Flag | Description |
|---|---|
--mark-seen ID [ID ...] |
Mark specific listing IDs as seen |
--reset |
Clear all seen-listing history before running |
--seen-file PATH |
Custom path for the seen-listings tracker (default: .boligwatch_seen.json) |
| Flag | Description |
|---|---|
--config PATH, -c PATH |
Path to a JSON config file |
--init-config |
Generate a config template at --config path or default location |
--log-file PATH |
Write log to file |
--verbose, -v |
Verbose (DEBUG-level) logging |
All filters default to no limit (unset). See Filter behavior for how filters interact with the config file.
| Flag | Type | Default | Description |
|---|---|---|---|
--city NAME |
string (repeatable) | ["k\u00f8benhavn"] |
City to search. Can be repeated: --city k\u00f8benhavn --city frederiksberg |
--bbox S,W,N,E |
floats | none | Bounding box as min_lat,min_lng,max_lat,max_lng. Replaces --city when set |
City names use lowercase Danish with original characters: k\u00f8benhavn, frederiksberg, aarhus, odense, aalborg.
| Flag | Type | Default | Description |
|---|---|---|---|
--rooms-min N |
int | none | Minimum number of rooms |
--rooms-max N |
int | none | Maximum number of rooms |
--max-rent N |
int | none | Maximum monthly rent in DKK |
--min-size N |
int | none | Minimum size in m\u00b2 |
--min-rental-period N |
int | none | Minimum lease length in months (12 = 1 year) |
--max-available-from DATE |
YYYY-MM-DD | none | Latest move-in date |
| Flag | Type | Default | Description |
|---|---|---|---|
--newbuild |
bool | none | Only new-build / project rentals (projektudlejning) |
--social-housing |
bool | none | Only social housing (almen bolig) |
| Flag | Type | Default | Description |
|---|---|---|---|
--pet-friendly |
bool | none | Only pet-friendly listings |
--senior-friendly |
bool | none | Only senior-friendly listings |
--student-only |
bool | none | Only student housing |
--shareable |
bool | none | Only shareable apartments (delevenlig) |
| Flag | Type | Default | Description |
|---|---|---|---|
--parking |
bool | none | Must have parking |
--elevator |
bool | none | Must have elevator |
--balcony |
bool | none | Must have balcony or terrace |
--ev-charging |
bool | none | Must have EV charging station (ladestander) |
| Flag | Type | Default | Description |
|---|---|---|---|
--furnished |
bool | none | Must be furnished |
--dishwasher |
bool | none | Must have dishwasher |
--washing-machine |
bool | none | Must have washing machine |
--dryer |
bool | none | Must have dryer |
| Flag | Type | Default | Description |
|---|---|---|---|
--max-pages N |
int | 5 |
Maximum pages to fetch (18 listings per page, max 50) |
# Pet-friendly 2-room apartments in Copenhagen under 12.000kr
python boligwatch.py --city k\u00f8benhavn --rooms-min 2 --rooms-max 2 --max-rent 12000 --pet-friendly
# Large apartments (100m2+) with balcony, available by August
python boligwatch.py --city k\u00f8benhavn --min-size 100 --balcony --max-available-from 2026-08-01
# Furnished places with dishwasher and washing machine in Frederiksberg
python boligwatch.py --city frederiksberg --furnished --dishwasher --washing-machine
# Social housing anywhere in the Copenhagen bounding box
python boligwatch.py --bbox 55.63,12.48,55.73,12.80 --social-housing
# New-build project rentals with EV charging and elevator
python boligwatch.py --city k\u00f8benhavn --newbuild --ev-charging --elevator
# Peek at new listings as JSON (safe to retry, doesn't mark seen)
python boligwatch.py --config boligwatch_config.json --peek
# Watch mode: poll every 2 minutes with a saved config
python boligwatch.py --config boligwatch_config.json --watch --interval 120
# Mark specific listings as seen
python boligwatch.py --mark-seen 5619969 2987892
# Pipe to jq for processing
python boligwatch.py --peek | jq '.[].url'A config file saves your default search criteria so you don't have to pass CLI flags every time. CLI flags always override config values.
python boligwatch.py --init-config
# or specify a path:
python boligwatch.py --init-config --config my_search.jsonAll fields are optional. Omit a field or set it to null for no limit. Unknown keys are logged as warnings to help catch typos.
{
"categories": ["rental_apartment", "rental_house", "rental_townhouse"],
"city_level_1": ["k\u00f8benhavn"],
"city_level_2": null,
"min_lat": null,
"min_lng": null,
"max_lat": null,
"max_lng": null,
"rooms_min": 3,
"rooms_max": null,
"max_rent": 17000,
"min_size_m2": null,
"min_rental_period": 12,
"max_available_from": null,
"pet_friendly": null,
"balcony": null,
"furnished": null,
"parking": null,
"elevator": null,
"shareable": null,
"student_only": null,
"senior_friendly": null,
"social_housing": null,
"newbuild": null,
"electric_charging_station": null,
"dishwasher": null,
"washing_machine": null,
"dryer": null,
"order": "DEFAULT",
"max_pages": 5
}The categories field controls which property types to include:
| Value | Danish | Description |
|---|---|---|
rental_apartment |
Lejligheder | Apartments |
rental_room |
V\u00e6relser | Rooms |
rental_house |
Huse | Houses |
rental_townhouse |
R\u00e6kkehuse | Townhouses |
You can filter by city name or bounding box, but not both. When bounding box coordinates are set, city filters are ignored.
- City:
"city_level_1": ["k\u00f8benhavn", "frederiksberg"] - Bounding box: Set all four of
min_lat,min_lng,max_lat,max_lng - Sub-city:
"city_level_2": ["amager"](narrows within city_level_1)
BoligWatch runs as a local MCP server, exposing search and tracking as tools for any MCP client.
Install the MCP SDK (the CLI works without it):
pip install mcpAdd to your MCP client config (.mcp.json, Claude Desktop config, etc.):
{
"mcpServers": {
"boligwatch": {
"command": "python3",
"args": [
"/path/to/boligwatch.py", "--mcp",
"--config", "/path/to/boligwatch_config.json"
]
}
}
}| Tool | Description |
|---|---|
search_listings |
Search with filters, returns all matches as JSON |
get_new_listings |
Like search, but only returns listings not previously seen (peek by default) |
mark_seen |
Mark listing IDs as seen |
reset_seen |
Clear all seen-listing history |
get_seen_stats |
Get tracker statistics (count, file path) |
The same logic applies to both CLI and MCP:
- No filters passed = uses the full saved search from your config file (your monitoring query)
- Any filter passed = starts from a clean slate. Only structural settings (categories, location, order, max_pages) carry over from the config. Restrictive filters (rent, rooms, size, features) are stripped — if you ask "find apartments over 200m\u00b2" it won't silently cap at 17,000 kr because of your config.
This means python boligwatch.py --peek uses your saved search, but python boligwatch.py --min-size 200 won't silently cap at 17,000 kr from the config. Same for MCP: get_new_listings({}) = saved search, search_listings({min_size_m2: 200}) = clean slate.
Both search_listings and get_new_listings accept the same filter parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
cities |
string[] |
from config | City names (e.g. ["k\u00f8benhavn"]) |
min_lat |
float |
from config | Bounding box south latitude |
min_lng |
float |
from config | Bounding box west longitude |
max_lat |
float |
from config | Bounding box north latitude |
max_lng |
float |
from config | Bounding box east longitude |
rooms_min |
int |
no limit | Minimum rooms |
rooms_max |
int |
no limit | Maximum rooms |
max_rent |
int |
no limit | Maximum monthly rent (DKK) |
min_size_m2 |
int |
no limit | Minimum size (m\u00b2) |
min_rental_period |
int |
no limit | Minimum lease (months) |
max_available_from |
string |
no limit | Latest move-in date (YYYY-MM-DD) |
pet_friendly |
bool |
no filter | Pet-friendly only |
balcony |
bool |
no filter | Has balcony/terrace |
furnished |
bool |
no filter | Furnished only |
parking |
bool |
no filter | Has parking |
elevator |
bool |
no filter | Has elevator |
shareable |
bool |
no filter | Shareable (delevenlig) |
student_only |
bool |
no filter | Student-only |
senior_friendly |
bool |
no filter | Senior-friendly |
social_housing |
bool |
no filter | Social housing (almen bolig) |
newbuild |
bool |
no filter | New-build / project rental |
electric_charging_station |
bool |
no filter | EV charging |
dishwasher |
bool |
no filter | Has dishwasher |
washing_machine |
bool |
no filter | Has washing machine |
dryer |
bool |
no filter | Has dryer |
max_pages |
int |
5 |
Pages to fetch (18 per page, max 50) |
get_new_listings also accepts:
| Parameter | Type | Default | Description |
|---|---|---|---|
mark_as_seen |
bool |
false |
If true, mark returned listings as seen |
Search for 3+ room apartments in Copenhagen under 16.000kr:
{
"tool": "search_listings",
"arguments": {
"cities": ["k\u00f8benhavn"],
"rooms_min": 3,
"max_rent": 16000
}
}Find furnished apartments with washing machine and dishwasher:
{
"tool": "search_listings",
"arguments": {
"cities": ["k\u00f8benhavn"],
"furnished": true,
"washing_machine": true,
"dishwasher": true
}
}Large apartments (150m\u00b2+) anywhere in the search area:
{
"tool": "search_listings",
"arguments": {
"min_size_m2": 150,
"max_pages": 10
}
}New-build projects with EV charging, available by September:
{
"tool": "search_listings",
"arguments": {
"newbuild": true,
"electric_charging_station": true,
"max_available_from": "2026-09-01"
}
}Check for unseen listings (peek mode):
{
"tool": "get_new_listings",
"arguments": {}
}Check for new listings and mark them as seen:
{
"tool": "get_new_listings",
"arguments": {
"mark_as_seen": true
}
}Mark specific listings as reviewed:
{
"tool": "mark_seen",
"arguments": {
"ids": [5619969, 2987892]
}
}A ready-made skill is included at skills/bolig-watch/SKILL.md that teaches Claude to translate natural-language apartment queries into MCP tool calls.
Once the MCP server and skill are configured, you can ask Claude things like:
Find me a 3-room apartment in Copenhagen under 15.000kr
Are there any pet-friendly places with a balcony in Frederiksberg?
Show me the largest apartments available right now
Any new listings since last time?
Find furnished apartments with a washing machine, at least 70m2
What's available in Amager for under 12k with at least 2 rooms?
Show me new-build projects with EV charging and elevator
Find social housing apartments in Copenhagen available before August
I've looked at those listings, mark them as seen
How many listings have I seen so far?
Use Claude Code's loop command to have Claude poll for new listings automatically:
/loop 5m check for new boligportal listings. Summarize anything new.
Combine BoligWatch with Playwright MCP and the Playwright MCP Bridge Chrome extension. Together, they let Claude control your actual browser with your logged-in boligportal.dk session.
This means Claude can navigate to listings, read details, contact landlords, fill in forms, and send messages.
- Install the Playwright MCP Bridge extension in Chrome
- Add both MCP servers to your config:
{
"mcpServers": {
"boligwatch": {
"command": "python3",
"args": ["/path/to/boligwatch.py", "--mcp", "--config", "/path/to/config.json"]
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--extension"]
}
}
}/loop 5m check for new boligportal listings with the boligwatch MCP.
For each new listing, open it in the browser with Playwright, save a PDF,
contact the landlord with a message in Danish, and notify me on Slack.
Because the extension bridges into your existing browser session, Claude authenticates as you — no separate login flow, no stored credentials.
Boligportal.dk uses Cloudflare bot protection, which can block requests from standard HTTP clients like Python's urllib with an HTTP 403 and a JavaScript challenge. When this happens, the API becomes unreachable.
Installing curl_cffi enables Chrome TLS fingerprint impersonation, which bypasses the challenge transparently:
pip install curl_cffiWhen curl_cffi is installed, BoligWatch automatically uses it for all API requests. When it's not installed, BoligWatch falls back to stdlib urllib — which works fine when Cloudflare isn't actively challenging requests.
Both backends retry on HTTP 403, 429, and 5xx errors with exponential backoff.
Each listing returned by the API includes:
| Field | Type | Description |
|---|---|---|
id |
int | Unique listing ID |
url |
string | Direct link to the listing |
title |
string | Listing title |
city |
string | City name |
city_area |
string | Sub-area (e.g. "K\u00f8benhavn S", "Amager") |
postal_code |
string | Postal code |
street_name |
string | Street name |
street_number |
string | Street number |
rooms |
float | Number of rooms |
size_m2 |
float | Size in square meters |
monthly_rent |
float | Monthly rent |
monthly_rent_currency |
string | Currency (typically "kr") |
deposit |
float | Deposit amount |
prepaid_rent |
float | Prepaid rent |
available_from |
string | Move-in date (YYYY-MM-DD) |
advertised_date |
string | When the listing was posted |
category |
string | Property type |
energy_rating |
string | Energy label (e.g. "A2010", "C") |
features |
object | Boolean feature flags (pet_friendly, elevator, etc.) |