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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyfunda
3
- Version: 2.1.0
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 89666837
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
@@ -16,5 +16,5 @@ Example usage:
16
16
  from funda.funda import Funda, FundaAPI
17
17
  from funda.listing import Listing
18
18
 
19
- __version__ = "2.1.0"
19
+ __version__ = "2.2.1"
20
20
  __all__ = ["Funda", "FundaAPI", "Listing", "__version__"]
@@ -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
- # Headers for mobile API
20
- HEADERS = {
21
- "user-agent": "Dart/3.9 (dart:io)",
22
- "x-funda-app-platform": "android",
23
- "content-type": "application/json",
24
- }
25
-
26
- SEARCH_HEADERS = {
27
- "user-agent": "Dart/3.9 (dart:io)",
28
- "content-type": "application/json",
29
- "accept": "application/json",
30
- "referer": "https://www.funda.nl/",
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(impersonate="safari")
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
- response = self.session.get(url, timeout=self.timeout)
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
- response = self.session.get(url, timeout=self.timeout)
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=SEARCH_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
- response = self.session.get(url, timeout=self.timeout)
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:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyfunda"
7
- version = "2.1.0"
7
+ version = "2.2.1"
8
8
  description = "Python API for Funda.nl real estate listings"
9
9
  readme = "README.md"
10
10
  license = "AGPL-3.0-or-later"
@@ -626,18 +626,18 @@ def test_session_lazy_loading():
626
626
  print(" Session lazy loading working correctly")
627
627
 
628
628
 
629
- @test("Session headers are set correctly")
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
- assert 'user-agent' in session.headers, "Should have user-agent header"
637
- assert 'Dart' in session.headers['user-agent'], "User-agent should contain 'Dart'"
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 headers set correctly")
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