pyfunda 3.1.1__tar.gz → 3.1.3__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.3}/PKG-INFO +1 -5
  2. {pyfunda-3.1.1 → pyfunda-3.1.3}/README.md +0 -4
  3. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/constants.py +1 -1
  4. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/funda.py +20 -1
  5. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/headers.py +2 -1
  6. {pyfunda-3.1.1 → pyfunda-3.1.3}/pyproject.toml +1 -1
  7. {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_client.py +24 -2
  8. {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_live.py +20 -0
  9. {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_search.py +3 -2
  10. {pyfunda-3.1.1 → pyfunda-3.1.3}/.dockerignore +0 -0
  11. {pyfunda-3.1.1 → pyfunda-3.1.3}/.github/FUNDING.yml +0 -0
  12. {pyfunda-3.1.1 → pyfunda-3.1.3}/.github/workflows/publish.yml +0 -0
  13. {pyfunda-3.1.1 → pyfunda-3.1.3}/.gitignore +0 -0
  14. {pyfunda-3.1.1 → pyfunda-3.1.3}/Dockerfile +0 -0
  15. {pyfunda-3.1.1 → pyfunda-3.1.3}/LICENSE +0 -0
  16. {pyfunda-3.1.1 → pyfunda-3.1.3}/docs/API.md +0 -0
  17. {pyfunda-3.1.1 → pyfunda-3.1.3}/docs/ARCHITECTURE.md +0 -0
  18. {pyfunda-3.1.1 → pyfunda-3.1.3}/docs/DEVELOPMENT.md +0 -0
  19. {pyfunda-3.1.1 → pyfunda-3.1.3}/docs/EXAMPLES.md +0 -0
  20. {pyfunda-3.1.1 → pyfunda-3.1.3}/docs/README.md +0 -0
  21. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/almere_age_rank.py +0 -0
  22. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/analysis.ipynb +0 -0
  23. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/batch_details.py +0 -0
  24. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/broker_due_diligence.py +0 -0
  25. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/enrichment_export.py +0 -0
  26. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/export_to_csv.py +0 -0
  27. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/full_api_walkthrough.py +0 -0
  28. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/neighborhood_market_snapshot.py +0 -0
  29. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/new_listings_alert.py +0 -0
  30. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/poll_new_listings.py +0 -0
  31. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/price_history.py +0 -0
  32. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/price_tracker.py +0 -0
  33. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/search_sold.py +0 -0
  34. {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/similar_sales_comp.py +0 -0
  35. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/__init__.py +0 -0
  36. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_autocomplete.py +0 -0
  37. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_autocomplete_parser.py +0 -0
  38. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_detail_parser.py +0 -0
  39. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_enrichment_parser.py +0 -0
  40. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_parallel.py +0 -0
  41. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_parse_helpers.py +0 -0
  42. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_price_history_parser.py +0 -0
  43. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_search_parser.py +0 -0
  44. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_transport.py +0 -0
  45. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/exceptions.py +0 -0
  46. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/listing.py +0 -0
  47. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/models.py +0 -0
  48. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/parsing.py +0 -0
  49. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/py.typed +0 -0
  50. {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/search.py +0 -0
  51. {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/__init__.py +0 -0
  52. {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_autocomplete.py +0 -0
  53. {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_enrichment_parser.py +0 -0
  54. {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_models.py +0 -0
  55. {pyfunda-3.1.1 → pyfunda-3.1.3}/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.3
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",
@@ -423,7 +423,9 @@ class Funda:
423
423
  for attempt in range(3):
424
424
  response = self._transport.post(API_SEARCH, profile="search", data=payload)
425
425
  if response.status_code == 200:
426
- return response.json()
426
+ data = response.json()
427
+ self._raise_for_msearch_error(data)
428
+ return data
427
429
  if response.status_code == 400 and attempt < 2:
428
430
  time.sleep(0.1 * (attempt + 1))
429
431
  continue
@@ -434,6 +436,23 @@ class Funda:
434
436
 
435
437
  raise SearchError("Search failed without a response")
436
438
 
439
+ @staticmethod
440
+ def _raise_for_msearch_error(data: JsonDict) -> None:
441
+ """Surface per-query errors that _msearch reports inside an HTTP 200 body.
442
+
443
+ The _msearch/template endpoint answers with HTTP 200 even when the
444
+ individual query fails (for example a rotated/stale search template),
445
+ embedding the failure as ``responses[].error``. Without this check such
446
+ breakage is silently parsed as zero results.
447
+ """
448
+ responses = data.get("responses") if isinstance(data, dict) else None
449
+ for entry in responses or []:
450
+ if isinstance(entry, dict) and entry.get("error"):
451
+ error = entry["error"]
452
+ reason = error.get("reason") if isinstance(error, dict) else str(error)
453
+ status = entry.get("status", "unknown")
454
+ raise SearchError(f"Search failed (status {status}): {reason}")
455
+
437
456
  def _autocomplete(self, autocomplete: LocationAutocomplete) -> JsonDict:
438
457
  payload = autocomplete.to_payload()
439
458
  for attempt in range(3):
@@ -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.3"
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