Skip to content

Commit decf9ab

Browse files
committed
Use Funda mobile app fingerprint and add open-source rationale
1 parent 8249f55 commit decf9ab

File tree

6 files changed

+108
-28
lines changed

6 files changed

+108
-28
lines changed

.github/workflows/publish.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
name: Publish to PyPI
22

33
on:
4-
workflow_dispatch:
4+
push:
5+
branches: [main]
6+
paths: [pyproject.toml]
57

68
jobs:
79
publish:
@@ -18,13 +20,27 @@ jobs:
1820
id: version
1921
run: echo "version=$(grep '^version' pyproject.toml | cut -d'"' -f2)" >> $GITHUB_OUTPUT
2022

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+
2134
- name: Build
35+
if: steps.check.outputs.exists == 'false'
2236
run: uv build
2337

2438
- name: Publish to PyPI
39+
if: steps.check.outputs.exists == 'false'
2540
run: uv publish --token ${{ secrets.PYPI_TOKEN }}
2641

2742
- name: Create GitHub Release
43+
if: steps.check.outputs.exists == 'false'
2844
run: gh release create "v${{ steps.version.outputs.version }}" --title "v${{ steps.version.outputs.version }}" --generate-notes
2945
env:
3046
GH_TOKEN: ${{ github.token }}

README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@ The only working real Python API for Funda ([funda.nl](https://www.funda.nl))
1010
1111
[![Star History Chart](https://api.star-history.com/svg?repos=0xMH/pyfunda&type=Date)](https://star-history.com/#0xMH/pyfunda&Date)
1212

13-
## Installation
13+
## Why I'm open-sourcing this?
1414

15-
```bash
16-
pip install pyfunda
17-
```
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.
1824

1925
## Why pyfunda?
2026

@@ -36,6 +42,12 @@ Funda has no public API. If you want Dutch real estate data programmatically, yo
3642
- 70+ fields including photos, floorplans, coordinates, and listing dates
3743
- Stable mobile API that doesn't break when the website changes
3844

45+
## Installation
46+
47+
```bash
48+
pip install pyfunda
49+
```
50+
3951
## Quick Start
4052

4153
```python

funda/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
... print(r['title'], r['city'])
1414
"""
1515

16+
from importlib.metadata import version
17+
1618
from funda.funda import Funda, FundaAPI
1719
from funda.listing import Listing
1820

19-
__version__ = "2.2.0"
21+
__version__ = version("pyfunda")
2022
__all__ = ["Funda", "FundaAPI", "Listing", "__version__"]

funda/funda.py

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Main Funda API class."""
22

3+
import random
34
import re
45
import time
56
from typing import Any
67

78
from curl_cffi import requests
9+
from curl_cffi.const import CurlHttpVersion
810

911
from funda.listing import Listing
1012

@@ -16,17 +18,47 @@
1618
API_SEARCH = "https://listing-search-wonen.funda.io/_msearch/template"
1719
API_WALTER = "https://api.walterliving.com/hunter/lookup"
1820

19-
# Headers for mobile API
20-
HEADERS = {
21-
"x-funda-app-platform": "android",
22-
"content-type": "application/json",
23-
}
24-
25-
SEARCH_HEADERS = {
26-
"content-type": "application/json",
27-
"accept": "application/json",
28-
"referer": "https://www.funda.nl/",
29-
}
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
3062

3163

3264
def _parse_area(value: str | None) -> int | None:
@@ -69,8 +101,7 @@ def __init__(self, timeout: int = 30):
69101
def session(self) -> requests.Session:
70102
"""Lazily create HTTP session."""
71103
if self._session is None:
72-
self._session = requests.Session(impersonate="chrome")
73-
self._session.headers.update(HEADERS)
104+
self._session = requests.Session()
74105
return self._session
75106

76107
def close(self) -> None:
@@ -111,17 +142,26 @@ def get_listing(self, listing_id: int | str) -> Listing:
111142

112143
# Try tinyId endpoint first (8-9 digits), then globalId (7 digits)
113144
listing_id_str = str(listing_id)
145+
host = "listing-detail-page.funda.io"
114146
if len(listing_id_str) >= 8:
115147
url = API_LISTING_TINY.format(tiny_id=listing_id_str)
116148
else:
117149
url = API_LISTING.format(listing_id=listing_id_str)
118150

119-
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+
)
120156

121157
# If tinyId fails, try as globalId
122158
if response.status_code == 404 and len(listing_id_str) >= 8:
123159
url = API_LISTING.format(listing_id=listing_id_str)
124-
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+
)
125165

126166
if response.status_code != 200:
127167
raise LookupError(f"Listing {listing_id} not found")
@@ -259,11 +299,15 @@ def search_listing(
259299
query = f"{index_line}\n{query_line}\n"
260300

261301
# Retry on intermittent 400 errors from API
302+
host = "listing-search-wonen.funda.io"
262303
for attempt in range(3):
304+
headers = _make_headers(host, for_search=True)
263305
response = self.session.post(
264306
API_SEARCH,
265-
headers=SEARCH_HEADERS,
307+
headers=headers,
266308
data=query,
309+
ja3=FUNDA_JA3,
310+
http_version=CurlHttpVersion.V1_1,
267311
timeout=self.timeout,
268312
)
269313
if response.status_code == 200:
@@ -458,11 +502,16 @@ def poll_new_listings(
458502
"""
459503
consecutive_404s = 0
460504
current_id = since_id + 1
505+
host = "listing-detail-page.funda.io"
461506

462507
while consecutive_404s < max_consecutive_404s:
463508
url = API_LISTING.format(listing_id=current_id)
464509
try:
465-
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+
)
466515

467516
if response.status_code == 200:
468517
consecutive_404s = 0
@@ -536,6 +585,7 @@ def get_price_history(self, listing: Listing | str) -> list[dict]:
536585
json=payload,
537586
headers={"Accept": "application/json", "Content-Type": "application/json"},
538587
timeout=self.timeout,
588+
http_version=CurlHttpVersion.V1_1,
539589
)
540590

541591
if response.status_code != 200:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "pyfunda"
7-
version = "2.2.0"
7+
version = "2.2.1"
88
description = "Python API for Funda.nl real estate listings"
99
readme = "README.md"
1010
license = "AGPL-3.0-or-later"

test_all_flows.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -626,18 +626,18 @@ def test_session_lazy_loading():
626626
print(" Session lazy loading working correctly")
627627

628628

629-
@test("Session headers are set correctly")
629+
@test("Session is created correctly")
630630
def test_session_headers():
631631
from funda import Funda
632632

633633
f = Funda()
634634
session = f.session
635635

636-
assert 'x-funda-app-platform' in session.headers, "Should have x-funda-app-platform header"
637-
assert session.headers['x-funda-app-platform'] == 'android', "Platform should be android"
636+
# Session exists (headers are generated per-request with Dart fingerprint)
637+
assert session is not None, "Session should exist"
638638

639639
f.close()
640-
print(" Session headers set correctly")
640+
print(" Session created correctly")
641641

642642

643643
@test("Custom timeout is respected")

0 commit comments

Comments
 (0)