pyfunda 2.7.0__tar.gz → 2.9.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyfunda
3
- Version: 2.7.0
3
+ Version: 2.9.0
4
4
  Summary: Python API for Funda.nl real estate listings
5
5
  Project-URL: Homepage, https://github.com/0xMH/pyfunda
6
6
  Project-URL: Repository, https://github.com/0xMH/pyfunda
@@ -333,6 +333,99 @@ print(contact['name'], contact['phone'])
333
333
 
334
334
  Raises `LookupError` if the listing has no contact info exposed.
335
335
 
336
+ #### get_contact_form(listing)
337
+
338
+ Get the agency's contact-form availability (which weekdays and times-of-day they accept inquiries through the in-app form).
339
+
340
+ ```python
341
+ form = f.get_contact_form(43333315)
342
+ print(form['days'], form['times_of_day'])
343
+ # ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'] ['Morning', 'Afternoon']
344
+ ```
345
+
346
+ **Returns:** Dict with `office_id`, `office_name`, `days`, `times_of_day`, `is_contacting_enabled`, `is_viewing_planner_enabled`, plus the raw `offices` list.
347
+
348
+ #### get_listing_summary(listing)
349
+
350
+ Lightweight version of `get_listing` (no descriptions, photos, kenmerken). Faster and smaller, useful for batch enrichment.
351
+
352
+ ```python
353
+ summary = f.get_listing_summary(7985628)
354
+ print(summary['title'], summary['price'], summary['energy_label'])
355
+ # Semarangstraat 13 650000 C
356
+ ```
357
+
358
+ **Returns:** A `Listing` object with summary fields (`title`, `price`, `living_area`, `plot_area`, `bedrooms`, `energy_label`, `broker_name`, `url`, `thumbnail_url`, …).
359
+
360
+ #### get_similar_listings(listing)
361
+
362
+ Get globalIds of similar / recently-sold listings near a given listing. Returns IDs only; combine with `get_listing_summary` or `get_listing` to materialize them.
363
+
364
+ ```python
365
+ sim = f.get_similar_listings(7988952)
366
+ for gid in sim['recently_sold']:
367
+ print(f.get_listing_summary(gid)['title'])
368
+ ```
369
+
370
+ **Returns:** Dict with `recently_listed` and `recently_sold`, each a list of integer globalIds.
371
+
372
+ #### get_market_insights(city, neighbourhood)
373
+
374
+ Neighbourhood demographics and average asking €/m² for a (city, neighbourhood) pair. Accepts a `Listing` directly to use its city/neighbourhood automatically.
375
+
376
+ ```python
377
+ mi = f.get_market_insights('Amsterdam', 'Twiske-West')
378
+ # {'city': 'Amsterdam', 'neighbourhood': 'Twiske-West',
379
+ # 'inhabitants': 2510, 'families_with_children_pct': 43.96,
380
+ # 'avg_asking_price_per_m2': 5975}
381
+
382
+ # Or pass a Listing
383
+ listing = f.get_listing(43333315)
384
+ mi = f.get_market_insights(listing)
385
+ ```
386
+
387
+ **Returns:** Dict with `city`, `neighbourhood`, `inhabitants`, `families_with_children_pct`, `avg_asking_price_per_m2`. Raises `LookupError` for unknown neighbourhoods (HTTP 204).
388
+
389
+ #### get_broker_info(broker)
390
+
391
+ Get the agency's profile page: phone, email, website, postal address, affiliation (NVM/VBO/…), description, certificates, languages, services. Accepts a numeric `broker_id` or a `Listing` (uses its `broker_id` automatically).
392
+
393
+ ```python
394
+ info = f.get_broker_info(24716)
395
+ print(info['name'], info['phone'], info['email'])
396
+ # Simone Dijkman Makelaardij 075 7725155 info@simonedijkman.nl
397
+
398
+ # Or chain from a listing
399
+ listing = f.get_listing(43333315)
400
+ info = f.get_broker_info(listing)
401
+ ```
402
+
403
+ #### get_broker_listings(broker)
404
+
405
+ Every listing the agency has handled, tagged by status. Useful for analyzing an agency's deal history (sold dates, prices, neighbourhoods).
406
+
407
+ ```python
408
+ listings = f.get_broker_listings(24716)
409
+ sold = [l for l in listings if l['status'] == 'sold']
410
+ for_sale = [l for l in listings if l['status'] == 'for_sale']
411
+ print(f"{len(sold)} sold, {len(for_sale)} active")
412
+ ```
413
+
414
+ **Returns:** Flat list of dicts. Each entry has `status` (`sold`, `for_sale`, `purchased`), `listing_id`, `tiny_id`, `title`, `street`, `house_number`, `postcode`, `city`, `latitude`, `longitude`, `price`, `price_formatted`, `price_condition`, `publication_date`, `transaction_date`, `image_url`, `detail_url`.
415
+
416
+ #### get_broker_reviews(broker)
417
+
418
+ Customer reviews and aggregate scores per agency. The API returns only a representative sample of recent reviews — `number_of_reviews` is the true total.
419
+
420
+ ```python
421
+ r = f.get_broker_reviews(24716)
422
+ print(f"{r['average']}/10 over {r['number_of_reviews']} reviews")
423
+ for review in r['reviews']:
424
+ print(review['date'], review['average'], review['text'][:60])
425
+ ```
426
+
427
+ **Returns:** Dict with `average` (float), `number_of_reviews`, `selectivity_percentage`, a single `highlight` review, and a `reviews` list. Each review has subscores (`expertise`, `local_market_knowledge`, `price_and_quality`, `service_and_guidance`, `average`), `transaction_type`, `text`, and `date`.
428
+
336
429
  ### Listing
337
430
 
338
431
  Listing objects support dict-like access with convenient aliases.
@@ -309,6 +309,99 @@ print(contact['name'], contact['phone'])
309
309
 
310
310
  Raises `LookupError` if the listing has no contact info exposed.
311
311
 
312
+ #### get_contact_form(listing)
313
+
314
+ Get the agency's contact-form availability (which weekdays and times-of-day they accept inquiries through the in-app form).
315
+
316
+ ```python
317
+ form = f.get_contact_form(43333315)
318
+ print(form['days'], form['times_of_day'])
319
+ # ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'] ['Morning', 'Afternoon']
320
+ ```
321
+
322
+ **Returns:** Dict with `office_id`, `office_name`, `days`, `times_of_day`, `is_contacting_enabled`, `is_viewing_planner_enabled`, plus the raw `offices` list.
323
+
324
+ #### get_listing_summary(listing)
325
+
326
+ Lightweight version of `get_listing` (no descriptions, photos, kenmerken). Faster and smaller, useful for batch enrichment.
327
+
328
+ ```python
329
+ summary = f.get_listing_summary(7985628)
330
+ print(summary['title'], summary['price'], summary['energy_label'])
331
+ # Semarangstraat 13 650000 C
332
+ ```
333
+
334
+ **Returns:** A `Listing` object with summary fields (`title`, `price`, `living_area`, `plot_area`, `bedrooms`, `energy_label`, `broker_name`, `url`, `thumbnail_url`, …).
335
+
336
+ #### get_similar_listings(listing)
337
+
338
+ Get globalIds of similar / recently-sold listings near a given listing. Returns IDs only; combine with `get_listing_summary` or `get_listing` to materialize them.
339
+
340
+ ```python
341
+ sim = f.get_similar_listings(7988952)
342
+ for gid in sim['recently_sold']:
343
+ print(f.get_listing_summary(gid)['title'])
344
+ ```
345
+
346
+ **Returns:** Dict with `recently_listed` and `recently_sold`, each a list of integer globalIds.
347
+
348
+ #### get_market_insights(city, neighbourhood)
349
+
350
+ Neighbourhood demographics and average asking €/m² for a (city, neighbourhood) pair. Accepts a `Listing` directly to use its city/neighbourhood automatically.
351
+
352
+ ```python
353
+ mi = f.get_market_insights('Amsterdam', 'Twiske-West')
354
+ # {'city': 'Amsterdam', 'neighbourhood': 'Twiske-West',
355
+ # 'inhabitants': 2510, 'families_with_children_pct': 43.96,
356
+ # 'avg_asking_price_per_m2': 5975}
357
+
358
+ # Or pass a Listing
359
+ listing = f.get_listing(43333315)
360
+ mi = f.get_market_insights(listing)
361
+ ```
362
+
363
+ **Returns:** Dict with `city`, `neighbourhood`, `inhabitants`, `families_with_children_pct`, `avg_asking_price_per_m2`. Raises `LookupError` for unknown neighbourhoods (HTTP 204).
364
+
365
+ #### get_broker_info(broker)
366
+
367
+ Get the agency's profile page: phone, email, website, postal address, affiliation (NVM/VBO/…), description, certificates, languages, services. Accepts a numeric `broker_id` or a `Listing` (uses its `broker_id` automatically).
368
+
369
+ ```python
370
+ info = f.get_broker_info(24716)
371
+ print(info['name'], info['phone'], info['email'])
372
+ # Simone Dijkman Makelaardij 075 7725155 info@simonedijkman.nl
373
+
374
+ # Or chain from a listing
375
+ listing = f.get_listing(43333315)
376
+ info = f.get_broker_info(listing)
377
+ ```
378
+
379
+ #### get_broker_listings(broker)
380
+
381
+ Every listing the agency has handled, tagged by status. Useful for analyzing an agency's deal history (sold dates, prices, neighbourhoods).
382
+
383
+ ```python
384
+ listings = f.get_broker_listings(24716)
385
+ sold = [l for l in listings if l['status'] == 'sold']
386
+ for_sale = [l for l in listings if l['status'] == 'for_sale']
387
+ print(f"{len(sold)} sold, {len(for_sale)} active")
388
+ ```
389
+
390
+ **Returns:** Flat list of dicts. Each entry has `status` (`sold`, `for_sale`, `purchased`), `listing_id`, `tiny_id`, `title`, `street`, `house_number`, `postcode`, `city`, `latitude`, `longitude`, `price`, `price_formatted`, `price_condition`, `publication_date`, `transaction_date`, `image_url`, `detail_url`.
391
+
392
+ #### get_broker_reviews(broker)
393
+
394
+ Customer reviews and aggregate scores per agency. The API returns only a representative sample of recent reviews — `number_of_reviews` is the true total.
395
+
396
+ ```python
397
+ r = f.get_broker_reviews(24716)
398
+ print(f"{r['average']}/10 over {r['number_of_reviews']} reviews")
399
+ for review in r['reviews']:
400
+ print(review['date'], review['average'], review['text'][:60])
401
+ ```
402
+
403
+ **Returns:** Dict with `average` (float), `number_of_reviews`, `selectivity_percentage`, a single `highlight` review, and a `reviews` list. Each review has subscores (`expertise`, `local_market_knowledge`, `price_and_quality`, `service_and_guidance`, `average`), `transaction_type`, `text`, and `date`.
404
+
312
405
  ### Listing
313
406
 
314
407
  Listing objects support dict-like access with convenient aliases.
@@ -17,6 +17,13 @@ API_LISTING = f"{API_BASE}/{{listing_id}}"
17
17
  API_LISTING_TINY = f"{API_BASE}/tinyId/{{tiny_id}}"
18
18
  API_SEARCH = "https://listing-search-wonen.funda.io/_msearch/template"
19
19
  API_CONTACTS = "https://contacts-flows-bff.funda.io/api/v1/contacts-flows/listings/{listing_id}/contact-block"
20
+ API_CONTACT_FORM = "https://contacts-bff.funda.io/api/v4/contact/listings/{listing_id}/contact-form"
21
+ API_LISTING_SUMMARY = "https://listing-detail-summary.funda.io/api/v1/listing/nl/{global_id}"
22
+ API_SIMILAR = "https://local-listings.funda.io/api/v1/similarlistings"
23
+ API_MARKET_INSIGHTS = "https://marketinsights.funda.io/v2/localinsights/preview/{city}/{neighbourhood}"
24
+ API_BROKER_INFO = "https://brokerpresentation-office-pages-bff.funda.io/api/v3.0/office-page/Wonen/{broker_id}/nl"
25
+ API_BROKER_LISTINGS = "https://brokerpresentation-office-pages-bff.funda.io/api/v3.0/office-page/Wonen/{broker_id}/nl/listings"
26
+ API_BROKER_REVIEWS = "https://reviews-office-pages-bff.funda.io/api/v1/office-page/{broker_id}/reviews/nl"
20
27
  API_WALTER = "https://api.walterliving.com/hunter/lookup"
21
28
 
22
29
  # Funda mobile app JA3 fingerprints (captured from real Dart/Flutter app traffic)
@@ -793,6 +800,35 @@ class Funda:
793
800
 
794
801
  return changes
795
802
 
803
+ def _resolve_global_id(self, listing: "Listing | int | str") -> int:
804
+ """Resolve any listing identifier to a numeric globalId.
805
+
806
+ Funda has two id systems: the 7-digit ``globalId`` used internally by
807
+ most APIs, and the 8-9 digit ``tinyId`` shown in funda.nl URLs. Many
808
+ endpoints (contact-block, listing-detail-summary, similarlistings,
809
+ contact-form) only accept the globalId, so tinyIds and URLs need to
810
+ be resolved through ``get_listing`` first.
811
+ """
812
+ if isinstance(listing, Listing):
813
+ gid = listing.get("global_id")
814
+ if not gid:
815
+ raise ValueError("Listing has no global_id")
816
+ return int(gid)
817
+ if isinstance(listing, str) and "funda.nl" in listing:
818
+ gid = self.get_listing(listing).get("global_id")
819
+ if not gid:
820
+ raise ValueError("Could not resolve URL to global_id")
821
+ return int(gid)
822
+ id_str = str(listing)
823
+ if not id_str.isdigit():
824
+ raise ValueError(f"Unrecognized listing identifier: {listing!r}")
825
+ if len(id_str) >= 8: # tinyId — needs resolution
826
+ gid = self.get_listing(int(id_str)).get("global_id")
827
+ if not gid:
828
+ raise ValueError(f"Could not resolve tinyId {id_str}")
829
+ return int(gid)
830
+ return int(id_str)
831
+
796
832
  def get_contact_info(self, listing: "Listing | int | str") -> dict:
797
833
  """Get realtor/makelaar contact info for a listing.
798
834
 
@@ -820,27 +856,7 @@ class Funda:
820
856
  >>> contact['name'], contact['phone']
821
857
  ('Scheffer Makelaardij B.V.', '020-2470322')
822
858
  """
823
- # Resolve to global_id (the endpoint expects the numeric listingId,
824
- # not the tinyId shown in funda.nl URLs).
825
- global_id: int | None = None
826
- if isinstance(listing, Listing):
827
- global_id = listing.get("global_id")
828
- elif isinstance(listing, str) and "funda.nl" in listing:
829
- global_id = self.get_listing(listing).get("global_id")
830
- else:
831
- id_str = str(listing)
832
- if not id_str.isdigit():
833
- raise ValueError(f"Unrecognized listing identifier: {listing!r}")
834
- # tinyIds are 8-9 digits; resolve them to a globalId via the
835
- # listing detail endpoint. 7-digit ids are already globalIds.
836
- if len(id_str) >= 8:
837
- global_id = self.get_listing(int(id_str)).get("global_id")
838
- else:
839
- global_id = int(id_str)
840
-
841
- if not global_id:
842
- raise ValueError("Could not determine listing globalId")
843
-
859
+ global_id = self._resolve_global_id(listing)
844
860
  url = API_CONTACTS.format(listing_id=global_id)
845
861
  headers = _make_headers()
846
862
  response = self._get(url, headers)
@@ -854,6 +870,287 @@ class Funda:
854
870
 
855
871
  return self._parse_contact_info(response.json())
856
872
 
873
+ def get_contact_form(self, listing: "Listing | int | str") -> dict:
874
+ """Get contact-form availability (days and times-of-day) for a listing.
875
+
876
+ Companion to ``get_contact_info``. Tells you which weekdays and
877
+ times-of-day the agency accepts inquiries through the in-app form.
878
+
879
+ Returns:
880
+ Dict with the primary office hoisted to the top level
881
+ (``office_id``, ``office_name``, ``days``, ``times_of_day``,
882
+ ``is_contacting_enabled``, ``is_viewing_planner_enabled``) plus
883
+ the raw ``offices`` list.
884
+
885
+ Example:
886
+ >>> form = f.get_contact_form(43333315)
887
+ >>> form['days']
888
+ ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']
889
+ >>> form['times_of_day']
890
+ ['Morning', 'Afternoon']
891
+ """
892
+ global_id = self._resolve_global_id(listing)
893
+ url = API_CONTACT_FORM.format(listing_id=global_id)
894
+ headers = _make_headers()
895
+ response = self._get(url, headers)
896
+
897
+ if response.status_code == 204:
898
+ raise LookupError(f"Listing {global_id} has no contact form")
899
+ if response.status_code != 200:
900
+ raise LookupError(
901
+ f"Could not fetch contact form (status {response.status_code})"
902
+ )
903
+
904
+ data = response.json()
905
+ if not data:
906
+ raise LookupError(f"No contact form entries for listing {global_id}")
907
+
908
+ primary = data[0]
909
+ return {
910
+ "office_id": primary.get("officeId"),
911
+ "office_name": primary.get("officeName"),
912
+ "is_contacting_enabled": primary.get("isContactingEnabled"),
913
+ "is_viewing_planner_enabled": primary.get("isViewingPlannerEnabled"),
914
+ "days": primary.get("days", []),
915
+ "times_of_day": primary.get("timesOfDay", []),
916
+ "offices": data,
917
+ }
918
+
919
+ def get_listing_summary(self, listing: "Listing | int | str") -> Listing:
920
+ """Get a lightweight summary of a listing.
921
+
922
+ Faster and smaller than ``get_listing`` (no descriptions, photos, or
923
+ kenmerken). Useful for batch enrichment, e.g. fetching a row of
924
+ ``get_similar_listings`` results.
925
+
926
+ Returns:
927
+ Listing object with summary fields populated.
928
+
929
+ Example:
930
+ >>> summary = f.get_listing_summary(7985628)
931
+ >>> summary['title'], summary['price'], summary['energy_label']
932
+ ('Semarangstraat 13', 650000, 'C')
933
+ """
934
+ global_id = self._resolve_global_id(listing)
935
+ url = API_LISTING_SUMMARY.format(global_id=global_id)
936
+ headers = _make_headers()
937
+ response = self._get(url, headers)
938
+
939
+ if response.status_code == 404:
940
+ raise LookupError(f"Listing summary {global_id} not found")
941
+ if response.status_code != 200:
942
+ raise LookupError(
943
+ f"Could not fetch listing summary (status {response.status_code})"
944
+ )
945
+
946
+ return self._parse_listing_summary(response.json())
947
+
948
+ def get_similar_listings(self, listing: "Listing | int | str") -> dict:
949
+ """Get globalIds of similar / recently-sold listings near this one.
950
+
951
+ Returns IDs only. Combine with ``get_listing_summary`` or
952
+ ``get_listing`` to materialize them.
953
+
954
+ Returns:
955
+ Dict with ``recently_listed`` and ``recently_sold``, each a list
956
+ of integer globalIds.
957
+
958
+ Example:
959
+ >>> sim = f.get_similar_listings(7988952)
960
+ >>> [f.get_listing_summary(gid)['title'] for gid in sim['recently_sold'][:3]]
961
+ """
962
+ global_id = self._resolve_global_id(listing)
963
+ url = f"{API_SIMILAR}?globalId={global_id}"
964
+ headers = _make_headers()
965
+ response = self._get(url, headers)
966
+
967
+ if response.status_code != 200:
968
+ raise LookupError(
969
+ f"Could not fetch similar listings (status {response.status_code})"
970
+ )
971
+
972
+ data = response.json()
973
+ return {
974
+ "recently_listed": [int(x["globalId"]) for x in data.get("recentlyListed", []) if x.get("globalId")],
975
+ "recently_sold": [int(x["globalId"]) for x in data.get("recentlySold", []) if x.get("globalId")],
976
+ }
977
+
978
+ def get_market_insights(
979
+ self,
980
+ city: "str | Listing",
981
+ neighbourhood: str | None = None,
982
+ ) -> dict:
983
+ """Get neighbourhood demographics and average €/m² for a location.
984
+
985
+ Args:
986
+ city: Either a city name (string) or a Listing object, in which
987
+ case ``city`` and ``neighbourhood`` are taken from the
988
+ listing automatically.
989
+ neighbourhood: Neighbourhood name. Required if ``city`` is a
990
+ string; ignored if ``city`` is a Listing.
991
+
992
+ Returns:
993
+ Dict with ``city``, ``neighbourhood``, ``inhabitants``,
994
+ ``families_with_children_pct``, ``avg_asking_price_per_m2``.
995
+
996
+ Example:
997
+ >>> f.get_market_insights('Amsterdam', 'Twiske-West')
998
+ {'city': 'Amsterdam', 'neighbourhood': 'Twiske-West',
999
+ 'inhabitants': 2510, 'families_with_children_pct': 43.96,
1000
+ 'avg_asking_price_per_m2': 5975}
1001
+ >>> # Or pass a Listing directly
1002
+ >>> listing = f.get_listing(43333315)
1003
+ >>> f.get_market_insights(listing)
1004
+ """
1005
+ if isinstance(city, Listing):
1006
+ city_name = city.get("city") or ""
1007
+ nb_name = city.get("neighbourhood") or ""
1008
+ if not city_name or not nb_name:
1009
+ raise ValueError(
1010
+ "Listing must have city and neighbourhood for market insights"
1011
+ )
1012
+ else:
1013
+ if not neighbourhood:
1014
+ raise ValueError("neighbourhood is required when city is a string")
1015
+ city_name = city
1016
+ nb_name = neighbourhood
1017
+
1018
+ city_slug = city_name.lower().replace(" ", "-")
1019
+ nb_slug = nb_name.lower().replace(" ", "-")
1020
+
1021
+ url = API_MARKET_INSIGHTS.format(city=city_slug, neighbourhood=nb_slug)
1022
+ headers = _make_headers()
1023
+ response = self._get(url, headers)
1024
+
1025
+ if response.status_code == 204:
1026
+ raise LookupError(
1027
+ f"No market insights for {city_slug}/{nb_slug}"
1028
+ )
1029
+ if response.status_code != 200:
1030
+ raise LookupError(
1031
+ f"Could not fetch market insights (status {response.status_code})"
1032
+ )
1033
+
1034
+ data = response.json()
1035
+ return {
1036
+ "city": data.get("city"),
1037
+ "neighbourhood": data.get("neighbourhood"),
1038
+ "inhabitants": data.get("inhabitants"),
1039
+ "families_with_children_pct": data.get("familiesWithChildren"),
1040
+ "avg_asking_price_per_m2": data.get("averageAskingPricePerM2"),
1041
+ }
1042
+
1043
+ def _resolve_broker_id(self, broker: "Listing | int | str") -> int:
1044
+ """Resolve a broker reference (Listing, int, or numeric str) to an id.
1045
+
1046
+ The broker id (Funda calls it ``officeId``, internally ``broker_id``
1047
+ on Listing objects) is shared across the broker-page endpoints.
1048
+ """
1049
+ if isinstance(broker, Listing):
1050
+ bid = broker.get("broker_id")
1051
+ if not bid:
1052
+ raise ValueError("Listing has no broker_id")
1053
+ return int(bid)
1054
+ id_str = str(broker)
1055
+ if not id_str.isdigit():
1056
+ raise ValueError(f"Unrecognized broker identifier: {broker!r}")
1057
+ return int(id_str)
1058
+
1059
+ def get_broker_info(self, broker: "Listing | int | str") -> dict:
1060
+ """Get a broker/agency profile page (phone, email, website, etc).
1061
+
1062
+ Args:
1063
+ broker: A Listing object (uses its ``broker_id``) or a numeric
1064
+ broker id (also called ``officeId`` in the Funda app).
1065
+
1066
+ Returns:
1067
+ Dict with the agency's display name, phone, email, website,
1068
+ postal address, affiliation (NVM/VBO/…), description (HTML),
1069
+ certificates, languages, services, and logo image URL set.
1070
+
1071
+ Raises:
1072
+ LookupError: Unknown broker id (HTTP 204) or non-200.
1073
+
1074
+ Example:
1075
+ >>> info = f.get_broker_info(24716)
1076
+ >>> info['name'], info['phone'], info['email']
1077
+ ('Simone Dijkman Makelaardij', '075 7725155', 'info@simonedijkman.nl')
1078
+ """
1079
+ broker_id = self._resolve_broker_id(broker)
1080
+ url = API_BROKER_INFO.format(broker_id=broker_id)
1081
+ headers = _make_headers()
1082
+ response = self._get(url, headers)
1083
+
1084
+ if response.status_code == 204:
1085
+ raise LookupError(f"Broker {broker_id} not found")
1086
+ if response.status_code != 200:
1087
+ raise LookupError(
1088
+ f"Could not fetch broker info (status {response.status_code})"
1089
+ )
1090
+
1091
+ return self._parse_broker_info(response.json())
1092
+
1093
+ def get_broker_listings(self, broker: "Listing | int | str") -> list[dict]:
1094
+ """Get every listing handled by a broker (sold, for-sale, purchased).
1095
+
1096
+ Returns a flat list of listing entries with a ``status`` field
1097
+ ("sold", "for_sale", or "purchased"). Each entry includes price,
1098
+ address, lat/lng, image, dates, and the ``tiny_id`` parsed from the
1099
+ detail URL.
1100
+
1101
+ Returns:
1102
+ List of dicts, one per listing, sorted as the API returns them.
1103
+
1104
+ Example:
1105
+ >>> listings = f.get_broker_listings(24716)
1106
+ >>> sold = [l for l in listings if l['status'] == 'sold']
1107
+ >>> len(sold), sold[0]['title'], sold[0]['transaction_date']
1108
+ """
1109
+ broker_id = self._resolve_broker_id(broker)
1110
+ url = API_BROKER_LISTINGS.format(broker_id=broker_id)
1111
+ headers = _make_headers()
1112
+ response = self._get(url, headers)
1113
+
1114
+ if response.status_code == 204:
1115
+ raise LookupError(f"Broker {broker_id} has no listings")
1116
+ if response.status_code != 200:
1117
+ raise LookupError(
1118
+ f"Could not fetch broker listings (status {response.status_code})"
1119
+ )
1120
+
1121
+ return self._parse_broker_listings(response.json())
1122
+
1123
+ def get_broker_reviews(self, broker: "Listing | int | str") -> dict:
1124
+ """Get customer reviews and aggregate scores for a broker.
1125
+
1126
+ Note that the API only returns a small sample of recent reviews,
1127
+ not the full set — ``number_of_reviews`` reflects the total, while
1128
+ ``reviews`` is a representative slice.
1129
+
1130
+ Returns:
1131
+ Dict with aggregate ``average`` (float), ``number_of_reviews``,
1132
+ ``selectivity_percentage``, a ``highlight`` review, and a list
1133
+ of ``reviews`` (each with subscores, transaction_type, text).
1134
+
1135
+ Example:
1136
+ >>> r = f.get_broker_reviews(24716)
1137
+ >>> r['average'], r['number_of_reviews']
1138
+ (9.3, 26)
1139
+ """
1140
+ broker_id = self._resolve_broker_id(broker)
1141
+ url = API_BROKER_REVIEWS.format(broker_id=broker_id)
1142
+ headers = _make_headers()
1143
+ response = self._get(url, headers)
1144
+
1145
+ if response.status_code == 204:
1146
+ raise LookupError(f"Broker {broker_id} has no reviews")
1147
+ if response.status_code != 200:
1148
+ raise LookupError(
1149
+ f"Could not fetch broker reviews (status {response.status_code})"
1150
+ )
1151
+
1152
+ return self._parse_broker_reviews(response.json())
1153
+
857
1154
  def _parse_contact_info(self, data: dict) -> dict:
858
1155
  """Normalize the contact-block payload into a flat dict."""
859
1156
  brokers = [
@@ -895,6 +1192,169 @@ class Funda:
895
1192
 
896
1193
  return result
897
1194
 
1195
+ def _parse_listing_summary(self, data: dict) -> Listing:
1196
+ """Parse the lightweight listing-detail-summary payload."""
1197
+ ids = data.get("identifiers", {})
1198
+ addr = data.get("address", {})
1199
+ fast = data.get("fastView", {})
1200
+ price = data.get("price", {})
1201
+ media = data.get("media", {})
1202
+ brokers = data.get("brokers", []) or []
1203
+ primary_broker = brokers[0] if brokers else {}
1204
+ urls = data.get("urls", {}).get("friendlyUrl", {})
1205
+ promo = data.get("promo", {}).get("blikvanger", {}) or {}
1206
+ tracking = data.get("tracking", {}).get("values", {}) or {}
1207
+
1208
+ photo_base = media.get("thumbnailBaseUrl", "") or ""
1209
+ photo_id = media.get("id")
1210
+ thumbnail_url = None
1211
+ if photo_base and photo_id:
1212
+ thumbnail_url = photo_base.replace("{id}", str(photo_id)).replace(
1213
+ "{size}", "720x480"
1214
+ )
1215
+
1216
+ listing_data = {
1217
+ "global_id": ids.get("globalId"),
1218
+ "tiny_id": ids.get("tinyId"),
1219
+ "title": addr.get("title"),
1220
+ "subtitle": addr.get("subTitle"),
1221
+ "city": addr.get("city"),
1222
+ "postcode": addr.get("postCode"),
1223
+ "price_formatted": price.get("sellingPrice") or price.get("rentalPrice"),
1224
+ "price": tracking.get("listing_askingprice"),
1225
+ "living_area_formatted": fast.get("livingArea"),
1226
+ "living_area": _parse_area(fast.get("livingArea")),
1227
+ "plot_area_formatted": fast.get("plotArea"),
1228
+ "plot_area": _parse_area(fast.get("plotArea")),
1229
+ "bedrooms": fast.get("numberOfBedrooms"),
1230
+ "energy_label": fast.get("energyLabel"),
1231
+ "object_type": tracking.get("listing_type"),
1232
+ "offering_type": tracking.get("listing_offering_type"),
1233
+ "status": tracking.get("listing_status"),
1234
+ "is_sold_or_rented": data.get("isSoldOrRented"),
1235
+ "publication_date": data.get("publicationDate"),
1236
+ "highlight": promo.get("text"),
1237
+ "broker_id": primary_broker.get("officeId"),
1238
+ "broker_name": primary_broker.get("name"),
1239
+ "url": urls.get("fullUrl"),
1240
+ "share_url": data.get("share", {}).get("url"),
1241
+ "thumbnail_url": thumbnail_url,
1242
+ }
1243
+
1244
+ return Listing(
1245
+ listing_id=ids.get("tinyId") or ids.get("globalId"),
1246
+ data=listing_data,
1247
+ )
1248
+
1249
+ def _parse_broker_info(self, data: dict) -> dict:
1250
+ """Normalize the broker office-page payload."""
1251
+ office = data.get("officeId", {}) or {}
1252
+ desc = data.get("description", {}) or {}
1253
+ contact = data.get("contactDetails", {}) or {}
1254
+ addr = contact.get("address", {}) or {}
1255
+ media = data.get("mediaReferences", {}) or {}
1256
+ chars = data.get("characteristics", {}) or {}
1257
+
1258
+ return {
1259
+ "broker_id": office.get("officeNumber"),
1260
+ "uuid": office.get("id"),
1261
+ "name": data.get("displayName"),
1262
+ "affiliation": data.get("affiliation"),
1263
+ "slogan": desc.get("slogan"),
1264
+ "description": desc.get("description"),
1265
+ "short_description": desc.get("shortDescription"),
1266
+ "email": contact.get("email"),
1267
+ "website": contact.get("websiteUrl"),
1268
+ "phone": contact.get("phoneNumber"),
1269
+ "address": {
1270
+ "street": addr.get("street"),
1271
+ "number": addr.get("number"),
1272
+ "addition": addr.get("addition"),
1273
+ "postcode": addr.get("postcode"),
1274
+ "city": addr.get("location"),
1275
+ },
1276
+ "logo_urls": media.get("logoImages") or None,
1277
+ "languages": chars.get("languages", []),
1278
+ "services": chars.get("services", []),
1279
+ "certificates": chars.get("certificates", []),
1280
+ }
1281
+
1282
+ def _parse_broker_listings(self, data: dict) -> list[dict]:
1283
+ """Flatten the broker-listings payload into a list tagged by status."""
1284
+ status_map = {
1285
+ "Sold": "sold",
1286
+ "ForSale": "for_sale",
1287
+ "Purchased": "purchased",
1288
+ }
1289
+ tiny_re = re.compile(r"/detail/(\d+)/?")
1290
+
1291
+ out = []
1292
+ for group in data.get("offering", []) or []:
1293
+ api_status = group.get("type")
1294
+ status = status_map.get(api_status, api_status.lower() if api_status else None)
1295
+ for item in group.get("listings", []) or []:
1296
+ loc = item.get("location", {}) or {}
1297
+ addr = loc.get("address", {}) or {}
1298
+ detail_url = item.get("detailUrl") or ""
1299
+ tiny_match = tiny_re.search(detail_url)
1300
+ street = addr.get("street") or ""
1301
+ number = addr.get("number") or ""
1302
+ addition = addr.get("addition") or ""
1303
+ title = f"{street} {number}".strip()
1304
+ if addition:
1305
+ title = f"{title}-{addition}" if number else f"{title} {addition}".strip()
1306
+ out.append({
1307
+ "status": status,
1308
+ "listing_id": item.get("listingId"),
1309
+ "tiny_id": tiny_match.group(1) if tiny_match else None,
1310
+ "title": title or None,
1311
+ "street": addr.get("street"),
1312
+ "house_number": addr.get("number"),
1313
+ "house_number_ext": addr.get("addition") or None,
1314
+ "postcode": addr.get("postcode"),
1315
+ "city": addr.get("city"),
1316
+ "latitude": loc.get("latitude"),
1317
+ "longitude": loc.get("longitude"),
1318
+ "price": item.get("price"),
1319
+ "price_formatted": item.get("formattedPrice"),
1320
+ "price_type": item.get("priceType"),
1321
+ "price_condition": item.get("priceCondition"),
1322
+ "publication_date": item.get("publicationDate"),
1323
+ "transaction_date": item.get("transactionDate"),
1324
+ "image_url": item.get("image"),
1325
+ "detail_url": (
1326
+ f"https://www.funda.nl{detail_url}" if detail_url.startswith("/") else detail_url
1327
+ ) or None,
1328
+ })
1329
+ return out
1330
+
1331
+ def _parse_broker_reviews(self, data: dict) -> dict:
1332
+ """Normalize the broker reviews payload."""
1333
+ scores = data.get("scores", {}) or {}
1334
+
1335
+ def _flatten(review: dict | None) -> dict | None:
1336
+ if not review:
1337
+ return None
1338
+ score = review.get("score", {}) or {}
1339
+ return {
1340
+ "date": review.get("postedDate"),
1341
+ "transaction_type": review.get("transactionType"),
1342
+ "text": review.get("text"),
1343
+ "average": score.get("average"),
1344
+ "expertise": score.get("expertise"),
1345
+ "local_market_knowledge": score.get("localMarketKnowledge"),
1346
+ "price_and_quality": score.get("priceAndQuality"),
1347
+ "service_and_guidance": score.get("serviceAndGuidance"),
1348
+ }
1349
+
1350
+ return {
1351
+ "average": scores.get("average"),
1352
+ "number_of_reviews": scores.get("numberOfReviews"),
1353
+ "selectivity_percentage": scores.get("selectivityPercentage"),
1354
+ "highlight": _flatten(data.get("highlightedReview")),
1355
+ "reviews": [_flatten(r) for r in data.get("reviews", []) or []],
1356
+ }
1357
+
898
1358
  def _parse_search_results(self, data: dict) -> list[Listing]:
899
1359
  """Parse search API response into list of Listings."""
900
1360
  listings = []
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyfunda"
7
- version = "2.7.0"
7
+ version = "2.9.0"
8
8
  description = "Python API for Funda.nl real estate listings"
9
9
  readme = "README.md"
10
10
  license = "AGPL-3.0-or-later"
@@ -811,6 +811,199 @@ def test_get_contact_info_not_found():
811
811
  print(f" Correctly raised: {e}")
812
812
 
813
813
 
814
+ # =============================================================================
815
+ # FLOW 19: Contact Form Availability
816
+ # =============================================================================
817
+
818
+ @test("Get contact form returns days and times of day")
819
+ def test_get_contact_form():
820
+ from funda import Funda
821
+ f = Funda()
822
+ form = f.get_contact_form(7988952)
823
+
824
+ assert form['office_name'], "Should have office name"
825
+ assert form['office_id'], "Should have office id"
826
+ assert isinstance(form['days'], list) and form['days'], "Should have days list"
827
+ assert isinstance(form['times_of_day'], list) and form['times_of_day'], "Should have times_of_day"
828
+ assert isinstance(form['offices'], list) and form['offices'], "Should expose raw offices list"
829
+ print(f" {form['office_name']} | days={form['days']} | times={form['times_of_day']}")
830
+
831
+
832
+ # =============================================================================
833
+ # FLOW 20: Lightweight Listing Summary
834
+ # =============================================================================
835
+
836
+ @test("Get listing summary by globalId")
837
+ def test_get_listing_summary_by_global_id():
838
+ from funda import Funda, Listing
839
+ f = Funda()
840
+ summary = f.get_listing_summary(7985628)
841
+
842
+ assert isinstance(summary, Listing), "Should return Listing instance"
843
+ assert summary['title'], "Should have title"
844
+ assert summary['price'], "Should have numeric price"
845
+ assert summary['energy_label'], "Should have energy_label"
846
+ assert summary['url'].startswith("https://www.funda.nl/"), "Should have full URL"
847
+ assert summary['broker_name'], "Should have broker_name"
848
+ print(f" {summary['title']} | €{summary['price']} | label {summary['energy_label']}")
849
+
850
+
851
+ @test("Get listing summary resolves tinyId via get_listing")
852
+ def test_get_listing_summary_by_tiny_id():
853
+ from funda import Funda
854
+ f = Funda()
855
+ summary = f.get_listing_summary(43333315)
856
+
857
+ assert summary['global_id'] == 7988952, "tinyId should resolve to globalId 7988952"
858
+
859
+
860
+ @test("Get listing summary raises LookupError for unknown id")
861
+ def test_get_listing_summary_not_found():
862
+ from funda import Funda
863
+ f = Funda()
864
+
865
+ try:
866
+ f.get_listing_summary(1)
867
+ assert False, "Should have raised LookupError"
868
+ except LookupError as e:
869
+ print(f" Correctly raised: {e}")
870
+
871
+
872
+ # =============================================================================
873
+ # FLOW 21: Similar Listings
874
+ # =============================================================================
875
+
876
+ @test("Get similar listings returns globalId lists")
877
+ def test_get_similar_listings():
878
+ from funda import Funda
879
+ f = Funda()
880
+ sim = f.get_similar_listings(7988952)
881
+
882
+ assert "recently_listed" in sim and "recently_sold" in sim, "Should have both keys"
883
+ assert all(isinstance(x, int) for x in sim['recently_listed']), "recently_listed should be list of ints"
884
+ assert all(isinstance(x, int) for x in sim['recently_sold']), "recently_sold should be list of ints"
885
+ print(f" listed={sim['recently_listed']} sold={sim['recently_sold']}")
886
+
887
+
888
+ # =============================================================================
889
+ # FLOW 22: Local Market Insights
890
+ # =============================================================================
891
+
892
+ @test("Get market insights by city and neighbourhood")
893
+ def test_get_market_insights_by_strings():
894
+ from funda import Funda
895
+ f = Funda()
896
+ mi = f.get_market_insights('Amsterdam', 'Twiske-West')
897
+
898
+ assert mi['city'] == 'Amsterdam', "city should round-trip"
899
+ assert mi['neighbourhood'] == 'Twiske-West', "neighbourhood should round-trip"
900
+ assert isinstance(mi['inhabitants'], int) and mi['inhabitants'] > 0
901
+ assert isinstance(mi['avg_asking_price_per_m2'], (int, float)) and mi['avg_asking_price_per_m2'] > 0
902
+ print(f" {mi['city']}/{mi['neighbourhood']}: {mi['inhabitants']} inhab, €{mi['avg_asking_price_per_m2']}/m²")
903
+
904
+
905
+ @test("Get market insights from a Listing object")
906
+ def test_get_market_insights_from_listing():
907
+ from funda import Funda
908
+ f = Funda()
909
+ listing = f.get_listing(43333315)
910
+ mi = f.get_market_insights(listing)
911
+
912
+ assert mi['city'], "Should have city"
913
+ assert mi['neighbourhood'], "Should have neighbourhood"
914
+
915
+
916
+ @test("Get market insights raises LookupError for unknown neighbourhood")
917
+ def test_get_market_insights_not_found():
918
+ from funda import Funda
919
+ f = Funda()
920
+
921
+ try:
922
+ f.get_market_insights('Amsterdam', 'Nope-Nope-Nope')
923
+ assert False, "Should have raised LookupError"
924
+ except LookupError as e:
925
+ print(f" Correctly raised: {e}")
926
+
927
+
928
+ # =============================================================================
929
+ # FLOW 23: Broker Profile, Listings, and Reviews
930
+ # =============================================================================
931
+
932
+ @test("Get broker info by id")
933
+ def test_get_broker_info():
934
+ from funda import Funda
935
+ f = Funda()
936
+ info = f.get_broker_info(24716)
937
+
938
+ assert info['name'], "Should have agency name"
939
+ assert info['phone'], "Should have phone"
940
+ assert info['email'], "Should have email"
941
+ assert info['affiliation'], "Should have affiliation"
942
+ assert isinstance(info['address'], dict) and info['address']['city'], "Should have address"
943
+ assert isinstance(info['languages'], list)
944
+ assert isinstance(info['services'], list)
945
+ assert isinstance(info['certificates'], list)
946
+ print(f" {info['name']} | {info['phone']} | {info['email']} ({info['affiliation']})")
947
+
948
+
949
+ @test("Get broker info via Listing object")
950
+ def test_get_broker_info_via_listing():
951
+ from funda import Funda
952
+ f = Funda()
953
+ listing = f.get_listing(43333315)
954
+ info = f.get_broker_info(listing)
955
+
956
+ assert info['broker_id'] == listing.get('broker_id'), "broker_id should round-trip"
957
+ assert info['name'], "Should have agency name"
958
+
959
+
960
+ @test("Get broker info raises LookupError for unknown id")
961
+ def test_get_broker_info_not_found():
962
+ from funda import Funda
963
+ f = Funda()
964
+
965
+ try:
966
+ f.get_broker_info(99999999)
967
+ assert False, "Should have raised LookupError"
968
+ except LookupError as e:
969
+ print(f" Correctly raised: {e}")
970
+
971
+
972
+ @test("Get broker listings returns flat tagged list")
973
+ def test_get_broker_listings():
974
+ from funda import Funda
975
+ f = Funda()
976
+ listings = f.get_broker_listings(24716)
977
+
978
+ assert isinstance(listings, list) and listings, "Should be non-empty list"
979
+ statuses = {l['status'] for l in listings}
980
+ assert statuses, "Should have at least one status"
981
+ assert all(l.get('listing_id') for l in listings), "All entries should have listing_id"
982
+ sold = [l for l in listings if l['status'] == 'sold']
983
+ if sold:
984
+ s = sold[0]
985
+ assert s['transaction_date'], "Sold listings should have transaction_date"
986
+ assert s['tiny_id'], "Should parse tiny_id from detailUrl"
987
+ assert s['detail_url'].startswith('https://www.funda.nl/'), "Should have absolute URL"
988
+ print(f" {len(listings)} listings | statuses={statuses}")
989
+
990
+
991
+ @test("Get broker reviews returns aggregate scores and review list")
992
+ def test_get_broker_reviews():
993
+ from funda import Funda
994
+ f = Funda()
995
+ r = f.get_broker_reviews(24716)
996
+
997
+ assert isinstance(r['average'], (int, float)), "Should have numeric average"
998
+ assert isinstance(r['number_of_reviews'], int) and r['number_of_reviews'] >= 1
999
+ assert isinstance(r['reviews'], list)
1000
+ if r['reviews']:
1001
+ first = r['reviews'][0]
1002
+ for k in ['average', 'expertise', 'local_market_knowledge', 'price_and_quality', 'service_and_guidance']:
1003
+ assert k in first, f"Review should have {k}"
1004
+ print(f" avg={r['average']} | n={r['number_of_reviews']} | selectivity={r['selectivity_percentage']}%")
1005
+
1006
+
814
1007
  # =============================================================================
815
1008
  # Run all tests
816
1009
  # =============================================================================
@@ -909,6 +1102,29 @@ def run_all_tests():
909
1102
  test_get_contact_info_by_listing,
910
1103
  test_get_contact_info_by_tiny_id,
911
1104
  test_get_contact_info_not_found,
1105
+
1106
+ # Flow 19: Contact Form Availability
1107
+ test_get_contact_form,
1108
+
1109
+ # Flow 20: Lightweight Listing Summary
1110
+ test_get_listing_summary_by_global_id,
1111
+ test_get_listing_summary_by_tiny_id,
1112
+ test_get_listing_summary_not_found,
1113
+
1114
+ # Flow 21: Similar Listings
1115
+ test_get_similar_listings,
1116
+
1117
+ # Flow 22: Local Market Insights
1118
+ test_get_market_insights_by_strings,
1119
+ test_get_market_insights_from_listing,
1120
+ test_get_market_insights_not_found,
1121
+
1122
+ # Flow 23: Broker profile, listings, reviews
1123
+ test_get_broker_info,
1124
+ test_get_broker_info_via_listing,
1125
+ test_get_broker_info_not_found,
1126
+ test_get_broker_listings,
1127
+ test_get_broker_reviews,
912
1128
  ]
913
1129
 
914
1130
  start_time = time.time()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes