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.
- {pyfunda-2.6.2 → pyfunda-2.9.0}/PKG-INFO +120 -1
- {pyfunda-2.6.2 → pyfunda-2.9.0}/README.md +119 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/funda/funda.py +563 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/pyproject.toml +1 -1
- {pyfunda-2.6.2 → pyfunda-2.9.0}/test_all_flows.py +276 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/.dockerignore +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/.github/FUNDING.yml +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/.github/workflows/publish.yml +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/.gitignore +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/Dockerfile +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/LICENSE +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/examples/analysis.ipynb +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/examples/export_to_csv.py +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/examples/new_listings_alert.py +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/examples/poll_new_listings.py +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/examples/price_history.py +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/examples/price_tracker.py +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/examples/search_sold.py +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/funda/__init__.py +0 -0
- {pyfunda-2.6.2 → pyfunda-2.9.0}/funda/listing.py +0 -0
- {pyfunda-2.6.2 → 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
|
|
@@ -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 = []
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|