pyfunda 3.1.1__tar.gz → 3.1.2__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.
Files changed (55) hide show
  1. {pyfunda-3.1.1 → pyfunda-3.1.2}/PKG-INFO +1 -5
  2. {pyfunda-3.1.1 → pyfunda-3.1.2}/README.md +0 -4
  3. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/constants.py +1 -1
  4. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/funda.py +27 -2
  5. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/headers.py +2 -1
  6. {pyfunda-3.1.1 → pyfunda-3.1.2}/pyproject.toml +1 -1
  7. {pyfunda-3.1.1 → pyfunda-3.1.2}/tests/test_client.py +24 -2
  8. {pyfunda-3.1.1 → pyfunda-3.1.2}/tests/test_live.py +20 -0
  9. {pyfunda-3.1.1 → pyfunda-3.1.2}/tests/test_search.py +3 -2
  10. {pyfunda-3.1.1 → pyfunda-3.1.2}/.dockerignore +0 -0
  11. {pyfunda-3.1.1 → pyfunda-3.1.2}/.github/FUNDING.yml +0 -0
  12. {pyfunda-3.1.1 → pyfunda-3.1.2}/.github/workflows/publish.yml +0 -0
  13. {pyfunda-3.1.1 → pyfunda-3.1.2}/.gitignore +0 -0
  14. {pyfunda-3.1.1 → pyfunda-3.1.2}/Dockerfile +0 -0
  15. {pyfunda-3.1.1 → pyfunda-3.1.2}/LICENSE +0 -0
  16. {pyfunda-3.1.1 → pyfunda-3.1.2}/docs/API.md +0 -0
  17. {pyfunda-3.1.1 → pyfunda-3.1.2}/docs/ARCHITECTURE.md +0 -0
  18. {pyfunda-3.1.1 → pyfunda-3.1.2}/docs/DEVELOPMENT.md +0 -0
  19. {pyfunda-3.1.1 → pyfunda-3.1.2}/docs/EXAMPLES.md +0 -0
  20. {pyfunda-3.1.1 → pyfunda-3.1.2}/docs/README.md +0 -0
  21. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/almere_age_rank.py +0 -0
  22. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/analysis.ipynb +0 -0
  23. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/batch_details.py +0 -0
  24. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/broker_due_diligence.py +0 -0
  25. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/enrichment_export.py +0 -0
  26. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/export_to_csv.py +0 -0
  27. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/full_api_walkthrough.py +0 -0
  28. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/neighborhood_market_snapshot.py +0 -0
  29. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/new_listings_alert.py +0 -0
  30. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/poll_new_listings.py +0 -0
  31. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/price_history.py +0 -0
  32. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/price_tracker.py +0 -0
  33. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/search_sold.py +0 -0
  34. {pyfunda-3.1.1 → pyfunda-3.1.2}/examples/similar_sales_comp.py +0 -0
  35. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/__init__.py +0 -0
  36. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/_autocomplete.py +0 -0
  37. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/_autocomplete_parser.py +0 -0
  38. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/_detail_parser.py +0 -0
  39. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/_enrichment_parser.py +0 -0
  40. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/_parallel.py +0 -0
  41. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/_parse_helpers.py +0 -0
  42. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/_price_history_parser.py +0 -0
  43. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/_search_parser.py +0 -0
  44. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/_transport.py +0 -0
  45. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/exceptions.py +0 -0
  46. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/listing.py +0 -0
  47. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/models.py +0 -0
  48. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/parsing.py +0 -0
  49. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/py.typed +0 -0
  50. {pyfunda-3.1.1 → pyfunda-3.1.2}/funda/search.py +0 -0
  51. {pyfunda-3.1.1 → pyfunda-3.1.2}/tests/__init__.py +0 -0
  52. {pyfunda-3.1.1 → pyfunda-3.1.2}/tests/test_autocomplete.py +0 -0
  53. {pyfunda-3.1.1 → pyfunda-3.1.2}/tests/test_enrichment_parser.py +0 -0
  54. {pyfunda-3.1.1 → pyfunda-3.1.2}/tests/test_models.py +0 -0
  55. {pyfunda-3.1.1 → pyfunda-3.1.2}/tests/test_transport_parallel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyfunda
3
- Version: 3.1.1
3
+ Version: 3.1.2
4
4
  Summary: Python API wrapper for Funda.nl, the Dutch real estate platform. Reverse-engineered mobile API client, no scraping, no Selenium, no CAPTCHA.
5
5
  Project-URL: Homepage, https://github.com/0xMH/pyfunda
6
6
  Project-URL: Repository, https://github.com/0xMH/pyfunda
@@ -33,10 +33,6 @@ Description-Content-Type: text/markdown
33
33
 
34
34
  Funda has no public developer API. pyfunda is the only working opensource Python client that hits Funda's app facing `*.funda.io` endpoints directly, the same ones the official Android app uses.
35
35
 
36
- ```bash
37
- pip install pyfunda
38
- ```
39
-
40
36
  Use pyfunda as a Funda API client or Funda Python SDK to fetch Dutch property listings, run search queries by city and price, pull listing detail and media, get broker info and reviews, read price history, and stream new listings. If you have been looking for a working fundascraper alternative or a Netherlands housing API, this is the only Python wrapper hitting the same `*.funda.io` endpoints the Funda Android app uses.
41
37
 
42
38
  > If you find this useful, consider giving it a star, it helps others discover the project.
@@ -8,10 +8,6 @@
8
8
 
9
9
  Funda has no public developer API. pyfunda is the only working opensource Python client that hits Funda's app facing `*.funda.io` endpoints directly, the same ones the official Android app uses.
10
10
 
11
- ```bash
12
- pip install pyfunda
13
- ```
14
-
15
11
  Use pyfunda as a Funda API client or Funda Python SDK to fetch Dutch property listings, run search queries by city and price, pull listing detail and media, get broker info and reviews, read price history, and stream new listings. If you have been looking for a working fundascraper alternative or a Netherlands housing API, this is the only Python wrapper hitting the same `*.funda.io` endpoints the Funda Android app uses.
16
12
 
17
13
  > If you find this useful, consider giving it a star, it helps others discover the project.
@@ -36,7 +36,7 @@ API_BROKER_REVIEWS = (
36
36
  )
37
37
 
38
38
  SEARCH_INDEX = "listings-wonen-searcher-alias-prod"
39
- SEARCH_TEMPLATE_ID = "search_result_20250805"
39
+ SEARCH_TEMPLATE_ID = "search_result_20260227"
40
40
  LOCATION_AUTOCOMPLETE_TEMPLATE_ID = "searchbox_20250805"
41
41
  LOCATION_AUTOCOMPLETE_AREA_TYPES = (
42
42
  "country",
@@ -63,7 +63,9 @@ class Funda:
63
63
 
64
64
  timeout: int = 30
65
65
  max_retries: int = DEFAULT_MAX_RETRIES
66
- retry_backoff: float = 0.1
66
+ retry_backoff: float = 0.5
67
+ min_request_interval: float = 0.0
68
+ adaptive_throttle: bool = True
67
69
 
68
70
  _transport: _FundaTransport = field(init=False, repr=False)
69
71
  _parallel_runner: _ParallelRunner["Funda"] | None = field(default=None, init=False, repr=False)
@@ -73,6 +75,8 @@ class Funda:
73
75
  timeout=self.timeout,
74
76
  max_retries=self.max_retries,
75
77
  retry_backoff=self.retry_backoff,
78
+ min_request_interval=self.min_request_interval,
79
+ adaptive_throttle=self.adaptive_throttle,
76
80
  )
77
81
 
78
82
  def close(self) -> None:
@@ -423,7 +427,9 @@ class Funda:
423
427
  for attempt in range(3):
424
428
  response = self._transport.post(API_SEARCH, profile="search", data=payload)
425
429
  if response.status_code == 200:
426
- return response.json()
430
+ data = response.json()
431
+ self._raise_for_msearch_error(data)
432
+ return data
427
433
  if response.status_code == 400 and attempt < 2:
428
434
  time.sleep(0.1 * (attempt + 1))
429
435
  continue
@@ -434,6 +440,23 @@ class Funda:
434
440
 
435
441
  raise SearchError("Search failed without a response")
436
442
 
443
+ @staticmethod
444
+ def _raise_for_msearch_error(data: JsonDict) -> None:
445
+ """Surface per-query errors that _msearch reports inside an HTTP 200 body.
446
+
447
+ The _msearch/template endpoint answers with HTTP 200 even when the
448
+ individual query fails (for example a rotated/stale search template),
449
+ embedding the failure as ``responses[].error``. Without this check such
450
+ breakage is silently parsed as zero results.
451
+ """
452
+ responses = data.get("responses") if isinstance(data, dict) else None
453
+ for entry in responses or []:
454
+ if isinstance(entry, dict) and entry.get("error"):
455
+ error = entry["error"]
456
+ reason = error.get("reason") if isinstance(error, dict) else str(error)
457
+ status = entry.get("status", "unknown")
458
+ raise SearchError(f"Search failed (status {status}): {reason}")
459
+
437
460
  def _autocomplete(self, autocomplete: LocationAutocomplete) -> JsonDict:
438
461
  payload = autocomplete.to_payload()
439
462
  for attempt in range(3):
@@ -484,6 +507,8 @@ class Funda:
484
507
  timeout=self.timeout,
485
508
  max_retries=self.max_retries,
486
509
  retry_backoff=self.retry_backoff,
510
+ min_request_interval=self.min_request_interval,
511
+ adaptive_throttle=self.adaptive_throttle,
487
512
  )
488
513
 
489
514
  def _listing_id_from_input(self, listing_id: int | str) -> str:
@@ -19,7 +19,7 @@ def make_headers(profile: RequestProfile = "listing") -> HeaderList:
19
19
  tid = hex(int(time.time()))[2:] + "00000000"
20
20
 
21
21
  headers = [
22
- ("user-agent", "Dart/3.9 (dart:io)"),
22
+ ("user-agent", "Dart/3.11 (dart:io)"),
23
23
  ("x-datadog-sampling-priority", "0"),
24
24
  ("x-datadog-origin", "rum"),
25
25
  ("tracestate", f"dd=s:0;o:rum;p:{parent_id}"),
@@ -39,6 +39,7 @@ def make_headers(profile: RequestProfile = "listing") -> HeaderList:
39
39
  headers.extend(
40
40
  [
41
41
  ("x-funda-app-platform", "android"),
42
+ ("x-funda-app-version", "7.14.11"),
42
43
  ("content-type", "application/json"),
43
44
  ]
44
45
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyfunda"
7
- version = "3.1.1"
7
+ version = "3.1.2"
8
8
  description = "Python API wrapper for Funda.nl, the Dutch real estate platform. Reverse-engineered mobile API client, no scraping, no Selenium, no CAPTCHA."
9
9
  readme = "README.md"
10
10
  license = "AGPL-3.0-or-later"
@@ -2,6 +2,8 @@ import unittest
2
2
  from unittest.mock import patch
3
3
 
4
4
  from funda import Funda, FundaRequestError, Listing, LocationSuggestion
5
+ from funda.constants import LOCATION_AUTOCOMPLETE_TEMPLATE_ID, SEARCH_TEMPLATE_ID
6
+ from funda.exceptions import SearchError
5
7
 
6
8
 
7
9
  class FakeResponse:
@@ -83,7 +85,27 @@ class FundaClientTests(unittest.TestCase):
83
85
  url, payload, json_data = client._transport.posts[0]
84
86
  self.assertIn("_msearch/template", url)
85
87
  self.assertIsNone(json_data)
86
- self.assertIn("search_result_", payload)
88
+ self.assertIn(SEARCH_TEMPLATE_ID, payload)
89
+
90
+ def test_search_raises_on_embedded_msearch_error(self) -> None:
91
+ # _msearch/template answers 200 even when the query fails (e.g. a
92
+ # stale search template), embedding the error inside the body. This
93
+ # must surface as an error rather than being parsed as zero results.
94
+ error_body = {
95
+ "responses": [
96
+ {
97
+ "error": {
98
+ "reason": "No mapping found for [placement_type] in order to sort on",
99
+ },
100
+ "status": 400,
101
+ }
102
+ ]
103
+ }
104
+ client = self.client(FakeResponse(200, error_body))
105
+
106
+ with self.assertRaises(SearchError) as ctx:
107
+ client.search("amsterdam")
108
+ self.assertIn("placement_type", str(ctx.exception))
87
109
 
88
110
  def test_autocomplete_posts_json_payload_and_parses_results(self) -> None:
89
111
  client = self.client(FakeResponse(200, {"hits": {"hits": []}}))
@@ -100,7 +122,7 @@ class FundaClientTests(unittest.TestCase):
100
122
  url, payload, json_data = client._transport.posts[0]
101
123
  self.assertIn("geo-wonen-alias-prod/_search/template", url)
102
124
  self.assertIsNone(payload)
103
- self.assertEqual(json_data["id"], "searchbox_20250805")
125
+ self.assertEqual(json_data["id"], LOCATION_AUTOCOMPLETE_TEMPLATE_ID)
104
126
  self.assertEqual(json_data["params"]["value"], "amsterdam west")
105
127
  self.assertEqual(json_data["params"]["size"], 5)
106
128
  self.assertEqual(
@@ -2,10 +2,30 @@ import os
2
2
  import unittest
3
3
 
4
4
  from funda import Funda
5
+ from funda.constants import SEARCH_TEMPLATE_ID
6
+ from funda.exceptions import SearchError
5
7
 
6
8
 
7
9
  @unittest.skipUnless(os.environ.get("PYFUNDA_LIVE") == "1", "set PYFUNDA_LIVE=1")
8
10
  class LiveFundaSmokeTests(unittest.TestCase):
11
+ def test_search_template_id_is_accepted_by_funda(self) -> None:
12
+ """Guard against Funda rotating the stored search template ID.
13
+
14
+ A stale SEARCH_TEMPLATE_ID makes every search fail server-side with an
15
+ HTTP 400, while the mocked unit tests keep passing. This isolates that
16
+ failure mode with a clear pointer to the constant that needs updating.
17
+ """
18
+ with Funda(timeout=30) as client:
19
+ try:
20
+ results = client.search("amsterdam")
21
+ except SearchError as exc:
22
+ self.fail(
23
+ f"Funda rejected search template {SEARCH_TEMPLATE_ID!r}: {exc}. "
24
+ "Update SEARCH_TEMPLATE_ID in funda/constants.py to the value "
25
+ "used by the current Funda app."
26
+ )
27
+ self.assertTrue(results, "search returned no listings for 'amsterdam'")
28
+
9
29
  def test_core_listing_search_and_parallel_paths(self) -> None:
10
30
  with Funda(timeout=30) as client:
11
31
  listing = client.listing(43117443)
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  import unittest
3
3
 
4
+ from funda.constants import SEARCH_INDEX, SEARCH_TEMPLATE_ID
4
5
  from funda.search import _Search
5
6
 
6
7
 
@@ -65,8 +66,8 @@ class SearchTests(unittest.TestCase):
65
66
  lines = payload.splitlines()
66
67
 
67
68
  self.assertEqual(len(lines), 2)
68
- self.assertIn("listings-wonen-searcher", json.loads(lines[0])["index"])
69
- self.assertIn("search_result_", json.loads(lines[1])["id"])
69
+ self.assertEqual(json.loads(lines[0])["index"], SEARCH_INDEX)
70
+ self.assertEqual(json.loads(lines[1])["id"], SEARCH_TEMPLATE_ID)
70
71
 
71
72
 
72
73
  if __name__ == "__main__":
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
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