pyfunda 2.2.0__tar.gz → 2.3.0__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.2.0 → pyfunda-2.3.0}/.github/workflows/publish.yml +17 -1
- pyfunda-2.2.0/README.md → pyfunda-2.3.0/PKG-INFO +52 -4
- pyfunda-2.2.0/PKG-INFO → pyfunda-2.3.0/README.md +28 -26
- {pyfunda-2.2.0 → pyfunda-2.3.0}/funda/__init__.py +3 -1
- {pyfunda-2.2.0 → pyfunda-2.3.0}/funda/funda.py +215 -37
- {pyfunda-2.2.0 → pyfunda-2.3.0}/pyproject.toml +3 -1
- {pyfunda-2.2.0 → pyfunda-2.3.0}/test_all_flows.py +4 -4
- {pyfunda-2.2.0 → pyfunda-2.3.0}/.github/FUNDING.yml +0 -0
- {pyfunda-2.2.0 → pyfunda-2.3.0}/.gitignore +0 -0
- {pyfunda-2.2.0 → pyfunda-2.3.0}/LICENSE +0 -0
- {pyfunda-2.2.0 → pyfunda-2.3.0}/examples/analysis.ipynb +0 -0
- {pyfunda-2.2.0 → pyfunda-2.3.0}/examples/export_to_csv.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.3.0}/examples/new_listings_alert.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.3.0}/examples/poll_new_listings.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.3.0}/examples/price_history.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.3.0}/examples/price_tracker.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.3.0}/funda/listing.py +0 -0
- {pyfunda-2.2.0 → pyfunda-2.3.0}/funda/py.typed +0 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
name: Publish to PyPI
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
|
-
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
paths: [pyproject.toml]
|
|
5
7
|
|
|
6
8
|
jobs:
|
|
7
9
|
publish:
|
|
@@ -18,13 +20,27 @@ jobs:
|
|
|
18
20
|
id: version
|
|
19
21
|
run: echo "version=$(grep '^version' pyproject.toml | cut -d'"' -f2)" >> $GITHUB_OUTPUT
|
|
20
22
|
|
|
23
|
+
- name: Check if release exists
|
|
24
|
+
id: check
|
|
25
|
+
run: |
|
|
26
|
+
if gh release view "v${{ steps.version.outputs.version }}" &>/dev/null; then
|
|
27
|
+
echo "exists=true" >> $GITHUB_OUTPUT
|
|
28
|
+
else
|
|
29
|
+
echo "exists=false" >> $GITHUB_OUTPUT
|
|
30
|
+
fi
|
|
31
|
+
env:
|
|
32
|
+
GH_TOKEN: ${{ github.token }}
|
|
33
|
+
|
|
21
34
|
- name: Build
|
|
35
|
+
if: steps.check.outputs.exists == 'false'
|
|
22
36
|
run: uv build
|
|
23
37
|
|
|
24
38
|
- name: Publish to PyPI
|
|
39
|
+
if: steps.check.outputs.exists == 'false'
|
|
25
40
|
run: uv publish --token ${{ secrets.PYPI_TOKEN }}
|
|
26
41
|
|
|
27
42
|
- name: Create GitHub Release
|
|
43
|
+
if: steps.check.outputs.exists == 'false'
|
|
28
44
|
run: gh release create "v${{ steps.version.outputs.version }}" --title "v${{ steps.version.outputs.version }}" --generate-notes
|
|
29
45
|
env:
|
|
30
46
|
GH_TOKEN: ${{ github.token }}
|
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyfunda
|
|
3
|
+
Version: 2.3.0
|
|
4
|
+
Summary: Python API for Funda.nl real estate listings
|
|
5
|
+
Project-URL: Homepage, https://github.com/0xMH/pyfunda
|
|
6
|
+
Project-URL: Repository, https://github.com/0xMH/pyfunda
|
|
7
|
+
Project-URL: Issues, https://github.com/0xMH/pyfunda/issues
|
|
8
|
+
Author: 0xMH
|
|
9
|
+
License-Expression: AGPL-3.0-or-later
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,funda,housing,netherlands,real-estate,scraper
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: curl-cffi>=0.14.0
|
|
21
|
+
Requires-Dist: tls-client>=1.0.1
|
|
22
|
+
Requires-Dist: typing-extensions>=4.0.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
1
25
|
# pyfunda
|
|
2
26
|
|
|
3
27
|
[](https://pypi.org/project/pyfunda/)
|
|
@@ -10,11 +34,17 @@ The only working real Python API for Funda ([funda.nl](https://www.funda.nl))
|
|
|
10
34
|
|
|
11
35
|
[](https://star-history.com/#0xMH/pyfunda&Date)
|
|
12
36
|
|
|
13
|
-
##
|
|
37
|
+
## Why I'm open-sourcing this?
|
|
14
38
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
39
|
+
After pyfunda, I got messages asking why I'd give this away when aggregators will just take it and sell it. They're right, every week there's a new "revolutionary AI-powered housing finder" charging €40/month or a €250 "success fee.". They all pull from the same one or two sources and wrap it in a fancy UI completely built with AI.
|
|
40
|
+
|
|
41
|
+
That's exactly why I'm open-sourcing it.
|
|
42
|
+
|
|
43
|
+
These services are selling air to people who are looking for any kind of hope. The data is public. The APIs aren't hard to figure out. You shouldn't have to pay someone to refresh a webpage for you. Funda could kill this entire market overnight by offering a public API. They don't, so here we are.
|
|
44
|
+
|
|
45
|
+
Here's the code, do it yourself. Send my library link to any AI service you use and ask it to build whatever tool you think will make your life easier while searching for your next home.
|
|
46
|
+
|
|
47
|
+
With pyfunda, I've already done all the heavy lifting for you.
|
|
18
48
|
|
|
19
49
|
## Why pyfunda?
|
|
20
50
|
|
|
@@ -36,6 +66,12 @@ Funda has no public API. If you want Dutch real estate data programmatically, yo
|
|
|
36
66
|
- 70+ fields including photos, floorplans, coordinates, and listing dates
|
|
37
67
|
- Stable mobile API that doesn't break when the website changes
|
|
38
68
|
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install pyfunda
|
|
73
|
+
```
|
|
74
|
+
|
|
39
75
|
## Quick Start
|
|
40
76
|
|
|
41
77
|
```python
|
|
@@ -545,6 +581,18 @@ if len(funda_prices) >= 2:
|
|
|
545
581
|
print(f"\nPrice change: {change_pct:+.1f}%")
|
|
546
582
|
```
|
|
547
583
|
|
|
584
|
+
## Disclaimer
|
|
585
|
+
|
|
586
|
+
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.
|
|
587
|
+
|
|
588
|
+
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.
|
|
589
|
+
|
|
590
|
+
This project is intended for personal use, research, and educational purposes only.
|
|
591
|
+
|
|
592
|
+
- The API is undocumented and may change or break at any time without notice.
|
|
593
|
+
- Please use this library responsibly and avoid excessive requests that could burden Funda's infrastructure.
|
|
594
|
+
- Scraped data may be subject to copyright and usage restrictions. Ensure your use complies with applicable laws.
|
|
595
|
+
|
|
548
596
|
## License
|
|
549
597
|
|
|
550
598
|
AGPL-3.0
|
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: pyfunda
|
|
3
|
-
Version: 2.2.0
|
|
4
|
-
Summary: Python API for Funda.nl real estate listings
|
|
5
|
-
Project-URL: Homepage, https://github.com/0xMH/pyfunda
|
|
6
|
-
Project-URL: Repository, https://github.com/0xMH/pyfunda
|
|
7
|
-
Project-URL: Issues, https://github.com/0xMH/pyfunda/issues
|
|
8
|
-
Author: 0xMH
|
|
9
|
-
License-Expression: AGPL-3.0-or-later
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
Keywords: api,funda,housing,netherlands,real-estate,scraper
|
|
12
|
-
Classifier: Development Status :: 4 - Beta
|
|
13
|
-
Classifier: Intended Audience :: Developers
|
|
14
|
-
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
|
|
15
|
-
Classifier: Programming Language :: Python :: 3
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
-
Requires-Python: >=3.10
|
|
20
|
-
Requires-Dist: curl-cffi>=0.14.0
|
|
21
|
-
Description-Content-Type: text/markdown
|
|
22
|
-
|
|
23
1
|
# pyfunda
|
|
24
2
|
|
|
25
3
|
[](https://pypi.org/project/pyfunda/)
|
|
@@ -32,11 +10,17 @@ The only working real Python API for Funda ([funda.nl](https://www.funda.nl))
|
|
|
32
10
|
|
|
33
11
|
[](https://star-history.com/#0xMH/pyfunda&Date)
|
|
34
12
|
|
|
35
|
-
##
|
|
13
|
+
## Why I'm open-sourcing this?
|
|
36
14
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
15
|
+
After pyfunda, I got messages asking why I'd give this away when aggregators will just take it and sell it. They're right, every week there's a new "revolutionary AI-powered housing finder" charging €40/month or a €250 "success fee.". They all pull from the same one or two sources and wrap it in a fancy UI completely built with AI.
|
|
16
|
+
|
|
17
|
+
That's exactly why I'm open-sourcing it.
|
|
18
|
+
|
|
19
|
+
These services are selling air to people who are looking for any kind of hope. The data is public. The APIs aren't hard to figure out. You shouldn't have to pay someone to refresh a webpage for you. Funda could kill this entire market overnight by offering a public API. They don't, so here we are.
|
|
20
|
+
|
|
21
|
+
Here's the code, do it yourself. Send my library link to any AI service you use and ask it to build whatever tool you think will make your life easier while searching for your next home.
|
|
22
|
+
|
|
23
|
+
With pyfunda, I've already done all the heavy lifting for you.
|
|
40
24
|
|
|
41
25
|
## Why pyfunda?
|
|
42
26
|
|
|
@@ -58,6 +42,12 @@ Funda has no public API. If you want Dutch real estate data programmatically, yo
|
|
|
58
42
|
- 70+ fields including photos, floorplans, coordinates, and listing dates
|
|
59
43
|
- Stable mobile API that doesn't break when the website changes
|
|
60
44
|
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install pyfunda
|
|
49
|
+
```
|
|
50
|
+
|
|
61
51
|
## Quick Start
|
|
62
52
|
|
|
63
53
|
```python
|
|
@@ -567,6 +557,18 @@ if len(funda_prices) >= 2:
|
|
|
567
557
|
print(f"\nPrice change: {change_pct:+.1f}%")
|
|
568
558
|
```
|
|
569
559
|
|
|
560
|
+
## Disclaimer
|
|
561
|
+
|
|
562
|
+
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.
|
|
563
|
+
|
|
564
|
+
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.
|
|
565
|
+
|
|
566
|
+
This project is intended for personal use, research, and educational purposes only.
|
|
567
|
+
|
|
568
|
+
- The API is undocumented and may change or break at any time without notice.
|
|
569
|
+
- Please use this library responsibly and avoid excessive requests that could burden Funda's infrastructure.
|
|
570
|
+
- Scraped data may be subject to copyright and usage restrictions. Ensure your use complies with applicable laws.
|
|
571
|
+
|
|
570
572
|
## License
|
|
571
573
|
|
|
572
574
|
AGPL-3.0
|
|
@@ -13,8 +13,10 @@ Example usage:
|
|
|
13
13
|
... print(r['title'], r['city'])
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
+
from importlib.metadata import version
|
|
17
|
+
|
|
16
18
|
from funda.funda import Funda, FundaAPI
|
|
17
19
|
from funda.listing import Listing
|
|
18
20
|
|
|
19
|
-
__version__ = "
|
|
21
|
+
__version__ = version("pyfunda")
|
|
20
22
|
__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
|
+
import tls_client
|
|
8
10
|
|
|
9
11
|
from funda.listing import Listing
|
|
10
12
|
|
|
@@ -16,17 +18,71 @@ 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
|
-
}
|
|
21
|
+
# Funda mobile app JA3 fingerprints (captured from real Dart/Flutter app traffic)
|
|
22
|
+
# JA3 without extension 21 - from favourites.funda.io
|
|
23
|
+
# JA3 hash: 9225d95490794840d9d5f1f94d339285
|
|
24
|
+
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,29-23-24,0"
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
# JA3 with extension 21 (padding) - from cdn-settings.segment.com
|
|
27
|
+
# JA3 hash: 4bf8cdd8919b07d35ca824c20efb3537
|
|
28
|
+
FUNDA_JA3_EXT21 = "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"
|
|
29
|
+
|
|
30
|
+
# Fingerprint pool - tried in order until one works
|
|
31
|
+
# Types: "tls_ja3", "curl_impersonate", "tls_client"
|
|
32
|
+
FINGERPRINT_POOL = [
|
|
33
|
+
# tls_client with exact Funda app JA3 fingerprints
|
|
34
|
+
{"type": "tls_ja3", "ja3": FUNDA_JA3},
|
|
35
|
+
{"type": "tls_ja3", "ja3": FUNDA_JA3_EXT21},
|
|
36
|
+
# curl_cffi impersonate fallback - Safari works best with Funda headers
|
|
37
|
+
{"type": "curl_impersonate", "target": "safari15_5"},
|
|
38
|
+
{"type": "curl_impersonate", "target": "safari15_3"},
|
|
39
|
+
# tls_client preset profiles as final fallback
|
|
40
|
+
{"type": "tls_client", "identifier": "okhttp4_android_13"},
|
|
41
|
+
{"type": "tls_client", "identifier": "chrome_120"},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Test endpoint to verify fingerprint works
|
|
45
|
+
TEST_URL = f"{API_BASE}/tinyId/43117443"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _make_headers(for_search: bool = False) -> list[tuple[str, str]]:
|
|
50
|
+
"""Generate headers matching the Funda Android app exactly.
|
|
51
|
+
|
|
52
|
+
Header order and values are captured from real Funda app traffic.
|
|
53
|
+
"""
|
|
54
|
+
trace_id = str(random.randint(10**18, 10**19))
|
|
55
|
+
parent_id = hex(random.randint(10**15, 10**16))[2:]
|
|
56
|
+
tid = hex(int(time.time()))[2:] + "00000000"
|
|
57
|
+
|
|
58
|
+
# Base headers in exact order from app traffic
|
|
59
|
+
headers = [
|
|
60
|
+
("user-agent", "Dart/3.9 (dart:io)"),
|
|
61
|
+
("x-datadog-sampling-priority", "0"),
|
|
62
|
+
("x-datadog-origin", "rum"),
|
|
63
|
+
("tracestate", f"dd=s:0;o:rum;p:{parent_id}"),
|
|
64
|
+
("accept-encoding", "gzip"),
|
|
65
|
+
("x-datadog-parent-id", trace_id),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
if for_search:
|
|
69
|
+
# Search endpoint: content-type, referer, accept, then traceparent
|
|
70
|
+
headers.extend([
|
|
71
|
+
("content-type", "application/json"),
|
|
72
|
+
("referer", "https://www.funda.nl/"),
|
|
73
|
+
("accept", "application/json"),
|
|
74
|
+
])
|
|
75
|
+
else:
|
|
76
|
+
# Listing endpoint: x-funda-app-platform, content-type, then traceparent
|
|
77
|
+
headers.extend([
|
|
78
|
+
("x-funda-app-platform", "android"),
|
|
79
|
+
("content-type", "application/json"),
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
# traceparent is always last
|
|
83
|
+
headers.append(("traceparent", f"00-{tid}{trace_id[:16]}-{parent_id}-00"))
|
|
84
|
+
|
|
85
|
+
return headers
|
|
30
86
|
|
|
31
87
|
|
|
32
88
|
def _parse_area(value: str | None) -> int | None:
|
|
@@ -63,21 +119,145 @@ class Funda:
|
|
|
63
119
|
timeout: Request timeout in seconds
|
|
64
120
|
"""
|
|
65
121
|
self.timeout = timeout
|
|
66
|
-
self.
|
|
122
|
+
self._curl_session: requests.Session | None = None
|
|
123
|
+
self._tls_session: tls_client.Session | None = None
|
|
124
|
+
self._fingerprint: dict | None = None
|
|
67
125
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
126
|
+
def _make_headers_dict(self, for_search: bool = False) -> dict[str, str]:
|
|
127
|
+
"""Generate headers as dict for tls_client.
|
|
128
|
+
|
|
129
|
+
Header order and values match the real Funda Android app exactly.
|
|
130
|
+
"""
|
|
131
|
+
trace_id = str(random.randint(10**18, 10**19))
|
|
132
|
+
parent_id = hex(random.randint(10**15, 10**16))[2:]
|
|
133
|
+
tid = hex(int(time.time()))[2:] + "00000000"
|
|
134
|
+
|
|
135
|
+
# Build headers in exact order from app traffic
|
|
136
|
+
# Python 3.7+ dicts preserve insertion order
|
|
137
|
+
headers = {
|
|
138
|
+
"user-agent": "Dart/3.9 (dart:io)",
|
|
139
|
+
"x-datadog-sampling-priority": "0",
|
|
140
|
+
"x-datadog-origin": "rum",
|
|
141
|
+
"tracestate": f"dd=s:0;o:rum;p:{parent_id}",
|
|
142
|
+
"accept-encoding": "gzip",
|
|
143
|
+
"x-datadog-parent-id": trace_id,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if for_search:
|
|
147
|
+
# Search endpoint: content-type, referer, accept, then traceparent
|
|
148
|
+
headers["content-type"] = "application/json"
|
|
149
|
+
headers["referer"] = "https://www.funda.nl/"
|
|
150
|
+
headers["accept"] = "application/json"
|
|
151
|
+
else:
|
|
152
|
+
# Listing endpoint: x-funda-app-platform, content-type, then traceparent
|
|
153
|
+
headers["x-funda-app-platform"] = "android"
|
|
154
|
+
headers["content-type"] = "application/json"
|
|
155
|
+
|
|
156
|
+
# traceparent is always last
|
|
157
|
+
headers["traceparent"] = f"00-{tid}{trace_id[:16]}-{parent_id}-00"
|
|
158
|
+
|
|
159
|
+
return headers
|
|
160
|
+
|
|
161
|
+
def _test_fingerprint(self, fingerprint: dict) -> bool:
|
|
162
|
+
"""Test if a fingerprint works against Funda API."""
|
|
163
|
+
try:
|
|
164
|
+
fp_type = fingerprint["type"]
|
|
165
|
+
if fp_type == "tls_ja3":
|
|
166
|
+
# tls_client with custom JA3 - primary method for Funda app fingerprint
|
|
167
|
+
session = tls_client.Session(ja3_string=fingerprint["ja3"], random_tls_extension_order=False)
|
|
168
|
+
headers = self._make_headers_dict()
|
|
169
|
+
response = session.get(TEST_URL, headers=headers, timeout_seconds=5)
|
|
170
|
+
elif fp_type == "curl_ja3":
|
|
171
|
+
session = requests.Session()
|
|
172
|
+
headers = _make_headers()
|
|
173
|
+
response = session.get(TEST_URL, headers=headers, ja3=fingerprint["ja3"], timeout=5)
|
|
174
|
+
session.close()
|
|
175
|
+
elif fp_type == "curl_impersonate":
|
|
176
|
+
session = requests.Session(impersonate=fingerprint["target"])
|
|
177
|
+
headers = _make_headers()
|
|
178
|
+
response = session.get(TEST_URL, headers=headers, timeout=5)
|
|
179
|
+
session.close()
|
|
180
|
+
elif fp_type == "tls_client":
|
|
181
|
+
session = tls_client.Session(client_identifier=fingerprint["identifier"], random_tls_extension_order=False)
|
|
182
|
+
headers = self._make_headers_dict()
|
|
183
|
+
response = session.get(TEST_URL, headers=headers, timeout_seconds=5)
|
|
184
|
+
else:
|
|
185
|
+
return False
|
|
186
|
+
return response.status_code == 200
|
|
187
|
+
except Exception:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def _find_working_fingerprint(self) -> dict:
|
|
191
|
+
"""Find a working fingerprint from the pool."""
|
|
192
|
+
for fp in FINGERPRINT_POOL:
|
|
193
|
+
if self._test_fingerprint(fp):
|
|
194
|
+
return fp
|
|
195
|
+
raise RuntimeError("No working fingerprint found. Funda may have updated their bot detection.")
|
|
196
|
+
|
|
197
|
+
def _ensure_session(self) -> None:
|
|
198
|
+
"""Ensure a working session is created."""
|
|
199
|
+
if self._fingerprint is None:
|
|
200
|
+
self._fingerprint = self._find_working_fingerprint()
|
|
201
|
+
|
|
202
|
+
fp_type = self._fingerprint["type"]
|
|
203
|
+
if fp_type == "tls_ja3":
|
|
204
|
+
# tls_client with custom JA3 - exact Funda app fingerprint
|
|
205
|
+
if self._tls_session is None:
|
|
206
|
+
self._tls_session = tls_client.Session(
|
|
207
|
+
ja3_string=self._fingerprint["ja3"],
|
|
208
|
+
random_tls_extension_order=False
|
|
209
|
+
)
|
|
210
|
+
elif fp_type == "curl_ja3":
|
|
211
|
+
if self._curl_session is None:
|
|
212
|
+
self._curl_session = requests.Session()
|
|
213
|
+
elif fp_type == "curl_impersonate":
|
|
214
|
+
if self._curl_session is None:
|
|
215
|
+
self._curl_session = requests.Session(impersonate=self._fingerprint["target"])
|
|
216
|
+
elif fp_type == "tls_client":
|
|
217
|
+
if self._tls_session is None:
|
|
218
|
+
self._tls_session = tls_client.Session(
|
|
219
|
+
client_identifier=self._fingerprint["identifier"],
|
|
220
|
+
random_tls_extension_order=False
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _get(self, url: str, headers_list: list[tuple[str, str]]) -> Any:
|
|
224
|
+
"""Make GET request using the active session."""
|
|
225
|
+
self._ensure_session()
|
|
226
|
+
fp_type = self._fingerprint["type"]
|
|
227
|
+
|
|
228
|
+
if fp_type in ("tls_ja3", "tls_client"):
|
|
229
|
+
headers = self._make_headers_dict()
|
|
230
|
+
return self._tls_session.get(url, headers=headers, timeout_seconds=self.timeout)
|
|
231
|
+
elif fp_type == "curl_ja3":
|
|
232
|
+
return self._curl_session.get(url, headers=headers_list, ja3=self._fingerprint["ja3"], timeout=self.timeout)
|
|
233
|
+
else:
|
|
234
|
+
return self._curl_session.get(url, headers=headers_list, timeout=self.timeout)
|
|
235
|
+
|
|
236
|
+
def _post(self, url: str, headers_list: list[tuple[str, str]], data: str = None, json_data: dict = None, for_search: bool = False) -> Any:
|
|
237
|
+
"""Make POST request using the active session."""
|
|
238
|
+
self._ensure_session()
|
|
239
|
+
fp_type = self._fingerprint["type"]
|
|
240
|
+
|
|
241
|
+
if fp_type in ("tls_ja3", "tls_client"):
|
|
242
|
+
headers = self._make_headers_dict(for_search=for_search)
|
|
243
|
+
if json_data:
|
|
244
|
+
return self._tls_session.post(url, headers=headers, json=json_data, timeout_seconds=self.timeout)
|
|
245
|
+
return self._tls_session.post(url, headers=headers, data=data, timeout_seconds=self.timeout)
|
|
246
|
+
elif fp_type == "curl_ja3":
|
|
247
|
+
if json_data:
|
|
248
|
+
return self._curl_session.post(url, headers=headers_list, json=json_data, ja3=self._fingerprint["ja3"], timeout=self.timeout)
|
|
249
|
+
return self._curl_session.post(url, headers=headers_list, data=data, ja3=self._fingerprint["ja3"], timeout=self.timeout)
|
|
250
|
+
else:
|
|
251
|
+
if json_data:
|
|
252
|
+
return self._curl_session.post(url, headers=headers_list, json=json_data, timeout=self.timeout)
|
|
253
|
+
return self._curl_session.post(url, headers=headers_list, data=data, timeout=self.timeout)
|
|
75
254
|
|
|
76
255
|
def close(self) -> None:
|
|
77
256
|
"""Close the HTTP session."""
|
|
78
|
-
if self.
|
|
79
|
-
self.
|
|
80
|
-
self.
|
|
257
|
+
if self._curl_session:
|
|
258
|
+
self._curl_session.close()
|
|
259
|
+
self._curl_session = None
|
|
260
|
+
self._tls_session = None
|
|
81
261
|
|
|
82
262
|
def __enter__(self) -> "Funda":
|
|
83
263
|
return self
|
|
@@ -116,12 +296,14 @@ class Funda:
|
|
|
116
296
|
else:
|
|
117
297
|
url = API_LISTING.format(listing_id=listing_id_str)
|
|
118
298
|
|
|
119
|
-
|
|
299
|
+
headers = _make_headers()
|
|
300
|
+
response = self._get(url, headers)
|
|
120
301
|
|
|
121
302
|
# If tinyId fails, try as globalId
|
|
122
303
|
if response.status_code == 404 and len(listing_id_str) >= 8:
|
|
123
304
|
url = API_LISTING.format(listing_id=listing_id_str)
|
|
124
|
-
|
|
305
|
+
headers = _make_headers()
|
|
306
|
+
response = self._get(url, headers)
|
|
125
307
|
|
|
126
308
|
if response.status_code != 200:
|
|
127
309
|
raise LookupError(f"Listing {listing_id} not found")
|
|
@@ -260,12 +442,8 @@ class Funda:
|
|
|
260
442
|
|
|
261
443
|
# Retry on intermittent 400 errors from API
|
|
262
444
|
for attempt in range(3):
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
headers=SEARCH_HEADERS,
|
|
266
|
-
data=query,
|
|
267
|
-
timeout=self.timeout,
|
|
268
|
-
)
|
|
445
|
+
headers = _make_headers(for_search=True)
|
|
446
|
+
response = self._post(API_SEARCH, headers, data=query, for_search=True)
|
|
269
447
|
if response.status_code == 200:
|
|
270
448
|
break
|
|
271
449
|
if response.status_code == 400 and attempt < 2:
|
|
@@ -462,7 +640,8 @@ class Funda:
|
|
|
462
640
|
while consecutive_404s < max_consecutive_404s:
|
|
463
641
|
url = API_LISTING.format(listing_id=current_id)
|
|
464
642
|
try:
|
|
465
|
-
|
|
643
|
+
headers = _make_headers()
|
|
644
|
+
response = self._get(url, headers)
|
|
466
645
|
|
|
467
646
|
if response.status_code == 200:
|
|
468
647
|
consecutive_404s = 0
|
|
@@ -480,7 +659,7 @@ class Funda:
|
|
|
480
659
|
else:
|
|
481
660
|
consecutive_404s += 1
|
|
482
661
|
|
|
483
|
-
except requests.
|
|
662
|
+
except requests.errors.RequestsError:
|
|
484
663
|
consecutive_404s += 1
|
|
485
664
|
|
|
486
665
|
current_id += 1
|
|
@@ -531,12 +710,11 @@ class Funda:
|
|
|
531
710
|
"zipcode": postcode,
|
|
532
711
|
}
|
|
533
712
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
json
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
)
|
|
713
|
+
walter_headers = [
|
|
714
|
+
("Accept", "application/json"),
|
|
715
|
+
("Content-Type", "application/json"),
|
|
716
|
+
]
|
|
717
|
+
response = self._post(API_WALTER, walter_headers, json_data=payload)
|
|
540
718
|
|
|
541
719
|
if response.status_code != 200:
|
|
542
720
|
raise LookupError(f"Could not fetch price history (status {response.status_code})")
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyfunda"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
8
8
|
description = "Python API for Funda.nl real estate listings"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "AGPL-3.0-or-later"
|
|
@@ -24,6 +24,8 @@ classifiers = [
|
|
|
24
24
|
]
|
|
25
25
|
dependencies = [
|
|
26
26
|
"curl-cffi>=0.14.0",
|
|
27
|
+
"tls-client>=1.0.1",
|
|
28
|
+
"typing_extensions>=4.0.0",
|
|
27
29
|
]
|
|
28
30
|
|
|
29
31
|
[project.urls]
|
|
@@ -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
|