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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any