pyfunda 2.6.2__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.6.2
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
@@ -307,6 +307,125 @@ for change in history:
307
307
 
308
308
  > **Note:** This fetches data from the Walter Living API. Only called when explicitly requested (lazy-loaded).
309
309
 
310
+ #### get_contact_info(listing)
311
+
312
+ Get the realtor/makelaar agency name and phone number for a listing. Accepts a `Listing`, a numeric id (globalId or tinyId), or a Funda URL.
313
+
314
+ ```python
315
+ listing = f.get_listing(43333315)
316
+ contact = f.get_contact_info(listing)
317
+
318
+ print(contact['name'], contact['phone'])
319
+ # Scheffer Makelaardij B.V. 020-2470322
320
+ ```
321
+
322
+ **Returns:** A dict with the primary broker hoisted to the top level:
323
+
324
+ | Field | Description |
325
+ |-------|-------------|
326
+ | `name` | Agency display name |
327
+ | `phone` | Agency phone number |
328
+ | `broker_id` | Numeric office id |
329
+ | `association` | Trade association code (e.g. `VN`) |
330
+ | `is_contacting_enabled` | Whether the in-app contact form is enabled |
331
+ | `listing_id` / `tiny_id` / `listing_status` | Listing meta |
332
+ | `brokers` | Full list of brokers (for the rare multi-agency case) |
333
+
334
+ Raises `LookupError` if the listing has no contact info exposed.
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
+
310
429
  ### Listing
311
430
 
312
431
  Listing objects support dict-like access with convenient aliases.
@@ -283,6 +283,125 @@ for change in history:
283
283
 
284
284
  > **Note:** This fetches data from the Walter Living API. Only called when explicitly requested (lazy-loaded).
285
285
 
286
+ #### get_contact_info(listing)
287
+
288
+ Get the realtor/makelaar agency name and phone number for a listing. Accepts a `Listing`, a numeric id (globalId or tinyId), or a Funda URL.
289
+
290
+ ```python
291
+ listing = f.get_listing(43333315)
292
+ contact = f.get_contact_info(listing)
293
+
294
+ print(contact['name'], contact['phone'])
295
+ # Scheffer Makelaardij B.V. 020-2470322
296
+ ```
297
+
298
+ **Returns:** A dict with the primary broker hoisted to the top level:
299
+
300
+ | Field | Description |
301
+ |-------|-------------|
302
+ | `name` | Agency display name |
303
+ | `phone` | Agency phone number |
304
+ | `broker_id` | Numeric office id |
305
+ | `association` | Trade association code (e.g. `VN`) |
306
+ | `is_contacting_enabled` | Whether the in-app contact form is enabled |
307
+ | `listing_id` / `tiny_id` / `listing_status` | Listing meta |
308
+ | `brokers` | Full list of brokers (for the rare multi-agency case) |
309
+
310
+ Raises `LookupError` if the listing has no contact info exposed.
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
+
286
405
  ### Listing
287
406
 
288
407
  Listing objects support dict-like access with convenient aliases.
@@ -16,6 +16,14 @@ API_BASE = "https://listing-detail-page.funda.io/api/v4/listing/object/nl"
16
16
  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
+ 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"
19
27
  API_WALTER = "https://api.walterliving.com/hunter/lookup"
20
28
 
21
29
  # Funda mobile app JA3 fingerprints (captured from real Dart/Flutter app traffic)
@@ -792,6 +800,561 @@ class Funda:
792
800
 
793
801
  return changes
794
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
+
832
+ def get_contact_info(self, listing: "Listing | int | str") -> dict:
833
+ """Get realtor/makelaar contact info for a listing.
834
+
835
+ Returns the agency name, phone number, and association code as exposed
836
+ by the Funda Android app's contact block endpoint.
837
+
838
+ Args:
839
+ listing: A Listing object, a numeric id (globalId or tinyId), or a
840
+ Funda URL. tinyIds and URLs are resolved to a globalId via
841
+ ``get_listing`` before the contact lookup.
842
+
843
+ Returns:
844
+ Dict with the primary broker hoisted to the top level for
845
+ ergonomics (``name``, ``phone``, ``broker_id``, ``association``)
846
+ plus listing meta (``listing_id``, ``tiny_id``, ``listing_status``)
847
+ and the full ``brokers`` list for the rare multi-agency case.
848
+
849
+ Raises:
850
+ LookupError: Listing exists but has no contact info (HTTP 204) or
851
+ the endpoint returned a non-200 status.
852
+
853
+ Example:
854
+ >>> listing = f.get_listing(43333315)
855
+ >>> contact = f.get_contact_info(listing)
856
+ >>> contact['name'], contact['phone']
857
+ ('Scheffer Makelaardij B.V.', '020-2470322')
858
+ """
859
+ global_id = self._resolve_global_id(listing)
860
+ url = API_CONTACTS.format(listing_id=global_id)
861
+ headers = _make_headers()
862
+ response = self._get(url, headers)
863
+
864
+ if response.status_code == 204:
865
+ raise LookupError(f"Listing {global_id} has no contact info")
866
+ if response.status_code != 200:
867
+ raise LookupError(
868
+ f"Could not fetch contact info (status {response.status_code})"
869
+ )
870
+
871
+ return self._parse_contact_info(response.json())
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
+
1154
+ def _parse_contact_info(self, data: dict) -> dict:
1155
+ """Normalize the contact-block payload into a flat dict."""
1156
+ brokers = [
1157
+ {
1158
+ "broker_id": b.get("id"),
1159
+ "name": b.get("displayName"),
1160
+ "phone": b.get("phoneNumber"),
1161
+ "association": b.get("associationCode"),
1162
+ "logo_url": b.get("logoUrl") or None,
1163
+ "banner_url": b.get("bannerUrl"),
1164
+ "is_contacting_enabled": b.get("isContactingEnabled"),
1165
+ "personal_contact_info": b.get("personalContactBlockInfo"),
1166
+ }
1167
+ for b in data.get("contactBlockDetails", [])
1168
+ ]
1169
+
1170
+ result: dict[str, Any] = {
1171
+ "listing_id": data.get("id"),
1172
+ "tiny_id": data.get("tinyId"),
1173
+ "listing_status": data.get("listingStatus"),
1174
+ "is_viewing_planner_enabled": data.get("isViewingPlannerEnabled"),
1175
+ "brokers": brokers,
1176
+ }
1177
+
1178
+ # Hoist primary broker fields to the top level (single-agency is the
1179
+ # overwhelming common case).
1180
+ if brokers:
1181
+ primary = brokers[0]
1182
+ result.update({
1183
+ "broker_id": primary["broker_id"],
1184
+ "name": primary["name"],
1185
+ "phone": primary["phone"],
1186
+ "association": primary["association"],
1187
+ "logo_url": primary["logo_url"],
1188
+ "banner_url": primary["banner_url"],
1189
+ "is_contacting_enabled": primary["is_contacting_enabled"],
1190
+ "personal_contact_info": primary["personal_contact_info"],
1191
+ })
1192
+
1193
+ return result
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
+
795
1358
  def _parse_search_results(self, data: dict) -> list[Listing]:
796
1359
  """Parse search API response into list of Listings."""
797
1360
  listings = []
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyfunda"
7
- version = "2.6.2"
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"
@@ -757,6 +757,253 @@ def test_listing_setitem():
757
757
  print(" setitem working correctly")
758
758
 
759
759
 
760
+ # =============================================================================
761
+ # FLOW 18: Realtor Contact Info
762
+ # =============================================================================
763
+
764
+ @test("Get contact info by globalId")
765
+ def test_get_contact_info_by_global_id():
766
+ from funda import Funda
767
+ f = Funda()
768
+ contact = f.get_contact_info(7988952)
769
+
770
+ assert contact['name'], "Should have agency name"
771
+ assert contact['phone'], "Should have phone number"
772
+ assert contact['broker_id'], "Should have broker_id"
773
+ assert contact['listing_id'] == '7988952', "Should echo listing_id"
774
+ assert contact['tiny_id'] == '43333315', "Should include tiny_id"
775
+ assert isinstance(contact['brokers'], list) and contact['brokers'], "Should have brokers list"
776
+ print(f" {contact['name']} | {contact['phone']}")
777
+
778
+
779
+ @test("Get contact info by Listing object")
780
+ def test_get_contact_info_by_listing():
781
+ from funda import Funda
782
+ f = Funda()
783
+ listing = f.get_listing(43333315)
784
+ contact = f.get_contact_info(listing)
785
+
786
+ assert contact['phone'], "Should have phone number"
787
+ assert contact['name'], "Should have agency name"
788
+ print(f" {contact['name']} | {contact['phone']}")
789
+
790
+
791
+ @test("Get contact info by tinyId resolves to globalId")
792
+ def test_get_contact_info_by_tiny_id():
793
+ from funda import Funda
794
+ f = Funda()
795
+ contact = f.get_contact_info(43333315)
796
+
797
+ assert contact['listing_id'] == '7988952', "tinyId should resolve to globalId"
798
+ assert contact['phone'], "Should have phone number"
799
+
800
+
801
+ @test("Get contact info raises LookupError for unknown listing")
802
+ def test_get_contact_info_not_found():
803
+ from funda import Funda
804
+ f = Funda()
805
+
806
+ try:
807
+ f.get_contact_info(1)
808
+ assert False, "Should have raised LookupError"
809
+ except LookupError as e:
810
+ assert "no contact info" in str(e).lower() or "could not fetch" in str(e).lower()
811
+ print(f" Correctly raised: {e}")
812
+
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
+
760
1007
  # =============================================================================
761
1008
  # Run all tests
762
1009
  # =============================================================================
@@ -849,6 +1096,35 @@ def run_all_tests():
849
1096
 
850
1097
  # Flow 17: Listing Set Item
851
1098
  test_listing_setitem,
1099
+
1100
+ # Flow 18: Realtor Contact Info
1101
+ test_get_contact_info_by_global_id,
1102
+ test_get_contact_info_by_listing,
1103
+ test_get_contact_info_by_tiny_id,
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,
852
1128
  ]
853
1129
 
854
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