pyfunda 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- funda/__init__.py +20 -0
- funda/funda.py +463 -0
- funda/listing.py +138 -0
- pyfunda-2.0.0.dist-info/METADATA +445 -0
- pyfunda-2.0.0.dist-info/RECORD +6 -0
- pyfunda-2.0.0.dist-info/WHEEL +4 -0
funda/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Funda - Python API for Funda.nl real estate listings.
|
|
3
|
+
|
|
4
|
+
Example usage:
|
|
5
|
+
>>> from funda import Funda
|
|
6
|
+
>>> f = Funda()
|
|
7
|
+
>>> listing = f.get_listing(43117443)
|
|
8
|
+
>>> print(listing['title'], listing['price'])
|
|
9
|
+
Reehorst 13 695000
|
|
10
|
+
|
|
11
|
+
>>> results = f.search_listing('amsterdam', price_max=500000)
|
|
12
|
+
>>> for r in results[:3]:
|
|
13
|
+
... print(r['title'], r['city'])
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from funda.funda import Funda, FundaAPI
|
|
17
|
+
from funda.listing import Listing
|
|
18
|
+
|
|
19
|
+
__version__ = "2.0.0"
|
|
20
|
+
__all__ = ["Funda", "FundaAPI", "Listing"]
|
funda/funda.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Main Funda API class."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from funda.listing import Listing
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# API endpoints
|
|
12
|
+
API_BASE = "https://listing-detail-page.funda.io/api/v4/listing/object/nl"
|
|
13
|
+
API_LISTING = f"{API_BASE}/{{listing_id}}"
|
|
14
|
+
API_LISTING_TINY = f"{API_BASE}/tinyId/{{tiny_id}}"
|
|
15
|
+
API_SEARCH = "https://listing-search-wonen.funda.io/_msearch/template"
|
|
16
|
+
|
|
17
|
+
# Headers for mobile API
|
|
18
|
+
HEADERS = {
|
|
19
|
+
"user-agent": "Dart/3.9 (dart:io)",
|
|
20
|
+
"x-funda-app-platform": "android",
|
|
21
|
+
"content-type": "application/json",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
SEARCH_HEADERS = {
|
|
25
|
+
"user-agent": "Dart/3.9 (dart:io)",
|
|
26
|
+
"content-type": "application/x-ndjson",
|
|
27
|
+
"referer": "https://www.funda.nl/",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_area(value: str | None) -> int | None:
|
|
32
|
+
"""Parse area string like '200 m²' or '2.960 m²' to integer."""
|
|
33
|
+
if not value: # handles None and ''
|
|
34
|
+
return None
|
|
35
|
+
if isinstance(value, (int, float)):
|
|
36
|
+
return int(value)
|
|
37
|
+
# Remove ' m²' suffix and '.' thousand separator (Dutch locale)
|
|
38
|
+
cleaned = value.replace(' m²', '').replace('.', '')
|
|
39
|
+
return int(cleaned) if cleaned.isdigit() else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Funda:
|
|
43
|
+
"""Main interface to Funda API.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
>>> from funda import Funda
|
|
47
|
+
>>> f = Funda()
|
|
48
|
+
>>> listing = f.get_listing(43117443)
|
|
49
|
+
>>> print(listing['title'], listing['city'])
|
|
50
|
+
Reehorst 13 Luttenberg
|
|
51
|
+
>>> results = f.search_listing('amsterdam', price_max=500000)
|
|
52
|
+
>>> for r in results[:3]:
|
|
53
|
+
... print(r['title'], r['price'])
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
TINYID_PATTERN = re.compile(r"/(\d{7,9})/?(?:\?|$|#)")
|
|
57
|
+
|
|
58
|
+
def __init__(self, timeout: int = 30):
|
|
59
|
+
"""Initialize Funda API client.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
timeout: Request timeout in seconds
|
|
63
|
+
"""
|
|
64
|
+
self.timeout = timeout
|
|
65
|
+
self._session: requests.Session | None = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def session(self) -> requests.Session:
|
|
69
|
+
"""Lazily create HTTP session."""
|
|
70
|
+
if self._session is None:
|
|
71
|
+
self._session = requests.Session()
|
|
72
|
+
self._session.headers.update(HEADERS)
|
|
73
|
+
return self._session
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
"""Close the HTTP session."""
|
|
77
|
+
if self._session:
|
|
78
|
+
self._session.close()
|
|
79
|
+
self._session = None
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> "Funda":
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *args) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
# -------------------------------------------------------------------------
|
|
88
|
+
# Listing methods
|
|
89
|
+
# -------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def get_listing(self, listing_id: int | str) -> Listing:
|
|
92
|
+
"""Get a listing by ID or URL.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
listing_id: Numeric ID (globalId or tinyId) or full URL
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Listing object with property data
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
>>> f.get_listing(43117443)
|
|
102
|
+
>>> f.get_listing('https://www.funda.nl/detail/koop/city/house-123/43117443/')
|
|
103
|
+
"""
|
|
104
|
+
# If it's a URL, extract tinyId
|
|
105
|
+
if isinstance(listing_id, str) and 'funda.nl' in listing_id:
|
|
106
|
+
match = self.TINYID_PATTERN.search(listing_id)
|
|
107
|
+
if not match:
|
|
108
|
+
raise ValueError(f"Could not extract listing ID from URL: {listing_id}")
|
|
109
|
+
listing_id = match.group(1)
|
|
110
|
+
|
|
111
|
+
# Try tinyId endpoint first (8-9 digits), then globalId (7 digits)
|
|
112
|
+
listing_id_str = str(listing_id)
|
|
113
|
+
if len(listing_id_str) >= 8:
|
|
114
|
+
url = API_LISTING_TINY.format(tiny_id=listing_id_str)
|
|
115
|
+
else:
|
|
116
|
+
url = API_LISTING.format(listing_id=listing_id_str)
|
|
117
|
+
|
|
118
|
+
response = self.session.get(url, timeout=self.timeout)
|
|
119
|
+
|
|
120
|
+
# If tinyId fails, try as globalId
|
|
121
|
+
if response.status_code == 404 and len(listing_id_str) >= 8:
|
|
122
|
+
url = API_LISTING.format(listing_id=listing_id_str)
|
|
123
|
+
response = self.session.get(url, timeout=self.timeout)
|
|
124
|
+
|
|
125
|
+
if response.status_code != 200:
|
|
126
|
+
raise LookupError(f"Listing {listing_id} not found")
|
|
127
|
+
|
|
128
|
+
data = response.json()
|
|
129
|
+
return self._parse_listing(data)
|
|
130
|
+
|
|
131
|
+
def search_listing(
|
|
132
|
+
self,
|
|
133
|
+
location: str | list[str] | None = None,
|
|
134
|
+
offering_type: str = "buy",
|
|
135
|
+
price_min: int | None = None,
|
|
136
|
+
price_max: int | None = None,
|
|
137
|
+
area_min: int | None = None,
|
|
138
|
+
area_max: int | None = None,
|
|
139
|
+
plot_min: int | None = None,
|
|
140
|
+
plot_max: int | None = None,
|
|
141
|
+
object_type: list[str] | None = None,
|
|
142
|
+
energy_label: list[str] | None = None,
|
|
143
|
+
radius_km: int | None = None,
|
|
144
|
+
sort: str | None = None,
|
|
145
|
+
page: int = 0,
|
|
146
|
+
) -> list[Listing]:
|
|
147
|
+
"""Search for listings.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
location: City/area name(s) or postcode to search in
|
|
151
|
+
offering_type: "buy" or "rent"
|
|
152
|
+
price_min: Minimum price
|
|
153
|
+
price_max: Maximum price
|
|
154
|
+
area_min: Minimum living area in m²
|
|
155
|
+
area_max: Maximum living area in m²
|
|
156
|
+
plot_min: Minimum plot area in m²
|
|
157
|
+
plot_max: Maximum plot area in m²
|
|
158
|
+
object_type: Property types (e.g. ["house", "apartment"])
|
|
159
|
+
energy_label: Energy labels (e.g. ["A", "A+", "A++"])
|
|
160
|
+
radius_km: Search radius in km (use with single location/postcode)
|
|
161
|
+
sort: Sort order - "newest", "oldest", "price_asc", "price_desc",
|
|
162
|
+
"area_asc", "area_desc", "plot_desc", "city", "postcode", or None
|
|
163
|
+
page: Page number (0-indexed, 15 results per page)
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
List of Listing objects (max 15 per page)
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
>>> f.search_listing('amsterdam', price_max=500000)
|
|
170
|
+
>>> f.search_listing('1012AB', radius_km=30, price_max=1250000, energy_label=['A', 'A+'])
|
|
171
|
+
"""
|
|
172
|
+
import json
|
|
173
|
+
|
|
174
|
+
# Normalize location to list
|
|
175
|
+
locations = None
|
|
176
|
+
if location:
|
|
177
|
+
locations = [location] if isinstance(location, str) else list(location)
|
|
178
|
+
|
|
179
|
+
# Build search params
|
|
180
|
+
params: dict[str, Any] = {
|
|
181
|
+
"availability": ["available", "negotiations"],
|
|
182
|
+
"type": ["single"],
|
|
183
|
+
"zoning": ["residential"],
|
|
184
|
+
"object_type": object_type or ["house", "apartment"],
|
|
185
|
+
"publication_date": {"no_preference": True},
|
|
186
|
+
"offering_type": offering_type,
|
|
187
|
+
"page": {"from": page * 15},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# Sort
|
|
191
|
+
sort_map = {
|
|
192
|
+
"newest": ("publish_date_utc", "desc"),
|
|
193
|
+
"oldest": ("publish_date_utc", "asc"),
|
|
194
|
+
"price_asc": ("price.selling_price", "asc"),
|
|
195
|
+
"price_desc": ("price.selling_price", "desc"),
|
|
196
|
+
"area_asc": ("floor_area", "asc"),
|
|
197
|
+
"area_desc": ("floor_area", "desc"),
|
|
198
|
+
"plot_desc": ("plot_area", "desc"),
|
|
199
|
+
"city": ("address.city", "asc"),
|
|
200
|
+
"postcode": ("address.postal_code", "asc"),
|
|
201
|
+
}
|
|
202
|
+
if sort and sort in sort_map:
|
|
203
|
+
field, order = sort_map[sort]
|
|
204
|
+
params["sort"] = {"field": field, "order": order}
|
|
205
|
+
else:
|
|
206
|
+
params["sort"] = {"field": None, "order": None}
|
|
207
|
+
|
|
208
|
+
# Location - either radius search or selected_area
|
|
209
|
+
if locations and radius_km and len(locations) == 1:
|
|
210
|
+
# Radius search from postcode or city
|
|
211
|
+
# Valid radius values in the geo index
|
|
212
|
+
valid_radii = [1, 2, 5, 10, 15, 30, 50]
|
|
213
|
+
actual_radius = min(valid_radii, key=lambda x: abs(x - radius_km))
|
|
214
|
+
loc_id = locations[0].lower().replace(" ", "-") + "-0"
|
|
215
|
+
params["radius_search"] = {
|
|
216
|
+
"index": "geo-wonen-alias-prod",
|
|
217
|
+
"id": loc_id,
|
|
218
|
+
"path": f"area_with_radius.{actual_radius}",
|
|
219
|
+
}
|
|
220
|
+
elif locations:
|
|
221
|
+
params["selected_area"] = locations
|
|
222
|
+
|
|
223
|
+
# Price filter - format depends on offering type
|
|
224
|
+
if price_min is not None or price_max is not None:
|
|
225
|
+
price_key = "selling_price" if offering_type == "buy" else "rent_price"
|
|
226
|
+
price_filter: dict[str, Any] = {}
|
|
227
|
+
if price_min:
|
|
228
|
+
price_filter["from"] = price_min
|
|
229
|
+
if price_max:
|
|
230
|
+
price_filter["to"] = price_max
|
|
231
|
+
params["price"] = {price_key: price_filter}
|
|
232
|
+
|
|
233
|
+
# Living area filter
|
|
234
|
+
if area_min is not None or area_max is not None:
|
|
235
|
+
floor_filter: dict[str, Any] = {}
|
|
236
|
+
if area_min:
|
|
237
|
+
floor_filter["from"] = area_min
|
|
238
|
+
if area_max:
|
|
239
|
+
floor_filter["to"] = area_max
|
|
240
|
+
params["floor_area"] = floor_filter
|
|
241
|
+
|
|
242
|
+
# Plot area filter
|
|
243
|
+
if plot_min is not None or plot_max is not None:
|
|
244
|
+
plot_filter: dict[str, Any] = {}
|
|
245
|
+
if plot_min:
|
|
246
|
+
plot_filter["from"] = plot_min
|
|
247
|
+
if plot_max:
|
|
248
|
+
plot_filter["to"] = plot_max
|
|
249
|
+
params["plot_area"] = plot_filter
|
|
250
|
+
|
|
251
|
+
# Energy label filter
|
|
252
|
+
if energy_label:
|
|
253
|
+
params["energy_label"] = energy_label
|
|
254
|
+
|
|
255
|
+
# Build NDJSON query
|
|
256
|
+
index_line = json.dumps({"index": "listings-wonen-searcher-alias-prod"})
|
|
257
|
+
query_line = json.dumps({"id": "search_result_20250805", "params": params})
|
|
258
|
+
query = f"{index_line}\n{query_line}\n"
|
|
259
|
+
|
|
260
|
+
response = requests.post(
|
|
261
|
+
API_SEARCH,
|
|
262
|
+
headers=SEARCH_HEADERS,
|
|
263
|
+
data=query,
|
|
264
|
+
timeout=self.timeout,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if response.status_code != 200:
|
|
268
|
+
raise RuntimeError(f"Search failed (status {response.status_code})")
|
|
269
|
+
|
|
270
|
+
data = response.json()
|
|
271
|
+
return self._parse_search_results(data)
|
|
272
|
+
|
|
273
|
+
# -------------------------------------------------------------------------
|
|
274
|
+
# Parsing methods
|
|
275
|
+
# -------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
def _parse_listing(self, data: dict) -> Listing:
|
|
278
|
+
"""Parse API response into Listing object."""
|
|
279
|
+
identifiers = data.get("Identifiers", {})
|
|
280
|
+
address = data.get("AddressDetails", {})
|
|
281
|
+
price_data = data.get("Price", {})
|
|
282
|
+
coords = data.get("Coordinates", {})
|
|
283
|
+
media = data.get("Media", {})
|
|
284
|
+
fast_view = data.get("FastView", {})
|
|
285
|
+
ads = data.get("Advertising", {}).get("TargetingOptions", {})
|
|
286
|
+
|
|
287
|
+
# Build listing data
|
|
288
|
+
listing_data = {
|
|
289
|
+
"global_id": identifiers.get("GlobalId"),
|
|
290
|
+
"tiny_id": identifiers.get("TinyId"),
|
|
291
|
+
"title": address.get("Title"),
|
|
292
|
+
"city": address.get("City"),
|
|
293
|
+
"postcode": address.get("PostCode"),
|
|
294
|
+
"province": address.get("Province"),
|
|
295
|
+
"neighbourhood": address.get("NeighborhoodName"),
|
|
296
|
+
"house_number": address.get("HouseNumber"),
|
|
297
|
+
"house_number_ext": address.get("HouseNumberExtension"),
|
|
298
|
+
"municipality": ads.get("gemeente"),
|
|
299
|
+
"price": price_data.get("NumericSellingPrice") or price_data.get("NumericRentalPrice"),
|
|
300
|
+
"price_formatted": price_data.get("SellingPrice") or price_data.get("RentalPrice"),
|
|
301
|
+
"offering_type": data.get("OfferingType"),
|
|
302
|
+
"object_type": data.get("ObjectType"),
|
|
303
|
+
"construction_type": data.get("ConstructionType"),
|
|
304
|
+
"status": "sold" if data.get("IsSoldOrRented") else "available",
|
|
305
|
+
"energy_label": fast_view.get("EnergyLabel"),
|
|
306
|
+
"living_area": int(ads["woonoppervlakte"]) if ads.get("woonoppervlakte", "").isdigit() else _parse_area(fast_view.get("LivingArea")),
|
|
307
|
+
"living_area_formatted": fast_view.get("LivingArea"),
|
|
308
|
+
"plot_area": int(ads["perceeloppervlakte"]) if ads.get("perceeloppervlakte", "").isdigit() else _parse_area(fast_view.get("PlotArea")),
|
|
309
|
+
"plot_area_formatted": fast_view.get("PlotArea"),
|
|
310
|
+
"bedrooms": fast_view.get("NumberOfBedrooms"),
|
|
311
|
+
"rooms": int(ads["aantalkamers"]) if ads.get("aantalkamers") else None,
|
|
312
|
+
"construction_year": int(ads["bouwjaar"]) if ads.get("bouwjaar") and ads["bouwjaar"].isdigit() else None,
|
|
313
|
+
"description": data.get("ListingDescription", {}).get("Description"),
|
|
314
|
+
"highlight": data.get("Promo", {}).get("Blikvanger", {}).get("Text"),
|
|
315
|
+
"publication_date": data.get("PublicationDate"),
|
|
316
|
+
# Booleans
|
|
317
|
+
"has_garden": ads.get("tuin") == "true",
|
|
318
|
+
"has_balcony": ads.get("balkon") == "true",
|
|
319
|
+
"has_solar_panels": ads.get("zonnepanelen") == "true",
|
|
320
|
+
"has_heat_pump": ads.get("warmtepomp") == "true",
|
|
321
|
+
"has_roof_terrace": ads.get("dakterras") == "true",
|
|
322
|
+
"has_parking_on_site": ads.get("parkeergelegenheidopeigenterrein") == "true",
|
|
323
|
+
"has_parking_enclosed": ads.get("parkeergelegenheidopafgeslotenterrein") == "true",
|
|
324
|
+
"open_house": ads.get("openhuis") == "true",
|
|
325
|
+
"is_auction": price_data.get("IsAuction", False),
|
|
326
|
+
"is_energy_efficient": ads.get("energiezuinig") == "true",
|
|
327
|
+
"is_monument": ads.get("monumentalestatus") == "true",
|
|
328
|
+
"is_fixer_upper": ads.get("kluswoning") == "true",
|
|
329
|
+
"house_type": ads.get("soortwoning"),
|
|
330
|
+
# URLs
|
|
331
|
+
"google_maps_url": data.get("GoogleMapsObjectUrl"),
|
|
332
|
+
"share_url": data.get("Share", {}).get("Url"),
|
|
333
|
+
"brochure_url": media.get("Brochure", {}).get("CdnUrl"),
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
# Coordinates
|
|
337
|
+
if coords.get("Latitude") and coords.get("Longitude"):
|
|
338
|
+
listing_data["latitude"] = float(coords["Latitude"])
|
|
339
|
+
listing_data["longitude"] = float(coords["Longitude"])
|
|
340
|
+
listing_data["coordinates"] = (listing_data["latitude"], listing_data["longitude"])
|
|
341
|
+
|
|
342
|
+
# Photos - IDs and full URLs
|
|
343
|
+
photos_data = media.get("Photos", {})
|
|
344
|
+
photo_base = photos_data.get("MediaBaseUrl", "").replace("{id}", "{}")
|
|
345
|
+
photo_items = photos_data.get("Items", [])
|
|
346
|
+
listing_data["photos"] = [p.get("Id") for p in photo_items if p.get("Id")]
|
|
347
|
+
listing_data["photo_urls"] = [photo_base.format(p["Id"]) for p in photo_items if p.get("Id")] if photo_base else []
|
|
348
|
+
listing_data["photo_count"] = len(listing_data["photos"])
|
|
349
|
+
|
|
350
|
+
# Floorplans
|
|
351
|
+
floorplans_data = media.get("LegacyFloorPlan", {})
|
|
352
|
+
floorplan_base = floorplans_data.get("ThumbnailBaseUrl", "").replace("{id}", "{}")
|
|
353
|
+
floorplan_items = floorplans_data.get("Items", [])
|
|
354
|
+
listing_data["floorplans"] = [f.get("Id") for f in floorplan_items if f.get("Id")]
|
|
355
|
+
listing_data["floorplan_urls"] = [
|
|
356
|
+
floorplan_base.format(f["ThumbnailId"])
|
|
357
|
+
for f in floorplan_items if f.get("ThumbnailId")
|
|
358
|
+
] if floorplan_base else []
|
|
359
|
+
|
|
360
|
+
# Videos
|
|
361
|
+
videos_data = media.get("Videos", {})
|
|
362
|
+
video_base = videos_data.get("MediaBaseUrl", "").replace("{id}", "{}")
|
|
363
|
+
video_items = videos_data.get("Items", [])
|
|
364
|
+
listing_data["videos"] = [v.get("Id") for v in video_items if v.get("Id")]
|
|
365
|
+
listing_data["video_urls"] = [video_base.format(v["Id"]) for v in video_items if v.get("Id")] if video_base else []
|
|
366
|
+
|
|
367
|
+
# 360 Photos
|
|
368
|
+
photos360_data = media.get("LegacyPhotos360", {})
|
|
369
|
+
photos360_base = photos360_data.get("ThumbnailBaseUrl", "").replace("{id}", "{}")
|
|
370
|
+
photos360_items = photos360_data.get("Items", [])
|
|
371
|
+
listing_data["photos_360"] = [
|
|
372
|
+
{"name": p.get("DisplayName"), "id": p.get("Id"), "url": photos360_base.format(p["Id"]) if photos360_base else None}
|
|
373
|
+
for p in photos360_items if p.get("Id")
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
# URL
|
|
377
|
+
city_slug = address.get("City", "").lower().replace(" ", "-")
|
|
378
|
+
title_slug = address.get("Title", "").lower().replace(" ", "-")
|
|
379
|
+
tiny_id = identifiers.get("TinyId")
|
|
380
|
+
offering = "koop" if data.get("OfferingType") == "Sale" else "huur"
|
|
381
|
+
listing_data["url"] = f"https://www.funda.nl/detail/{offering}/{city_slug}/{title_slug}/{tiny_id}/"
|
|
382
|
+
|
|
383
|
+
# Characteristics
|
|
384
|
+
characteristics = {}
|
|
385
|
+
for section in data.get("KenmerkSections", []):
|
|
386
|
+
for item in section.get("KenmerkenList", []):
|
|
387
|
+
if item.get("Label") and item.get("Value"):
|
|
388
|
+
characteristics[item["Label"]] = item["Value"]
|
|
389
|
+
listing_data["characteristics"] = characteristics
|
|
390
|
+
|
|
391
|
+
# Extract specific fields from characteristics
|
|
392
|
+
listing_data["offered_since"] = characteristics.get("Aangeboden sinds")
|
|
393
|
+
listing_data["acceptance"] = characteristics.get("Aanvaarding")
|
|
394
|
+
listing_data["price_per_m2"] = characteristics.get("Vraagprijs per m²")
|
|
395
|
+
|
|
396
|
+
# Broker
|
|
397
|
+
tracking = data.get("Tracking", {}).get("Values", {})
|
|
398
|
+
brokers = tracking.get("brokers", [])
|
|
399
|
+
if brokers:
|
|
400
|
+
listing_data["broker_id"] = brokers[0].get("broker_id")
|
|
401
|
+
listing_data["broker_association"] = brokers[0].get("broker_association")
|
|
402
|
+
|
|
403
|
+
# Insights
|
|
404
|
+
insights = data.get("ObjectInsights", {})
|
|
405
|
+
if insights:
|
|
406
|
+
listing_data["views"] = insights.get("Views")
|
|
407
|
+
listing_data["saves"] = insights.get("Saves")
|
|
408
|
+
|
|
409
|
+
return Listing(
|
|
410
|
+
listing_id=identifiers.get("TinyId") or identifiers.get("GlobalId"),
|
|
411
|
+
data=listing_data
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def _parse_search_results(self, data: dict) -> list[Listing]:
|
|
415
|
+
"""Parse search API response into list of Listings."""
|
|
416
|
+
listings = []
|
|
417
|
+
responses = data.get("responses", [])
|
|
418
|
+
|
|
419
|
+
if not responses:
|
|
420
|
+
return listings
|
|
421
|
+
|
|
422
|
+
hits = responses[0].get("hits", {}).get("hits", [])
|
|
423
|
+
|
|
424
|
+
for hit in hits:
|
|
425
|
+
source = hit.get("_source", {})
|
|
426
|
+
address = source.get("address", {})
|
|
427
|
+
|
|
428
|
+
# Price
|
|
429
|
+
price_data = source.get("price", {})
|
|
430
|
+
if isinstance(price_data, dict):
|
|
431
|
+
price = price_data.get("selling_price", [None])[0]
|
|
432
|
+
if not price:
|
|
433
|
+
price = price_data.get("rent_price", [None])[0] if price_data.get("rent_price") else None
|
|
434
|
+
else:
|
|
435
|
+
price = price_data
|
|
436
|
+
|
|
437
|
+
listing_data = {
|
|
438
|
+
"global_id": int(hit.get("_id", 0)),
|
|
439
|
+
"title": f"{address.get('street_name', '')} {address.get('house_number', '')}".strip(),
|
|
440
|
+
"city": address.get("city"),
|
|
441
|
+
"postcode": address.get("postal_code"),
|
|
442
|
+
"province": address.get("province"),
|
|
443
|
+
"neighbourhood": address.get("neighbourhood"),
|
|
444
|
+
"price": price,
|
|
445
|
+
"living_area": source.get("floor_area", [None])[0] if source.get("floor_area") else None,
|
|
446
|
+
"plot_area": source.get("plot_area_range", {}).get("gte"),
|
|
447
|
+
"bedrooms": source.get("number_of_bedrooms"),
|
|
448
|
+
"energy_label": source.get("energy_label"),
|
|
449
|
+
"object_type": source.get("object_type"),
|
|
450
|
+
"construction_type": source.get("construction_type"),
|
|
451
|
+
"photos": source.get("thumbnail_id", [])[:5],
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
listings.append(Listing(
|
|
455
|
+
listing_id=hit.get("_id"),
|
|
456
|
+
data=listing_data
|
|
457
|
+
))
|
|
458
|
+
|
|
459
|
+
return listings
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# Convenience alias
|
|
463
|
+
FundaAPI = Funda
|
funda/listing.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Listing class - represents a Funda property listing."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Listing:
|
|
7
|
+
"""A Funda property listing.
|
|
8
|
+
|
|
9
|
+
Data can be accessed as listing['key'] or listing.get('key').
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> listing = funda.get_listing(43117443)
|
|
13
|
+
>>> listing['title']
|
|
14
|
+
'Reehorst 13'
|
|
15
|
+
>>> listing['price']
|
|
16
|
+
695000
|
|
17
|
+
>>> listing['city']
|
|
18
|
+
'Luttenberg'
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
default_info = ('main',)
|
|
22
|
+
|
|
23
|
+
keys_alias = {
|
|
24
|
+
'name': 'title',
|
|
25
|
+
'address': 'title',
|
|
26
|
+
'location': 'city',
|
|
27
|
+
'locality': 'city',
|
|
28
|
+
'area': 'living_area',
|
|
29
|
+
'size': 'living_area',
|
|
30
|
+
'coords': 'coordinates',
|
|
31
|
+
'lat': 'latitude',
|
|
32
|
+
'lng': 'longitude',
|
|
33
|
+
'lon': 'longitude',
|
|
34
|
+
'zip': 'postcode',
|
|
35
|
+
'zipcode': 'postcode',
|
|
36
|
+
'postal_code': 'postcode',
|
|
37
|
+
'type': 'object_type',
|
|
38
|
+
'property_type': 'object_type',
|
|
39
|
+
'images': 'photos',
|
|
40
|
+
'pictures': 'photos',
|
|
41
|
+
'media': 'photos',
|
|
42
|
+
'desc': 'description',
|
|
43
|
+
'text': 'description',
|
|
44
|
+
'agent': 'broker',
|
|
45
|
+
'realtor': 'broker',
|
|
46
|
+
'makelaar': 'broker',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def __init__(self, listing_id: str | int | None = None, data: dict | None = None):
|
|
50
|
+
self.listing_id = str(listing_id) if listing_id else None
|
|
51
|
+
self.data: dict[str, Any] = data or {}
|
|
52
|
+
self.current_info: list[str] = []
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
title = self.data.get('title', 'Unknown')
|
|
56
|
+
city = self.data.get('city', '')
|
|
57
|
+
return f"<Listing id:{self.listing_id} [{title}, {city}]>"
|
|
58
|
+
|
|
59
|
+
def __str__(self) -> str:
|
|
60
|
+
return self.__repr__()
|
|
61
|
+
|
|
62
|
+
def __contains__(self, key: str) -> bool:
|
|
63
|
+
return self._normalize_key(key) in self.data
|
|
64
|
+
|
|
65
|
+
def __getitem__(self, key: str) -> Any:
|
|
66
|
+
normalized = self._normalize_key(key)
|
|
67
|
+
if normalized not in self.data:
|
|
68
|
+
raise KeyError(key)
|
|
69
|
+
return self.data[normalized]
|
|
70
|
+
|
|
71
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
72
|
+
self.data[self._normalize_key(key)] = value
|
|
73
|
+
|
|
74
|
+
def __bool__(self) -> bool:
|
|
75
|
+
return bool(self.listing_id or self.data.get('title'))
|
|
76
|
+
|
|
77
|
+
def _normalize_key(self, key: str) -> str:
|
|
78
|
+
"""Normalize key using aliases."""
|
|
79
|
+
key = key.lower().replace('-', '_').replace(' ', '_')
|
|
80
|
+
return self.keys_alias.get(key, key)
|
|
81
|
+
|
|
82
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
83
|
+
"""Get a value with optional default."""
|
|
84
|
+
try:
|
|
85
|
+
return self[key]
|
|
86
|
+
except KeyError:
|
|
87
|
+
return default
|
|
88
|
+
|
|
89
|
+
def keys(self) -> list[str]:
|
|
90
|
+
"""Return all available keys."""
|
|
91
|
+
return list(self.data.keys())
|
|
92
|
+
|
|
93
|
+
def items(self) -> list[tuple[str, Any]]:
|
|
94
|
+
"""Return all key-value pairs."""
|
|
95
|
+
return list(self.data.items())
|
|
96
|
+
|
|
97
|
+
def values(self) -> list[Any]:
|
|
98
|
+
"""Return all values."""
|
|
99
|
+
return list(self.data.values())
|
|
100
|
+
|
|
101
|
+
def to_dict(self) -> dict[str, Any]:
|
|
102
|
+
"""Return data as a plain dictionary."""
|
|
103
|
+
return self.data.copy()
|
|
104
|
+
|
|
105
|
+
def summary(self) -> str:
|
|
106
|
+
"""Return a text summary of the listing."""
|
|
107
|
+
lines = []
|
|
108
|
+
title = self.data.get('title', 'Unknown')
|
|
109
|
+
city = self.data.get('city', '')
|
|
110
|
+
lines.append(f"Listing: {title}, {city}")
|
|
111
|
+
|
|
112
|
+
if price := self.data.get('price_formatted'):
|
|
113
|
+
lines.append(f"Price: {price}")
|
|
114
|
+
elif price := self.data.get('price'):
|
|
115
|
+
lines.append(f"Price: €{price:,}")
|
|
116
|
+
|
|
117
|
+
if area := self.data.get('living_area'):
|
|
118
|
+
lines.append(f"Living area: {area}")
|
|
119
|
+
|
|
120
|
+
if bedrooms := self.data.get('bedrooms'):
|
|
121
|
+
lines.append(f"Bedrooms: {bedrooms}")
|
|
122
|
+
|
|
123
|
+
if energy := self.data.get('energy_label'):
|
|
124
|
+
lines.append(f"Energy label: {energy}")
|
|
125
|
+
|
|
126
|
+
if url := self.data.get('url'):
|
|
127
|
+
lines.append(f"URL: {url}")
|
|
128
|
+
|
|
129
|
+
return '\n'.join(lines)
|
|
130
|
+
|
|
131
|
+
def getID(self) -> str | None:
|
|
132
|
+
"""Return the listing ID."""
|
|
133
|
+
return self.listing_id
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def id(self) -> str | None:
|
|
137
|
+
"""Alias for listing_id."""
|
|
138
|
+
return self.listing_id
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyfunda
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Python API for Funda.nl real estate listings
|
|
5
|
+
Project-URL: Homepage, https://github.com/0xMH/pyfunda
|
|
6
|
+
Project-URL: Repository, https://github.com/0xMH/pyfunda
|
|
7
|
+
Project-URL: Issues, https://github.com/0xMH/pyfunda/issues
|
|
8
|
+
Author: 0xMH
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: api,funda,housing,netherlands,real-estate,scraper
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: requests>=2.28.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# Funda
|
|
23
|
+
|
|
24
|
+
Python API for [Funda.nl](https://www.funda.nl) real estate listings.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install -r requirements.txt
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from funda import Funda
|
|
36
|
+
|
|
37
|
+
f = Funda()
|
|
38
|
+
|
|
39
|
+
# Get a listing by ID
|
|
40
|
+
listing = f.get_listing(43117443)
|
|
41
|
+
print(listing['title'], listing['city'])
|
|
42
|
+
# Reehorst 13 Luttenberg
|
|
43
|
+
|
|
44
|
+
# Get a listing by URL
|
|
45
|
+
listing = f.get_listing('https://www.funda.nl/detail/koop/amsterdam/appartement-123/43117443/')
|
|
46
|
+
|
|
47
|
+
# Search listings
|
|
48
|
+
results = f.search_listing('amsterdam', price_max=500000)
|
|
49
|
+
for r in results:
|
|
50
|
+
print(r['title'], r['price'])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## How It Works
|
|
54
|
+
|
|
55
|
+
This library uses Funda's undocumented mobile app API, which provides clean JSON responses unlike the website that embeds data in Nuxt.js/JavaScript bundles.
|
|
56
|
+
|
|
57
|
+
### Discovery Process
|
|
58
|
+
|
|
59
|
+
The API was reverse engineered by intercepting and analyzing HTTPS traffic from the official Funda Android app:
|
|
60
|
+
|
|
61
|
+
1. Configured an Android device to route traffic through an intercepting proxy
|
|
62
|
+
2. Used the Funda app normally - browsing listings, searching, opening shared URLs
|
|
63
|
+
3. Identified the `*.funda.io` API infrastructure separate from the `www.funda.nl` website
|
|
64
|
+
4. Analyzed request/response patterns to understand the query format and available filters
|
|
65
|
+
5. Discovered how the app resolves URL-based IDs (`tinyId`) to internal IDs (`globalId`)
|
|
66
|
+
|
|
67
|
+
### API Architecture
|
|
68
|
+
|
|
69
|
+
The mobile app communicates with a separate API at `*.funda.io`:
|
|
70
|
+
|
|
71
|
+
| Endpoint | Method | Purpose |
|
|
72
|
+
|----------|--------|---------|
|
|
73
|
+
| `listing-detail-page.funda.io/api/v4/listing/object/nl/{globalId}` | GET | Fetch listing by internal ID |
|
|
74
|
+
| `listing-detail-page.funda.io/api/v4/listing/object/nl/tinyId/{tinyId}` | GET | Fetch listing by URL ID |
|
|
75
|
+
| `listing-search-wonen.funda.io/_msearch/template` | POST | Search listings |
|
|
76
|
+
|
|
77
|
+
### ID System
|
|
78
|
+
|
|
79
|
+
Funda uses two ID systems:
|
|
80
|
+
- **globalId**: Internal numeric ID (7 digits), used in the database
|
|
81
|
+
- **tinyId**: Public-facing ID (8-9 digits), appears in URLs like `funda.nl/detail/koop/amsterdam/.../{tinyId}/`
|
|
82
|
+
|
|
83
|
+
The `tinyId` endpoint was key - it allows fetching any listing directly from a Funda URL without needing to know the internal ID.
|
|
84
|
+
|
|
85
|
+
### Search API
|
|
86
|
+
|
|
87
|
+
Search uses Elasticsearch's [Multi Search Template API](https://www.elastic.co/guide/en/elasticsearch/reference/current/multi-search-template.html) with NDJSON format:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
{"index":"listings-wonen-searcher-alias-prod"}
|
|
91
|
+
{"id":"search_result_20250805","params":{...}}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Search parameters:**
|
|
95
|
+
|
|
96
|
+
| Parameter | Description | Example |
|
|
97
|
+
|-----------|-------------|---------|
|
|
98
|
+
| `selected_area` | Location filter | `["amsterdam"]` |
|
|
99
|
+
| `radius_search` | Radius from location | `{"index": "geo-wonen-alias-prod", "id": "1012AB-0", "path": "area_with_radius.10"}` |
|
|
100
|
+
| `offering_type` | Buy or rent | `"buy"` or `"rent"` |
|
|
101
|
+
| `price.selling_price` | Price range (buy) | `{"from": 200000, "to": 500000}` |
|
|
102
|
+
| `price.rent_price` | Price range (rent) | `{"from": 500, "to": 2000}` |
|
|
103
|
+
| `object_type` | Property types | `["house", "apartment"]` |
|
|
104
|
+
| `floor_area` | Living area m² | `{"from": 50, "to": 150}` |
|
|
105
|
+
| `plot_area` | Plot area m² | `{"from": 100, "to": 500}` |
|
|
106
|
+
| `energy_label` | Energy labels | `["A", "A+", "A++"]` |
|
|
107
|
+
| `sort` | Sort order | `{"field": "publish_date_utc", "order": "desc"}` |
|
|
108
|
+
| `page.from` | Pagination offset | `0`, `15`, `30`... |
|
|
109
|
+
|
|
110
|
+
Results are paginated with 15 listings per page.
|
|
111
|
+
|
|
112
|
+
**Valid radius values:** 1, 2, 5, 10, 15, 30, 50 km (other values are not indexed).
|
|
113
|
+
|
|
114
|
+
### Required Headers
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
User-Agent: Dart/3.9 (dart:io)
|
|
118
|
+
X-Funda-App-Platform: android
|
|
119
|
+
Content-Type: application/json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Response Data
|
|
123
|
+
|
|
124
|
+
Listing responses include:
|
|
125
|
+
- **Identifiers** - globalId, tinyId
|
|
126
|
+
- **AddressDetails** - title, city, postcode, province, neighbourhood, house number
|
|
127
|
+
- **Price** - numeric and formatted prices (selling or rental), auction flag
|
|
128
|
+
- **FastView** - bedrooms, living area, plot area, energy label
|
|
129
|
+
- **Media** - photos, floorplans, videos, 360° photos, brochure URL (all with CDN base URLs)
|
|
130
|
+
- **KenmerkSections** - detailed property characteristics (70+ fields)
|
|
131
|
+
- **Coordinates** - latitude/longitude
|
|
132
|
+
- **ObjectInsights** - view and save counts
|
|
133
|
+
- **Advertising.TargetingOptions** - boolean features (garden, balcony, solar panels, heat pump, parking, etc.), construction year, room counts
|
|
134
|
+
- **Share** - shareable URL
|
|
135
|
+
- **GoogleMapsObjectUrl** - direct Google Maps link
|
|
136
|
+
- **PublicationDate** - when the listing was published
|
|
137
|
+
- **Tracking.Values.brokers** - broker ID and association
|
|
138
|
+
|
|
139
|
+
## API Reference
|
|
140
|
+
|
|
141
|
+
### Funda
|
|
142
|
+
|
|
143
|
+
Main entry point for the API.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from funda import Funda
|
|
147
|
+
|
|
148
|
+
f = Funda(timeout=30)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
#### get_listing(listing_id)
|
|
152
|
+
|
|
153
|
+
Get a single listing by ID or URL.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
# By numeric ID (tinyId or globalId)
|
|
157
|
+
listing = f.get_listing(43117443)
|
|
158
|
+
|
|
159
|
+
# By URL
|
|
160
|
+
listing = f.get_listing('https://www.funda.nl/detail/koop/city/house-name/43117443/')
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
#### search_listing(location, ...)
|
|
164
|
+
|
|
165
|
+
Search for listings with filters.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
results = f.search_listing(
|
|
169
|
+
location='amsterdam', # City or area name
|
|
170
|
+
offering_type='buy', # 'buy' or 'rent'
|
|
171
|
+
price_min=200000, # Minimum price
|
|
172
|
+
price_max=500000, # Maximum price
|
|
173
|
+
area_min=50, # Minimum living area (m²)
|
|
174
|
+
area_max=150, # Maximum living area (m²)
|
|
175
|
+
plot_min=100, # Minimum plot area (m²)
|
|
176
|
+
plot_max=500, # Maximum plot area (m²)
|
|
177
|
+
object_type=['house'], # Property types (default: house, apartment)
|
|
178
|
+
energy_label=['A', 'A+'], # Energy labels to filter
|
|
179
|
+
sort='newest', # Sort order (see below)
|
|
180
|
+
page=0, # Page number (15 results per page)
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Radius search** - search within a radius from a postcode or city:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
results = f.search_listing(
|
|
188
|
+
location='1012AB', # Postcode or city
|
|
189
|
+
radius_km=10, # Search radius in km
|
|
190
|
+
price_max=750000,
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
> **Note:** Valid radius values are 1, 2, 5, 10, 15, 30, and 50 km. Other values are automatically mapped to the nearest valid radius.
|
|
195
|
+
|
|
196
|
+
**Sort options:**
|
|
197
|
+
|
|
198
|
+
| Sort Value | Description |
|
|
199
|
+
|------------|-------------|
|
|
200
|
+
| `newest` | Most recently published first |
|
|
201
|
+
| `oldest` | Oldest listings first |
|
|
202
|
+
| `price_asc` | Lowest price first |
|
|
203
|
+
| `price_desc` | Highest price first |
|
|
204
|
+
| `area_asc` | Smallest living area first |
|
|
205
|
+
| `area_desc` | Largest living area first |
|
|
206
|
+
| `plot_desc` | Largest plot area first |
|
|
207
|
+
| `city` | Alphabetically by city |
|
|
208
|
+
| `postcode` | Alphabetically by postcode |
|
|
209
|
+
|
|
210
|
+
**Multiple locations:**
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
results = f.search_listing(['amsterdam', 'rotterdam', 'utrecht'])
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Listing
|
|
217
|
+
|
|
218
|
+
Listing objects support dict-like access with convenient aliases.
|
|
219
|
+
|
|
220
|
+
**Basic info:**
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
listing['title'] # Property title/address
|
|
224
|
+
listing['city'] # City name
|
|
225
|
+
listing['postcode'] # Postal code
|
|
226
|
+
listing['province'] # Province
|
|
227
|
+
listing['neighbourhood'] # Neighbourhood name
|
|
228
|
+
listing['municipality'] # Municipality (gemeente)
|
|
229
|
+
listing['house_number'] # House number
|
|
230
|
+
listing['house_number_ext'] # House number extension (e.g., "A", "II")
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Price & Status:**
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
listing['price'] # Numeric price
|
|
237
|
+
listing['price_formatted'] # Formatted price string (e.g., "€ 450.000 k.k.")
|
|
238
|
+
listing['price_per_m2'] # Price per m² (from characteristics)
|
|
239
|
+
listing['status'] # "available" or "sold"
|
|
240
|
+
listing['offering_type'] # "Sale" or "Rent"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Property details:**
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
listing['object_type'] # House, Apartment, etc.
|
|
247
|
+
listing['house_type'] # Type of house (e.g., "Tussenwoning")
|
|
248
|
+
listing['construction_type'] # New or existing construction
|
|
249
|
+
listing['construction_year'] # Year built
|
|
250
|
+
listing['bedrooms'] # Number of bedrooms
|
|
251
|
+
listing['rooms'] # Total number of rooms
|
|
252
|
+
listing['living_area'] # Living area in m²
|
|
253
|
+
listing['plot_area'] # Plot area in m²
|
|
254
|
+
listing['energy_label'] # Energy label (A, B, C, etc.)
|
|
255
|
+
listing['description'] # Full description text
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Dates:**
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
listing['publication_date'] # When listed on Funda
|
|
262
|
+
listing['offered_since'] # "Offered since" date (from characteristics)
|
|
263
|
+
listing['acceptance'] # Acceptance terms (e.g., "In overleg")
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**Location:**
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
listing['coordinates'] # (lat, lng) tuple
|
|
270
|
+
listing['latitude'] # Latitude
|
|
271
|
+
listing['longitude'] # Longitude
|
|
272
|
+
listing['google_maps_url'] # Direct Google Maps link
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Media:**
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
listing['photos'] # List of photo IDs
|
|
279
|
+
listing['photo_urls'] # List of full CDN URLs for photos
|
|
280
|
+
listing['photo_count'] # Number of photos
|
|
281
|
+
listing['floorplans'] # List of floorplan IDs
|
|
282
|
+
listing['floorplan_urls'] # List of full CDN URLs for floorplans
|
|
283
|
+
listing['videos'] # List of video IDs
|
|
284
|
+
listing['video_urls'] # List of video URLs
|
|
285
|
+
listing['photos_360'] # List of 360° photo dicts with name, id, url
|
|
286
|
+
listing['brochure_url'] # PDF brochure URL (if available)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**Property features (booleans):**
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
listing['has_garden'] # Has garden
|
|
293
|
+
listing['has_balcony'] # Has balcony
|
|
294
|
+
listing['has_roof_terrace'] # Has roof terrace
|
|
295
|
+
listing['has_solar_panels'] # Has solar panels
|
|
296
|
+
listing['has_heat_pump'] # Has heat pump
|
|
297
|
+
listing['has_parking_on_site'] # Parking on property
|
|
298
|
+
listing['has_parking_enclosed'] # Enclosed parking
|
|
299
|
+
listing['is_energy_efficient'] # Energy efficient property
|
|
300
|
+
listing['is_monument'] # Listed/protected building
|
|
301
|
+
listing['is_fixer_upper'] # Fixer-upper (kluswoning)
|
|
302
|
+
listing['is_auction'] # Sold via auction
|
|
303
|
+
listing['open_house'] # Has open house scheduled
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
**Stats & metadata:**
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
listing['views'] # Number of views on Funda
|
|
310
|
+
listing['saves'] # Number of times saved
|
|
311
|
+
listing['highlight'] # Highlight text (blikvanger)
|
|
312
|
+
listing['global_id'] # Internal Funda ID
|
|
313
|
+
listing['tiny_id'] # Public ID (used in URLs)
|
|
314
|
+
listing['url'] # Full Funda URL
|
|
315
|
+
listing['share_url'] # Shareable URL
|
|
316
|
+
listing['broker_id'] # Broker ID
|
|
317
|
+
listing['broker_association'] # Broker association (e.g., "NVM")
|
|
318
|
+
listing['characteristics'] # Dict of all detailed characteristics
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Key aliases** - these all work:
|
|
322
|
+
|
|
323
|
+
| Alias | Canonical Key |
|
|
324
|
+
|-------|---------------|
|
|
325
|
+
| `name`, `address` | `title` |
|
|
326
|
+
| `location`, `locality` | `city` |
|
|
327
|
+
| `area`, `size` | `living_area` |
|
|
328
|
+
| `type`, `property_type` | `object_type` |
|
|
329
|
+
| `images`, `pictures`, `media` | `photos` |
|
|
330
|
+
| `agent`, `realtor`, `makelaar` | `broker` |
|
|
331
|
+
| `zip`, `zipcode`, `postal_code` | `postcode` |
|
|
332
|
+
|
|
333
|
+
#### Methods
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
listing.summary() # Text summary of the listing
|
|
337
|
+
listing.to_dict() # Convert to plain dictionary
|
|
338
|
+
listing.keys() # List available keys
|
|
339
|
+
listing.get('key') # Get with default (like dict.get)
|
|
340
|
+
listing.getID() # Get listing ID
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## Examples
|
|
344
|
+
|
|
345
|
+
### Find apartments in Amsterdam under €400k
|
|
346
|
+
|
|
347
|
+
```python
|
|
348
|
+
from funda import Funda
|
|
349
|
+
|
|
350
|
+
f = Funda()
|
|
351
|
+
results = f.search_listing('amsterdam', price_max=400000)
|
|
352
|
+
|
|
353
|
+
for listing in results:
|
|
354
|
+
print(f"{listing['title']}")
|
|
355
|
+
print(f" Price: €{listing['price']:,}")
|
|
356
|
+
print(f" Area: {listing.get('living_area', 'N/A')}")
|
|
357
|
+
print(f" Bedrooms: {listing.get('bedrooms', 'N/A')}")
|
|
358
|
+
print()
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Get detailed listing information
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
from funda import Funda
|
|
365
|
+
|
|
366
|
+
f = Funda()
|
|
367
|
+
listing = f.get_listing(43117443)
|
|
368
|
+
|
|
369
|
+
print(listing.summary())
|
|
370
|
+
|
|
371
|
+
# Access all characteristics
|
|
372
|
+
for key, value in listing['characteristics'].items():
|
|
373
|
+
print(f"{key}: {value}")
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Search rentals in multiple cities
|
|
377
|
+
|
|
378
|
+
```python
|
|
379
|
+
from funda import Funda
|
|
380
|
+
|
|
381
|
+
f = Funda()
|
|
382
|
+
results = f.search_listing(
|
|
383
|
+
location=['amsterdam', 'rotterdam', 'den-haag'],
|
|
384
|
+
offering_type='rent',
|
|
385
|
+
price_max=2000,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
print(f"Found {len(results)} rentals")
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Find energy-efficient homes with a garden
|
|
392
|
+
|
|
393
|
+
```python
|
|
394
|
+
from funda import Funda
|
|
395
|
+
|
|
396
|
+
f = Funda()
|
|
397
|
+
listing = f.get_listing(43117443)
|
|
398
|
+
|
|
399
|
+
# Check property features
|
|
400
|
+
if listing['has_garden'] and listing.get('has_solar_panels'):
|
|
401
|
+
print("Energy efficient with garden!")
|
|
402
|
+
|
|
403
|
+
if listing['is_energy_efficient']:
|
|
404
|
+
print(f"Energy label: {listing['energy_label']}")
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Download listing photos
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
from funda import Funda
|
|
411
|
+
import requests
|
|
412
|
+
|
|
413
|
+
f = Funda()
|
|
414
|
+
listing = f.get_listing(43117443)
|
|
415
|
+
|
|
416
|
+
# Photo URLs are ready to use
|
|
417
|
+
for i, url in enumerate(listing['photo_urls'][:5]):
|
|
418
|
+
response = requests.get(url)
|
|
419
|
+
with open(f"photo_{i}.jpg", "wb") as file:
|
|
420
|
+
file.write(response.content)
|
|
421
|
+
|
|
422
|
+
# Also available: floorplan_urls, video_urls
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### Search by radius from postcode
|
|
426
|
+
|
|
427
|
+
```python
|
|
428
|
+
from funda import Funda
|
|
429
|
+
|
|
430
|
+
f = Funda()
|
|
431
|
+
results = f.search_listing(
|
|
432
|
+
location='1012AB',
|
|
433
|
+
radius_km=15,
|
|
434
|
+
price_max=600000,
|
|
435
|
+
energy_label=['A', 'A+', 'A++'],
|
|
436
|
+
sort='newest',
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
for r in results:
|
|
440
|
+
print(f"{r['title']} - €{r['price']:,}")
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
## License
|
|
444
|
+
|
|
445
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
funda/__init__.py,sha256=4_ylEwS5cHhfKbFLO_CgdlbGrJ8Vzv006fpKy31-0Ts,524
|
|
2
|
+
funda/funda.py,sha256=mQjGIiIUbatEFWqfECuwooaKd4i2iqY0BTONqSyNSJY,19092
|
|
3
|
+
funda/listing.py,sha256=UQxQJwodADJ59Jm2envUNb9EIxfyiJGTuo0hUuswg1k,4103
|
|
4
|
+
pyfunda-2.0.0.dist-info/METADATA,sha256=hhnINyWZVlaLJ-s50dBSf9u6vp_AGKlpAKq_Jxg3MtE,13675
|
|
5
|
+
pyfunda-2.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
pyfunda-2.0.0.dist-info/RECORD,,
|