|
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 | +from curl_cffi.const import CurlHttpVersion |
8 | 10 |
|
9 | 11 | from funda.listing import Listing |
10 | 12 |
|
|
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 | | -# 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 |
30 | 62 |
|
31 | 63 |
|
32 | 64 | def _parse_area(value: str | None) -> int | None: |
@@ -69,8 +101,7 @@ def __init__(self, timeout: int = 30): |
69 | 101 | def session(self) -> requests.Session: |
70 | 102 | """Lazily create HTTP session.""" |
71 | 103 | if self._session is None: |
72 | | - self._session = requests.Session(impersonate="chrome") |
73 | | - self._session.headers.update(HEADERS) |
| 104 | + self._session = requests.Session() |
74 | 105 | return self._session |
75 | 106 |
|
76 | 107 | def close(self) -> None: |
@@ -111,17 +142,26 @@ def get_listing(self, listing_id: int | str) -> Listing: |
111 | 142 |
|
112 | 143 | # Try tinyId endpoint first (8-9 digits), then globalId (7 digits) |
113 | 144 | listing_id_str = str(listing_id) |
| 145 | + host = "listing-detail-page.funda.io" |
114 | 146 | if len(listing_id_str) >= 8: |
115 | 147 | url = API_LISTING_TINY.format(tiny_id=listing_id_str) |
116 | 148 | else: |
117 | 149 | url = API_LISTING.format(listing_id=listing_id_str) |
118 | 150 |
|
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 | + ) |
120 | 156 |
|
121 | 157 | # If tinyId fails, try as globalId |
122 | 158 | if response.status_code == 404 and len(listing_id_str) >= 8: |
123 | 159 | 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 | + ) |
125 | 165 |
|
126 | 166 | if response.status_code != 200: |
127 | 167 | raise LookupError(f"Listing {listing_id} not found") |
@@ -259,11 +299,15 @@ def search_listing( |
259 | 299 | query = f"{index_line}\n{query_line}\n" |
260 | 300 |
|
261 | 301 | # Retry on intermittent 400 errors from API |
| 302 | + host = "listing-search-wonen.funda.io" |
262 | 303 | for attempt in range(3): |
| 304 | + headers = _make_headers(host, for_search=True) |
263 | 305 | response = self.session.post( |
264 | 306 | API_SEARCH, |
265 | | - headers=SEARCH_HEADERS, |
| 307 | + headers=headers, |
266 | 308 | data=query, |
| 309 | + ja3=FUNDA_JA3, |
| 310 | + http_version=CurlHttpVersion.V1_1, |
267 | 311 | timeout=self.timeout, |
268 | 312 | ) |
269 | 313 | if response.status_code == 200: |
@@ -458,11 +502,16 @@ def poll_new_listings( |
458 | 502 | """ |
459 | 503 | consecutive_404s = 0 |
460 | 504 | current_id = since_id + 1 |
| 505 | + host = "listing-detail-page.funda.io" |
461 | 506 |
|
462 | 507 | while consecutive_404s < max_consecutive_404s: |
463 | 508 | url = API_LISTING.format(listing_id=current_id) |
464 | 509 | 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 | + ) |
466 | 515 |
|
467 | 516 | if response.status_code == 200: |
468 | 517 | consecutive_404s = 0 |
@@ -536,6 +585,7 @@ def get_price_history(self, listing: Listing | str) -> list[dict]: |
536 | 585 | json=payload, |
537 | 586 | headers={"Accept": "application/json", "Content-Type": "application/json"}, |
538 | 587 | timeout=self.timeout, |
| 588 | + http_version=CurlHttpVersion.V1_1, |
539 | 589 | ) |
540 | 590 |
|
541 | 591 | if response.status_code != 200: |
|
0 commit comments