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.
- {pyfunda-3.1.1 → pyfunda-3.1.3}/PKG-INFO +1 -5
- {pyfunda-3.1.1 → pyfunda-3.1.3}/README.md +0 -4
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/constants.py +1 -1
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/funda.py +20 -1
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/headers.py +2 -1
- {pyfunda-3.1.1 → pyfunda-3.1.3}/pyproject.toml +1 -1
- {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_client.py +24 -2
- {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_live.py +20 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_search.py +3 -2
- {pyfunda-3.1.1 → pyfunda-3.1.3}/.dockerignore +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/.github/FUNDING.yml +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/.github/workflows/publish.yml +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/.gitignore +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/Dockerfile +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/LICENSE +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/docs/API.md +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/docs/ARCHITECTURE.md +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/docs/DEVELOPMENT.md +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/docs/EXAMPLES.md +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/docs/README.md +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/almere_age_rank.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/analysis.ipynb +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/batch_details.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/broker_due_diligence.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/enrichment_export.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/export_to_csv.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/full_api_walkthrough.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/neighborhood_market_snapshot.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/new_listings_alert.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/poll_new_listings.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/price_history.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/price_tracker.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/search_sold.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/examples/similar_sales_comp.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/__init__.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_autocomplete.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_autocomplete_parser.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_detail_parser.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_enrichment_parser.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_parallel.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_parse_helpers.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_price_history_parser.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_search_parser.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/_transport.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/exceptions.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/listing.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/models.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/parsing.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/py.typed +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/funda/search.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/__init__.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_autocomplete.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_enrichment_parser.py +0 -0
- {pyfunda-3.1.1 → pyfunda-3.1.3}/tests/test_models.py +0 -0
- {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.
|
|
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 = "
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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"],
|
|
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.
|
|
69
|
-
self.
|
|
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
|
|
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
|