Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions funda/_search_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,33 @@
)


def parse_search_results(data: Mapping[str, Any]) -> list[Listing]:
def parse_search_results(
data: Mapping[str, Any],
*,
preferred_offering_type: str | None = None,
) -> list[Listing]:
responses = data.get("responses") or []
if not responses:
return []
hits = mapping(mapping(responses[0]).get("hits")).get("hits") or []
return [_hit(hit) for hit in hits if isinstance(hit, Mapping)]


def _hit(hit: Mapping[str, Any]) -> Listing:
return [
_hit(hit, preferred_offering_type=preferred_offering_type)
for hit in hits
if isinstance(hit, Mapping)
]


def _hit(
hit: Mapping[str, Any],
*,
preferred_offering_type: str | None = None,
) -> Listing:
source = mapping(hit.get("_source"))
address_data = mapping(source.get("address"))
global_id = parse_int(coalesce(hit.get("_id"), source.get("id")))
path = source.get("object_detail_page_relative_url")
tiny_id = tiny_id_from_url(path)
offering_type = normalize_offering_type(first(source.get("offering_type")))
offering_type = _offering_type(source.get("offering_type"), preferred_offering_type)

@0xMH 0xMH Jul 8, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Threading preferred_offering_type from the client into the pure parser couples parsing to caller intent. If that context is required to choose between sale and rent fields, can we make it an explicit parser-level decision and apply it consistently through the parsed model? Right now it enters through _hit, but related metadata still follows the old first-offering path.


return Listing(
listing_id=tiny_id or as_str(global_id),
Expand Down Expand Up @@ -88,18 +100,36 @@ def _address(address: Mapping[str, Any]) -> Address:
)


def _offering_type(value: Any, preferred: str | None = None) -> str | None:

@0xMH 0xMH Jul 8, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_offering_type() adds a second raw-offering normalization path next to the existing first() + normalize_offering_type() pattern. Since preferred selection is now part of parser behavior, consider centralizing that handling and using it consistently rather than only in _hit.

values = value if isinstance(value, list) else [value]
normalized = [normalize_offering_type(item) for item in values]
if preferred in normalized:
return preferred
return next((item for item in normalized if item), None)


def _price(source: Mapping[str, Any], offering_type: str | None) -> Price:
price = mapping(source.get("price"))
sale_amount = first(price.get("selling_price"))
rent_amount = first(price.get("rent_price"))
sale_range = mapping(price.get("selling_price_range"))
rent_range = mapping(price.get("rent_price_range"))
range_data = sale_range or rent_range

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial range_data = sale_range or rent_range is now dead code since both branches of the if/else immediately reassign it. Please drop that line. The else branch also just re-derives the original behavior, so this could collapse into a single prefer_rent flag instead of duplicating three lines per branch.

if offering_type == "rent":
amount = coalesce(rent_amount, sale_amount)
condition = price.get("rent_price_condition") or price.get("selling_price_condition")
price_type = price.get("rent_price_type") or price.get("selling_price_type")
range_data = rent_range or sale_range
else:
amount = coalesce(sale_amount, rent_amount)
condition = price.get("selling_price_condition") or price.get("rent_price_condition")
price_type = price.get("selling_price_type") or price.get("rent_price_type")
range_data = sale_range or rent_range
return Price(
amount=parse_int(coalesce(sale_amount, rent_amount)),
amount=parse_int(amount),
offering_type=offering_type,
condition=price.get("selling_price_condition") or price.get("rent_price_condition"),
price_type=price.get("selling_price_type") or price.get("rent_price_type"),
condition=condition,
price_type=price_type,
range_min=parse_int(range_data.get("gte")),
range_max=parse_int(range_data.get("lte")),
raw=dict(price),
Expand Down
5 changes: 4 additions & 1 deletion funda/funda.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,10 @@ def _autocomplete(self, autocomplete: LocationAutocomplete) -> JsonDict:
raise SearchError("Location autocomplete failed without a response")

def _search_results(self, search: _Search) -> list[Listing]:
return parse_search_results(self._search(search))
return parse_search_results(
self._search(search),
preferred_offering_type=search.offering,
)

def _parallel(
self,
Expand Down
6 changes: 5 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,14 @@ def test_search_posts_payload_and_parses_results(self) -> None:
client = self.client(FakeResponse(200, {"responses": []}))
expected = [Listing(global_id=1)]

with patch("funda.funda.parse_search_results", return_value=expected):
with patch("funda.funda.parse_search_results", return_value=expected) as parse:
results = client.search("amsterdam", max_price=500000)

self.assertEqual(results, expected)
parse.assert_called_once_with(
{"responses": []},
preferred_offering_type="buy",
)
url, payload, json_data = client._transport.posts[0]
self.assertIn("_msearch/template", url)
self.assertIsNone(json_data)
Expand Down
46 changes: 46 additions & 0 deletions tests/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import unittest

from funda.constants import SEARCH_INDEX, SEARCH_TEMPLATE_ID
from funda._search_parser import parse_search_results
from funda.search import _Search


Expand Down Expand Up @@ -69,6 +70,51 @@ def test_search_payload_is_ndjson_template_request(self) -> None:
self.assertEqual(json.loads(lines[0])["index"], SEARCH_INDEX)
self.assertEqual(json.loads(lines[1])["id"], SEARCH_TEMPLATE_ID)

def test_rent_parser_prefers_rent_price_for_buy_rent_listings(self) -> None:
results = parse_search_results(
{
"responses": [
{
"hits": {
"hits": [
{
"_id": "8070001",
"_source": {
"id": "8070001",
"object_detail_page_relative_url": "/detail/koophuur/amsterdam/appartement-werfkade-129/43347854/",
"offering_type": ["buy", "rent"],
"address": {
"street_name": "Werfkade",
"house_number": 129,
"city": "Amsterdam",
},
"price": {
"selling_price": [400000],
"selling_price_range": {"gte": 400000, "lte": 400000},
"selling_price_condition": "kosten_koper",
"selling_price_type": "regular",
"rent_price": [2250],
"rent_price_range": {"gte": 2250, "lte": 2250},
"rent_price_condition": "per_month",
"rent_price_type": "regular",
},
},
}
]
}
}
]
},
preferred_offering_type="rent",
)

self.assertEqual(len(results), 1)
self.assertEqual(results[0].offering_type, "rent")
self.assertEqual(results[0].price.amount, 2250)
self.assertEqual(results[0].price.condition, "per_month")
self.assertEqual(results[0].price.range_min, 2250)
self.assertEqual(results[0].price.range_max, 2250)


if __name__ == "__main__":
unittest.main()