pyfunda 2.2.0__tar.gz → 2.2.1__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.2.0 → pyfunda-2.2.1}/PKG-INFO +13 -1
- {pyfunda-2.2.0 → pyfunda-2.2.1}/README.md +12 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/funda/__init__.py +1 -1
- {pyfunda-2.2.0 → pyfunda-2.2.1}/funda/funda.py +67 -17
- {pyfunda-2.2.0 → pyfunda-2.2.1}/pyproject.toml +1 -1
- {pyfunda-2.2.0 → pyfunda-2.2.1}/test_all_flows.py +4 -4
- {pyfunda-2.2.0 → pyfunda-2.2.1}/.github/FUNDING.yml +0 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/.github/workflows/publish.yml +0 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/.gitignore +0 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/LICENSE +0 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/examples/analysis.ipynb +0 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/examples/export_to_csv.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/examples/new_listings_alert.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/examples/poll_new_listings.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/examples/price_history.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/examples/price_tracker.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/funda/listing.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.2.1}/funda/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyfunda
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.1
|
|
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
|
|
@@ -567,6 +567,18 @@ if len(funda_prices) >= 2:
|
|
|
567
567
|
print(f"\nPrice change: {change_pct:+.1f}%")
|
|
568
568
|
```
|
|
569
569
|
|
|
570
|
+
## Disclaimer
|
|
571
|
+
|
|
572
|
+
This is an unofficial library and is not affiliated with, authorized, maintained, sponsored, or endorsed by Funda or any of its affiliates. Use at your own risk.
|
|
573
|
+
|
|
574
|
+
This library only accesses publicly available listing data through Funda's undocumented internal API. Using this library may violate Funda's Terms of Service. The authors are not responsible for any consequences of using this software.
|
|
575
|
+
|
|
576
|
+
This project is intended for personal use, research, and educational purposes only.
|
|
577
|
+
|
|
578
|
+
- The API is undocumented and may change or break at any time without notice.
|
|
579
|
+
- Please use this library responsibly and avoid excessive requests that could burden Funda's infrastructure.
|
|
580
|
+
- Scraped data may be subject to copyright and usage restrictions. Ensure your use complies with applicable laws.
|
|
581
|
+
|
|
570
582
|
## License
|
|
571
583
|
|
|
572
584
|
AGPL-3.0
|
|
@@ -545,6 +545,18 @@ if len(funda_prices) >= 2:
|
|
|
545
545
|
print(f"\nPrice change: {change_pct:+.1f}%")
|
|
546
546
|
```
|
|
547
547
|
|
|
548
|
+
## Disclaimer
|
|
549
|
+
|
|
550
|
+
This is an unofficial library and is not affiliated with, authorized, maintained, sponsored, or endorsed by Funda or any of its affiliates. Use at your own risk.
|
|
551
|
+
|
|
552
|
+
This library only accesses publicly available listing data through Funda's undocumented internal API. Using this library may violate Funda's Terms of Service. The authors are not responsible for any consequences of using this software.
|
|
553
|
+
|
|
554
|
+
This project is intended for personal use, research, and educational purposes only.
|
|
555
|
+
|
|
556
|
+
- The API is undocumented and may change or break at any time without notice.
|
|
557
|
+
- Please use this library responsibly and avoid excessive requests that could burden Funda's infrastructure.
|
|
558
|
+
- Scraped data may be subject to copyright and usage restrictions. Ensure your use complies with applicable laws.
|
|
559
|
+
|
|
548
560
|
## License
|
|
549
561
|
|
|
550
562
|
AGPL-3.0
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"""Main Funda API class."""
|
|
2
2
|
|
|
3
|
+
import random
|
|
3
4
|
import re
|
|
4
5
|
import time
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from curl_cffi import requests
|
|
9
|
+
from curl_cffi.const import CurlHttpVersion
|
|
8
10
|
|
|
9
11
|
from funda.listing import Listing
|
|
10
12
|
|
|
@@ -16,17 +18,47 @@ API_LISTING_TINY = f"{API_BASE}/tinyId/{{tiny_id}}"
|
|
|
16
18
|
API_SEARCH = "https://listing-search-wonen.funda.io/_msearch/template"
|
|
17
19
|
API_WALTER = "https://api.walterliving.com/hunter/lookup"
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
FUNDA_JA3 = "771,4867-4865-4866-52393-52392-49195-49199-49196-49200-49161-49171-49162-49172-156-157-47-53,0-23-65281-10-11-35-13-51-45-43-21,29-23-24,0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _make_headers(host: str, for_search: bool = False) -> list[tuple[str, str]]:
|
|
26
|
+
"""Generate headers matching the Funda Android app."""
|
|
27
|
+
trace_id = str(random.randint(10**18, 10**19))
|
|
28
|
+
parent_id = hex(random.randint(10**15, 10**16))[2:]
|
|
29
|
+
tid = hex(int(time.time()))[2:] + "00000000"
|
|
30
|
+
|
|
31
|
+
headers = [
|
|
32
|
+
("user-agent", "Dart/3.9 (dart:io)"),
|
|
33
|
+
("x-datadog-sampling-priority", "0"),
|
|
34
|
+
("x-datadog-origin", "rum"),
|
|
35
|
+
("tracestate", f"dd=s:0;o:rum;p:{parent_id}"),
|
|
36
|
+
("accept-encoding", "gzip"),
|
|
37
|
+
("x-datadog-parent-id", trace_id),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
if for_search:
|
|
41
|
+
# Search endpoint uses referer and accept instead of x-funda-app-platform
|
|
42
|
+
headers.extend([
|
|
43
|
+
("content-type", "application/json"),
|
|
44
|
+
("referer", "https://www.funda.nl/"),
|
|
45
|
+
("accept", "application/json"),
|
|
46
|
+
])
|
|
47
|
+
else:
|
|
48
|
+
# Listing endpoint uses x-funda-app-platform
|
|
49
|
+
headers.extend([
|
|
50
|
+
("x-funda-app-platform", "android"),
|
|
51
|
+
("content-type", "application/json"),
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
headers.extend([
|
|
55
|
+
("traceparent", f"00-{tid}{trace_id[:16]}-{parent_id}-00"),
|
|
56
|
+
("host", host),
|
|
57
|
+
("x-datadog-tags", f"_dd.p.tid={tid}"),
|
|
58
|
+
("x-datadog-trace-id", trace_id),
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
return headers
|
|
30
62
|
|
|
31
63
|
|
|
32
64
|
def _parse_area(value: str | None) -> int | None:
|
|
@@ -69,8 +101,7 @@ class Funda:
|
|
|
69
101
|
def session(self) -> requests.Session:
|
|
70
102
|
"""Lazily create HTTP session."""
|
|
71
103
|
if self._session is None:
|
|
72
|
-
self._session = requests.Session(
|
|
73
|
-
self._session.headers.update(HEADERS)
|
|
104
|
+
self._session = requests.Session()
|
|
74
105
|
return self._session
|
|
75
106
|
|
|
76
107
|
def close(self) -> None:
|
|
@@ -111,17 +142,26 @@ class Funda:
|
|
|
111
142
|
|
|
112
143
|
# Try tinyId endpoint first (8-9 digits), then globalId (7 digits)
|
|
113
144
|
listing_id_str = str(listing_id)
|
|
145
|
+
host = "listing-detail-page.funda.io"
|
|
114
146
|
if len(listing_id_str) >= 8:
|
|
115
147
|
url = API_LISTING_TINY.format(tiny_id=listing_id_str)
|
|
116
148
|
else:
|
|
117
149
|
url = API_LISTING.format(listing_id=listing_id_str)
|
|
118
150
|
|
|
119
|
-
|
|
151
|
+
headers = _make_headers(host)
|
|
152
|
+
response = self.session.get(
|
|
153
|
+
url, headers=headers, ja3=FUNDA_JA3,
|
|
154
|
+
http_version=CurlHttpVersion.V1_1, timeout=self.timeout
|
|
155
|
+
)
|
|
120
156
|
|
|
121
157
|
# If tinyId fails, try as globalId
|
|
122
158
|
if response.status_code == 404 and len(listing_id_str) >= 8:
|
|
123
159
|
url = API_LISTING.format(listing_id=listing_id_str)
|
|
124
|
-
|
|
160
|
+
headers = _make_headers(host)
|
|
161
|
+
response = self.session.get(
|
|
162
|
+
url, headers=headers, ja3=FUNDA_JA3,
|
|
163
|
+
http_version=CurlHttpVersion.V1_1, timeout=self.timeout
|
|
164
|
+
)
|
|
125
165
|
|
|
126
166
|
if response.status_code != 200:
|
|
127
167
|
raise LookupError(f"Listing {listing_id} not found")
|
|
@@ -259,11 +299,15 @@ class Funda:
|
|
|
259
299
|
query = f"{index_line}\n{query_line}\n"
|
|
260
300
|
|
|
261
301
|
# Retry on intermittent 400 errors from API
|
|
302
|
+
host = "listing-search-wonen.funda.io"
|
|
262
303
|
for attempt in range(3):
|
|
304
|
+
headers = _make_headers(host, for_search=True)
|
|
263
305
|
response = self.session.post(
|
|
264
306
|
API_SEARCH,
|
|
265
|
-
headers=
|
|
307
|
+
headers=headers,
|
|
266
308
|
data=query,
|
|
309
|
+
ja3=FUNDA_JA3,
|
|
310
|
+
http_version=CurlHttpVersion.V1_1,
|
|
267
311
|
timeout=self.timeout,
|
|
268
312
|
)
|
|
269
313
|
if response.status_code == 200:
|
|
@@ -458,11 +502,16 @@ class Funda:
|
|
|
458
502
|
"""
|
|
459
503
|
consecutive_404s = 0
|
|
460
504
|
current_id = since_id + 1
|
|
505
|
+
host = "listing-detail-page.funda.io"
|
|
461
506
|
|
|
462
507
|
while consecutive_404s < max_consecutive_404s:
|
|
463
508
|
url = API_LISTING.format(listing_id=current_id)
|
|
464
509
|
try:
|
|
465
|
-
|
|
510
|
+
headers = _make_headers(host)
|
|
511
|
+
response = self.session.get(
|
|
512
|
+
url, headers=headers, ja3=FUNDA_JA3,
|
|
513
|
+
http_version=CurlHttpVersion.V1_1, timeout=self.timeout
|
|
514
|
+
)
|
|
466
515
|
|
|
467
516
|
if response.status_code == 200:
|
|
468
517
|
consecutive_404s = 0
|
|
@@ -536,6 +585,7 @@ class Funda:
|
|
|
536
585
|
json=payload,
|
|
537
586
|
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
538
587
|
timeout=self.timeout,
|
|
588
|
+
http_version=CurlHttpVersion.V1_1,
|
|
539
589
|
)
|
|
540
590
|
|
|
541
591
|
if response.status_code != 200:
|
|
@@ -626,18 +626,18 @@ def test_session_lazy_loading():
|
|
|
626
626
|
print(" Session lazy loading working correctly")
|
|
627
627
|
|
|
628
628
|
|
|
629
|
-
@test("Session
|
|
629
|
+
@test("Session is created correctly")
|
|
630
630
|
def test_session_headers():
|
|
631
631
|
from funda import Funda
|
|
632
632
|
|
|
633
633
|
f = Funda()
|
|
634
634
|
session = f.session
|
|
635
635
|
|
|
636
|
-
|
|
637
|
-
assert
|
|
636
|
+
# Session exists (headers are generated per-request with Dart fingerprint)
|
|
637
|
+
assert session is not None, "Session should exist"
|
|
638
638
|
|
|
639
639
|
f.close()
|
|
640
|
-
print(" Session
|
|
640
|
+
print(" Session created correctly")
|
|
641
641
|
|
|
642
642
|
|
|
643
643
|
@test("Custom timeout is respected")
|
|
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
|