pyfunda 2.6.1__tar.gz → 2.7.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.1 → pyfunda-2.7.0}/PKG-INFO +27 -1
- {pyfunda-2.6.1 → pyfunda-2.7.0}/README.md +26 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/funda/funda.py +112 -1
- {pyfunda-2.6.1 → pyfunda-2.7.0}/pyproject.toml +1 -1
- {pyfunda-2.6.1 → pyfunda-2.7.0}/test_all_flows.py +60 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/.dockerignore +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/.github/FUNDING.yml +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/.github/workflows/publish.yml +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/.gitignore +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/Dockerfile +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/LICENSE +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/examples/analysis.ipynb +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/examples/export_to_csv.py +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/examples/new_listings_alert.py +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/examples/poll_new_listings.py +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/examples/price_history.py +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/examples/price_tracker.py +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/examples/search_sold.py +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/funda/__init__.py +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.0}/funda/listing.py +0 -0
- {pyfunda-2.6.1 → pyfunda-2.7.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.7.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,32 @@ 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
|
+
|
|
310
336
|
### Listing
|
|
311
337
|
|
|
312
338
|
Listing objects support dict-like access with convenient aliases.
|
|
@@ -283,6 +283,32 @@ 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
|
+
|
|
286
312
|
### Listing
|
|
287
313
|
|
|
288
314
|
Listing objects support dict-like access with convenient aliases.
|
|
@@ -16,6 +16,7 @@ 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"
|
|
19
20
|
API_WALTER = "https://api.walterliving.com/hunter/lookup"
|
|
20
21
|
|
|
21
22
|
# Funda mobile app JA3 fingerprints (captured from real Dart/Flutter app traffic)
|
|
@@ -792,6 +793,108 @@ class Funda:
|
|
|
792
793
|
|
|
793
794
|
return changes
|
|
794
795
|
|
|
796
|
+
def get_contact_info(self, listing: "Listing | int | str") -> dict:
|
|
797
|
+
"""Get realtor/makelaar contact info for a listing.
|
|
798
|
+
|
|
799
|
+
Returns the agency name, phone number, and association code as exposed
|
|
800
|
+
by the Funda Android app's contact block endpoint.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
listing: A Listing object, a numeric id (globalId or tinyId), or a
|
|
804
|
+
Funda URL. tinyIds and URLs are resolved to a globalId via
|
|
805
|
+
``get_listing`` before the contact lookup.
|
|
806
|
+
|
|
807
|
+
Returns:
|
|
808
|
+
Dict with the primary broker hoisted to the top level for
|
|
809
|
+
ergonomics (``name``, ``phone``, ``broker_id``, ``association``)
|
|
810
|
+
plus listing meta (``listing_id``, ``tiny_id``, ``listing_status``)
|
|
811
|
+
and the full ``brokers`` list for the rare multi-agency case.
|
|
812
|
+
|
|
813
|
+
Raises:
|
|
814
|
+
LookupError: Listing exists but has no contact info (HTTP 204) or
|
|
815
|
+
the endpoint returned a non-200 status.
|
|
816
|
+
|
|
817
|
+
Example:
|
|
818
|
+
>>> listing = f.get_listing(43333315)
|
|
819
|
+
>>> contact = f.get_contact_info(listing)
|
|
820
|
+
>>> contact['name'], contact['phone']
|
|
821
|
+
('Scheffer Makelaardij B.V.', '020-2470322')
|
|
822
|
+
"""
|
|
823
|
+
# Resolve to global_id (the endpoint expects the numeric listingId,
|
|
824
|
+
# not the tinyId shown in funda.nl URLs).
|
|
825
|
+
global_id: int | None = None
|
|
826
|
+
if isinstance(listing, Listing):
|
|
827
|
+
global_id = listing.get("global_id")
|
|
828
|
+
elif isinstance(listing, str) and "funda.nl" in listing:
|
|
829
|
+
global_id = self.get_listing(listing).get("global_id")
|
|
830
|
+
else:
|
|
831
|
+
id_str = str(listing)
|
|
832
|
+
if not id_str.isdigit():
|
|
833
|
+
raise ValueError(f"Unrecognized listing identifier: {listing!r}")
|
|
834
|
+
# tinyIds are 8-9 digits; resolve them to a globalId via the
|
|
835
|
+
# listing detail endpoint. 7-digit ids are already globalIds.
|
|
836
|
+
if len(id_str) >= 8:
|
|
837
|
+
global_id = self.get_listing(int(id_str)).get("global_id")
|
|
838
|
+
else:
|
|
839
|
+
global_id = int(id_str)
|
|
840
|
+
|
|
841
|
+
if not global_id:
|
|
842
|
+
raise ValueError("Could not determine listing globalId")
|
|
843
|
+
|
|
844
|
+
url = API_CONTACTS.format(listing_id=global_id)
|
|
845
|
+
headers = _make_headers()
|
|
846
|
+
response = self._get(url, headers)
|
|
847
|
+
|
|
848
|
+
if response.status_code == 204:
|
|
849
|
+
raise LookupError(f"Listing {global_id} has no contact info")
|
|
850
|
+
if response.status_code != 200:
|
|
851
|
+
raise LookupError(
|
|
852
|
+
f"Could not fetch contact info (status {response.status_code})"
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
return self._parse_contact_info(response.json())
|
|
856
|
+
|
|
857
|
+
def _parse_contact_info(self, data: dict) -> dict:
|
|
858
|
+
"""Normalize the contact-block payload into a flat dict."""
|
|
859
|
+
brokers = [
|
|
860
|
+
{
|
|
861
|
+
"broker_id": b.get("id"),
|
|
862
|
+
"name": b.get("displayName"),
|
|
863
|
+
"phone": b.get("phoneNumber"),
|
|
864
|
+
"association": b.get("associationCode"),
|
|
865
|
+
"logo_url": b.get("logoUrl") or None,
|
|
866
|
+
"banner_url": b.get("bannerUrl"),
|
|
867
|
+
"is_contacting_enabled": b.get("isContactingEnabled"),
|
|
868
|
+
"personal_contact_info": b.get("personalContactBlockInfo"),
|
|
869
|
+
}
|
|
870
|
+
for b in data.get("contactBlockDetails", [])
|
|
871
|
+
]
|
|
872
|
+
|
|
873
|
+
result: dict[str, Any] = {
|
|
874
|
+
"listing_id": data.get("id"),
|
|
875
|
+
"tiny_id": data.get("tinyId"),
|
|
876
|
+
"listing_status": data.get("listingStatus"),
|
|
877
|
+
"is_viewing_planner_enabled": data.get("isViewingPlannerEnabled"),
|
|
878
|
+
"brokers": brokers,
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
# Hoist primary broker fields to the top level (single-agency is the
|
|
882
|
+
# overwhelming common case).
|
|
883
|
+
if brokers:
|
|
884
|
+
primary = brokers[0]
|
|
885
|
+
result.update({
|
|
886
|
+
"broker_id": primary["broker_id"],
|
|
887
|
+
"name": primary["name"],
|
|
888
|
+
"phone": primary["phone"],
|
|
889
|
+
"association": primary["association"],
|
|
890
|
+
"logo_url": primary["logo_url"],
|
|
891
|
+
"banner_url": primary["banner_url"],
|
|
892
|
+
"is_contacting_enabled": primary["is_contacting_enabled"],
|
|
893
|
+
"personal_contact_info": primary["personal_contact_info"],
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
return result
|
|
897
|
+
|
|
795
898
|
def _parse_search_results(self, data: dict) -> list[Listing]:
|
|
796
899
|
"""Parse search API response into list of Listings."""
|
|
797
900
|
listings = []
|
|
@@ -823,12 +926,20 @@ class Funda:
|
|
|
823
926
|
agents = source.get("agent", [])
|
|
824
927
|
agent = agents[0] if agents else {}
|
|
825
928
|
|
|
929
|
+
street = address.get("street_name") or ""
|
|
930
|
+
number = address.get("house_number") or ""
|
|
931
|
+
suffix = address.get("house_number_suffix") or ""
|
|
932
|
+
title = f"{street} {number}".strip()
|
|
933
|
+
if suffix:
|
|
934
|
+
title = f"{title}-{suffix}" if number else f"{title} {suffix}".strip()
|
|
935
|
+
|
|
826
936
|
listing_data = {
|
|
827
937
|
"global_id": int(hit.get("_id", 0)),
|
|
828
|
-
"title":
|
|
938
|
+
"title": title,
|
|
829
939
|
"street_name": address.get("street_name"),
|
|
830
940
|
"house_number": address.get("house_number"),
|
|
831
941
|
"house_number_suffix": address.get("house_number_suffix"),
|
|
942
|
+
"house_number_ext": address.get("house_number_suffix"),
|
|
832
943
|
"city": address.get("city"),
|
|
833
944
|
"postcode": address.get("postal_code"),
|
|
834
945
|
"province": address.get("province"),
|
|
@@ -757,6 +757,60 @@ 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
|
+
|
|
760
814
|
# =============================================================================
|
|
761
815
|
# Run all tests
|
|
762
816
|
# =============================================================================
|
|
@@ -849,6 +903,12 @@ def run_all_tests():
|
|
|
849
903
|
|
|
850
904
|
# Flow 17: Listing Set Item
|
|
851
905
|
test_listing_setitem,
|
|
906
|
+
|
|
907
|
+
# Flow 18: Realtor Contact Info
|
|
908
|
+
test_get_contact_info_by_global_id,
|
|
909
|
+
test_get_contact_info_by_listing,
|
|
910
|
+
test_get_contact_info_by_tiny_id,
|
|
911
|
+
test_get_contact_info_not_found,
|
|
852
912
|
]
|
|
853
913
|
|
|
854
914
|
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
|