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.
- {pyfunda-2.7.0 → pyfunda-2.9.0}/PKG-INFO +94 -1
- {pyfunda-2.7.0 → pyfunda-2.9.0}/README.md +93 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/funda/funda.py +481 -21
- {pyfunda-2.7.0 → pyfunda-2.9.0}/pyproject.toml +1 -1
- {pyfunda-2.7.0 → pyfunda-2.9.0}/test_all_flows.py +216 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/.dockerignore +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/.github/FUNDING.yml +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/.github/workflows/publish.yml +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/.gitignore +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/Dockerfile +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/LICENSE +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/examples/analysis.ipynb +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/examples/export_to_csv.py +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/examples/new_listings_alert.py +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/examples/poll_new_listings.py +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/examples/price_history.py +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/examples/price_tracker.py +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/examples/search_sold.py +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/funda/__init__.py +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/funda/listing.py +0 -0
- {pyfunda-2.7.0 → pyfunda-2.9.0}/funda/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyfunda
|
|
3
|
-
Version: 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
|
|
@@ -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
|
-
|
|
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 = []
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|