pyfunda 2.1.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.1.0 → pyfunda-2.2.1}/PKG-INFO +64 -1
- {pyfunda-2.1.0 → pyfunda-2.2.1}/README.md +63 -0
- {pyfunda-2.1.0 → pyfunda-2.2.1}/examples/price_history.py +2 -2
- {pyfunda-2.1.0 → pyfunda-2.2.1}/funda/__init__.py +1 -1
- {pyfunda-2.1.0 → pyfunda-2.2.1}/funda/funda.py +67 -19
- {pyfunda-2.1.0 → pyfunda-2.2.1}/pyproject.toml +1 -1
- {pyfunda-2.1.0 → pyfunda-2.2.1}/test_all_flows.py +4 -4
- {pyfunda-2.1.0 → pyfunda-2.2.1}/.github/FUNDING.yml +0 -0
- {pyfunda-2.1.0 → pyfunda-2.2.1}/.github/workflows/publish.yml +0 -0
- {pyfunda-2.1.0 → pyfunda-2.2.1}/.gitignore +0 -0
- {pyfunda-2.1.0 → pyfunda-2.2.1}/LICENSE +0 -0
- {pyfunda-2.1.0 → pyfunda-2.2.1}/examples/analysis.ipynb +0 -0
- {pyfunda-2.1.0 → pyfunda-2.2.1}/examples/export_to_csv.py +0 -0
- {pyfunda-2.1.0 → pyfunda-2.2.1}/examples/new_listings_alert.py +0 -0
- {pyfunda-2.1.0 → pyfunda-2.2.1}/examples/poll_new_listings.py +0 -0
- {pyfunda-2.1.0 → pyfunda-2.2.1}/examples/price_tracker.py +0 -0
- {pyfunda-2.1.0 → pyfunda-2.2.1}/funda/listing.py +0 -0
- {pyfunda-2.1.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.1
|
|
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
|
|
@@ -265,6 +265,34 @@ for listing in f.poll_new_listings(
|
|
|
265
265
|
|
|
266
266
|
This bypasses ES search and queries the detail API directly, catching listings that haven't been indexed yet.
|
|
267
267
|
|
|
268
|
+
#### get_price_history(listing)
|
|
269
|
+
|
|
270
|
+
Get historical price data for a listing, including previous asking prices, WOZ tax assessments, and sale history.
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
listing = f.get_listing(43032486)
|
|
274
|
+
history = f.get_price_history(listing)
|
|
275
|
+
|
|
276
|
+
for change in history:
|
|
277
|
+
print(change['date'], change['human_price'], change['status'])
|
|
278
|
+
# 22 okt, 2025 €435.000 asking_price
|
|
279
|
+
# 1 jan, 2025 €472.000 woz
|
|
280
|
+
# 19 aug, 2019 €300.000 asking_price
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**Returns:** List of price changes, each containing:
|
|
284
|
+
|
|
285
|
+
| Field | Description |
|
|
286
|
+
|-------|-------------|
|
|
287
|
+
| `price` | Numeric price |
|
|
288
|
+
| `human_price` | Formatted price (e.g., "€435.000") |
|
|
289
|
+
| `date` | Human readable date |
|
|
290
|
+
| `timestamp` | ISO timestamp |
|
|
291
|
+
| `source` | "Funda" or "WOZ" |
|
|
292
|
+
| `status` | `asking_price`, `sold`, or `woz` |
|
|
293
|
+
|
|
294
|
+
> **Note:** This fetches data from the Walter Living API. Only called when explicitly requested (lazy-loaded).
|
|
295
|
+
|
|
268
296
|
### Listing
|
|
269
297
|
|
|
270
298
|
Listing objects support dict-like access with convenient aliases.
|
|
@@ -516,6 +544,41 @@ for listing in f.poll_new_listings(since_id=latest_id, offering_type="buy"):
|
|
|
516
544
|
|
|
517
545
|
The generator stops after 20 consecutive 404s (configurable via `max_consecutive_404s`).
|
|
518
546
|
|
|
547
|
+
### Get price history for a listing
|
|
548
|
+
|
|
549
|
+
```python
|
|
550
|
+
from funda import Funda
|
|
551
|
+
|
|
552
|
+
f = Funda()
|
|
553
|
+
listing = f.get_listing(43032486)
|
|
554
|
+
|
|
555
|
+
# Fetch historical prices (WOZ assessments, previous asking prices, sales)
|
|
556
|
+
history = f.get_price_history(listing)
|
|
557
|
+
|
|
558
|
+
print(f"Price history for {listing['title']}:")
|
|
559
|
+
for change in history:
|
|
560
|
+
print(f" {change['date']}: {change['human_price']} ({change['status']})")
|
|
561
|
+
|
|
562
|
+
# Calculate price change over time
|
|
563
|
+
funda_prices = [c for c in history if c['source'] == 'Funda']
|
|
564
|
+
if len(funda_prices) >= 2:
|
|
565
|
+
newest, oldest = funda_prices[0]['price'], funda_prices[-1]['price']
|
|
566
|
+
change_pct = ((newest - oldest) / oldest) * 100
|
|
567
|
+
print(f"\nPrice change: {change_pct:+.1f}%")
|
|
568
|
+
```
|
|
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
|
+
|
|
519
582
|
## License
|
|
520
583
|
|
|
521
584
|
AGPL-3.0
|
|
@@ -243,6 +243,34 @@ for listing in f.poll_new_listings(
|
|
|
243
243
|
|
|
244
244
|
This bypasses ES search and queries the detail API directly, catching listings that haven't been indexed yet.
|
|
245
245
|
|
|
246
|
+
#### get_price_history(listing)
|
|
247
|
+
|
|
248
|
+
Get historical price data for a listing, including previous asking prices, WOZ tax assessments, and sale history.
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
listing = f.get_listing(43032486)
|
|
252
|
+
history = f.get_price_history(listing)
|
|
253
|
+
|
|
254
|
+
for change in history:
|
|
255
|
+
print(change['date'], change['human_price'], change['status'])
|
|
256
|
+
# 22 okt, 2025 €435.000 asking_price
|
|
257
|
+
# 1 jan, 2025 €472.000 woz
|
|
258
|
+
# 19 aug, 2019 €300.000 asking_price
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Returns:** List of price changes, each containing:
|
|
262
|
+
|
|
263
|
+
| Field | Description |
|
|
264
|
+
|-------|-------------|
|
|
265
|
+
| `price` | Numeric price |
|
|
266
|
+
| `human_price` | Formatted price (e.g., "€435.000") |
|
|
267
|
+
| `date` | Human readable date |
|
|
268
|
+
| `timestamp` | ISO timestamp |
|
|
269
|
+
| `source` | "Funda" or "WOZ" |
|
|
270
|
+
| `status` | `asking_price`, `sold`, or `woz` |
|
|
271
|
+
|
|
272
|
+
> **Note:** This fetches data from the Walter Living API. Only called when explicitly requested (lazy-loaded).
|
|
273
|
+
|
|
246
274
|
### Listing
|
|
247
275
|
|
|
248
276
|
Listing objects support dict-like access with convenient aliases.
|
|
@@ -494,6 +522,41 @@ for listing in f.poll_new_listings(since_id=latest_id, offering_type="buy"):
|
|
|
494
522
|
|
|
495
523
|
The generator stops after 20 consecutive 404s (configurable via `max_consecutive_404s`).
|
|
496
524
|
|
|
525
|
+
### Get price history for a listing
|
|
526
|
+
|
|
527
|
+
```python
|
|
528
|
+
from funda import Funda
|
|
529
|
+
|
|
530
|
+
f = Funda()
|
|
531
|
+
listing = f.get_listing(43032486)
|
|
532
|
+
|
|
533
|
+
# Fetch historical prices (WOZ assessments, previous asking prices, sales)
|
|
534
|
+
history = f.get_price_history(listing)
|
|
535
|
+
|
|
536
|
+
print(f"Price history for {listing['title']}:")
|
|
537
|
+
for change in history:
|
|
538
|
+
print(f" {change['date']}: {change['human_price']} ({change['status']})")
|
|
539
|
+
|
|
540
|
+
# Calculate price change over time
|
|
541
|
+
funda_prices = [c for c in history if c['source'] == 'Funda']
|
|
542
|
+
if len(funda_prices) >= 2:
|
|
543
|
+
newest, oldest = funda_prices[0]['price'], funda_prices[-1]['price']
|
|
544
|
+
change_pct = ((newest - oldest) / oldest) * 100
|
|
545
|
+
print(f"\nPrice change: {change_pct:+.1f}%")
|
|
546
|
+
```
|
|
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
|
+
|
|
497
560
|
## License
|
|
498
561
|
|
|
499
562
|
AGPL-3.0
|
|
@@ -5,8 +5,8 @@ Shows previous asking prices, WOZ tax assessments, and sale history
|
|
|
5
5
|
using the Walter Living API.
|
|
6
6
|
|
|
7
7
|
Usage:
|
|
8
|
-
uv run examples/price_history.py
|
|
9
|
-
uv run examples/price_history.py "https://www.funda.nl/detail/koop
|
|
8
|
+
uv run examples/price_history.py 43032486
|
|
9
|
+
uv run examples/price_history.py "https://www.funda.nl/en/detail/koop/amsterdam/appartement-pieter-calandlaan-400/43032486/"
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
@@ -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,19 +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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
32
62
|
|
|
33
63
|
|
|
34
64
|
def _parse_area(value: str | None) -> int | None:
|
|
@@ -71,8 +101,7 @@ class Funda:
|
|
|
71
101
|
def session(self) -> requests.Session:
|
|
72
102
|
"""Lazily create HTTP session."""
|
|
73
103
|
if self._session is None:
|
|
74
|
-
self._session = requests.Session(
|
|
75
|
-
self._session.headers.update(HEADERS)
|
|
104
|
+
self._session = requests.Session()
|
|
76
105
|
return self._session
|
|
77
106
|
|
|
78
107
|
def close(self) -> None:
|
|
@@ -113,17 +142,26 @@ class Funda:
|
|
|
113
142
|
|
|
114
143
|
# Try tinyId endpoint first (8-9 digits), then globalId (7 digits)
|
|
115
144
|
listing_id_str = str(listing_id)
|
|
145
|
+
host = "listing-detail-page.funda.io"
|
|
116
146
|
if len(listing_id_str) >= 8:
|
|
117
147
|
url = API_LISTING_TINY.format(tiny_id=listing_id_str)
|
|
118
148
|
else:
|
|
119
149
|
url = API_LISTING.format(listing_id=listing_id_str)
|
|
120
150
|
|
|
121
|
-
|
|
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
|
+
)
|
|
122
156
|
|
|
123
157
|
# If tinyId fails, try as globalId
|
|
124
158
|
if response.status_code == 404 and len(listing_id_str) >= 8:
|
|
125
159
|
url = API_LISTING.format(listing_id=listing_id_str)
|
|
126
|
-
|
|
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
|
+
)
|
|
127
165
|
|
|
128
166
|
if response.status_code != 200:
|
|
129
167
|
raise LookupError(f"Listing {listing_id} not found")
|
|
@@ -261,11 +299,15 @@ class Funda:
|
|
|
261
299
|
query = f"{index_line}\n{query_line}\n"
|
|
262
300
|
|
|
263
301
|
# Retry on intermittent 400 errors from API
|
|
302
|
+
host = "listing-search-wonen.funda.io"
|
|
264
303
|
for attempt in range(3):
|
|
304
|
+
headers = _make_headers(host, for_search=True)
|
|
265
305
|
response = self.session.post(
|
|
266
306
|
API_SEARCH,
|
|
267
|
-
headers=
|
|
307
|
+
headers=headers,
|
|
268
308
|
data=query,
|
|
309
|
+
ja3=FUNDA_JA3,
|
|
310
|
+
http_version=CurlHttpVersion.V1_1,
|
|
269
311
|
timeout=self.timeout,
|
|
270
312
|
)
|
|
271
313
|
if response.status_code == 200:
|
|
@@ -460,11 +502,16 @@ class Funda:
|
|
|
460
502
|
"""
|
|
461
503
|
consecutive_404s = 0
|
|
462
504
|
current_id = since_id + 1
|
|
505
|
+
host = "listing-detail-page.funda.io"
|
|
463
506
|
|
|
464
507
|
while consecutive_404s < max_consecutive_404s:
|
|
465
508
|
url = API_LISTING.format(listing_id=current_id)
|
|
466
509
|
try:
|
|
467
|
-
|
|
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
|
+
)
|
|
468
515
|
|
|
469
516
|
if response.status_code == 200:
|
|
470
517
|
consecutive_404s = 0
|
|
@@ -538,6 +585,7 @@ class Funda:
|
|
|
538
585
|
json=payload,
|
|
539
586
|
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
540
587
|
timeout=self.timeout,
|
|
588
|
+
http_version=CurlHttpVersion.V1_1,
|
|
541
589
|
)
|
|
542
590
|
|
|
543
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
|