Day 27 of 30 Days of Search.
TL;DR
To build an apartment finder with the Valyu Python SDK, turn a renter's requirements into neighbourhood-specific searches with valyu.search(), retrieve rental pages with their source links, merge duplicate URLs, and review individual listings before shortlisting them. This tutorial builds a Python and Streamlit tool for London and San Francisco, including weekly-to-monthly rent conversion and a downloadable shortlist.
The finished tool lets you:
- Search selected rental websites from one brief.
- Read the returned evidence and open the original listing.
- Enter the advertised rent and confirm the details that matter.
- Download your research, notes and reviewed shortlist.
You'll need Python 3.11 or later, as required by the pinned Streamlit 1.66.1 release, and a Valyu API key. We use the official Valyu SDK for search; you don't need a separate language-model API key.
The apartment looks great. Then you read the listing.
You find a flat that seems to fit your budget. The photographs look good. The location looks promising.
Then you notice the price is per week, not per month.
Or you're looking in San Francisco, and the advertised price is an effective average after a move-in concession. The monthly base rent is higher. Another promising result is a room in a shared apartment, even though you need the whole place.
You now have tabs open across several websites and a notes app full of half-remembered addresses.
Let's build something that helps with that specific job: find rental pages worth investigating, keep the evidence together, and produce a shortlist you can use to arrange viewings.
We'll work through two searches. The budgets and neighbourhoods below are example inputs, not claims about market averages or guaranteed availability.
| Requirement | London example | San Francisco example |
|---|---|---|
| Home | Entire one-bedroom flat | Entire one-bedroom apartment |
| Monthly base-rent ceiling | £2,200 GBP | $3,200 USD |
| Areas to investigate | Stratford, Lewisham, Finsbury Park | Inner Sunset, Inner Richmond, Bernal Heights |
| Example move-in date | 1 December 2026 | 1 December 2026 |
| Commute to check | King's Cross | Embarcadero |
| Another question | Does the landlord accept a cat? | What is the pet policy, including any extra charges? |
The app lets you change these inputs. It defaults the move-in date to 30 days from the day you run it.
How does an apartment discovery tool work?
An apartment discovery tool converts a rental brief into searches across relevant websites, then collects the returned pages for review. Valyu Search API supplies ranked results with source URLs and extracted text. Your application manages the renter's requirements, duplicate URLs, price calculations, review state and shortlist.
A search result starts the investigation. The reviewed listing determines whether you should contact the advertiser.
Our first version has a simple path:
Rental brief → One web search per neighbourhood → Source-linked candidate pages → Listing review and rent normalization → Downloadable shortlist For London, the source configuration includes OpenRent, Rightmove and Zoopla. For San Francisco, it includes Zillow, Apartments.com and the San Francisco Bay Area Craigslist domain. These are example web-search filters, not direct inventory integrations or a promise that every website will return accessible results.
Keep the vocabulary local. A London query should include “flat to rent”. A San Francisco query should include “apartment for rent” and the city name. Country targeting adds context, but the query still needs the city and neighbourhood.
Step 1: Set up the Python apartment finder
Create a project and install the Valyu SDK and Streamlit:
mkdir apartment-finder cd apartment-finder python3 -m venv .venv source .venv/bin/activate python -m pip install valyu==2.13.2 streamlit==1.66.1 Add a .gitignore before storing credentials:
.env* .venv/ __pycache__/ *.pem *.key credentials.* secrets.* shortlist-*.json Set VALYU_API_KEY in the environment of the terminal that will run the app. In Bash or zsh on macOS or Linux, this reads the key without displaying it or putting its value in your shell history:
read -s VALYU_API_KEY export VALYU_API_KEY Paste the key at the prompt and press Enter. Keep it in the Python server process, not in browser JavaScript.
Step 2: Search rental websites with the Valyu SDK
The Valyu Python SDK reads VALYU_API_KEY automatically. Call valyu.search() with search_type="web" and the rental domains you want to investigate. The SDK handles the HTTP request and returns typed results.
Save this as first_search.py and run it with python first_search.py:
from valyu import Valyu with Valyu() as valyu: response = valyu.search( "1 bedroom entire flat to rent in Stratford, London under GBP 2200 per month. " "Move in 2026-12-01. Individual long-term rental listings, not rooms.", search_type="web", country_code="GB", included_sources=["openrent.co.uk", "rightmove.co.uk", "zoopla.co.uk"], max_num_results=10, ) if not response or not response.success: raise RuntimeError(response.error if response else "Search did not complete.") for listing in response.results: print(listing.title, listing.url) print(listing.content) For San Francisco, use a query such as:
1 bedroom entire apartment for rent in Inner Sunset, San Francisco under USD 3200 per month. Move in 2026-12-01. Individual long-term rental listings, not rooms. Change country_code to US and use the San Francisco domain list from the code below.
Putting a budget in a query is not the same as enforcing a numeric price filter. Search can return an area page, a building with multiple units, or a listing that doesn't meet every requirement. We apply the budget after reviewing a specific listing's price.
We also avoid a blanket start_date filter. It filters publication dates, which aren't the same as move-in dates or confirmed availability. A page's publication date can be missing, and an older listing may have been updated.
Step 3: Search rental websites and preserve the evidence
Save this as rental_search.py.
The two market profiles supply the local vocabulary, currency, example areas and source domains. discover() reuses one Valyu client for up to three neighbourhood searches, keeps source text and dates, and merges repeated URLs. It records failures separately so a failed search doesn't silently look like an empty neighbourhood.
import math from datetime import datetime, timezone from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from valyu import Valyu MARKETS = { "London": { "country": "GB", "currency": "GBP", "budget": 2200, "areas": "Stratford, Lewisham, Finsbury Park", "domains": ["openrent.co.uk", "rightmove.co.uk", "zoopla.co.uk"], "home": "flat to rent", "destination": "King's Cross", }, "San Francisco": { "country": "US", "currency": "USD", "budget": 3200, "areas": "Inner Sunset, Inner Richmond, Bernal Heights", "domains": ["zillow.com", "apartments.com", "sfbay.craigslist.org"], "home": "apartment for rent", "destination": "Embarcadero", }, } def monthly_rent(amount, period): if not isinstance(amount, (int, float)) or isinstance(amount, bool): return None if not math.isfinite(amount) or amount <= 0 or period not in ("month", "week"): return None return amount * 52 / 12 if period == "week" else amount def clean_url(url, domains): try: p = urlsplit(url) host = (p.hostname or "").lower() if p.scheme not in ("http", "https") or p.username or p.password: return None if not any(host == d or host.endswith("." + d) for d in domains): return None params = [(k, v) for k, v in parse_qsl(p.query, keep_blank_values=True) if not k.lower().startswith("utm_") and k.lower() not in ("gclid", "fbclid")] return urlunsplit((p.scheme, p.netloc.lower(), p.path, urlencode(params), p.fragment)) except (ValueError, TypeError): return None def discover(brief): market = MARKETS[brief["city"]] rows, receipts = {}, [] with Valyu() as valyu: for area in brief["areas"]: query = (f"{brief['bedrooms']} bedroom entire {market['home']} in {area}, " f"{brief['city']} under {market['currency']} {brief['budget']} per month. " f"Move in {brief['move_in']}. Individual long-term rental listings, not rooms.") observed_at = datetime.now(timezone.utc).isoformat() try: response = valyu.search( query, search_type="web", max_num_results=5, country_code=market["country"], included_sources=market["domains"], response_length="short", include_image_links=False, ) except Exception as exc: receipts.append({"query": query, "error": type(exc).__name__}) continue if not response or not response.success: receipts.append({"query": query, "error": response.error if response else "Search did not complete.", "tx_id": response.tx_id if response else None}) continue receipts.append({"query": query, "tx_id": response.tx_id, "warning": response.error, "warnings": [w.model_dump(mode="json") for w in response.warnings or []], "returned": len(response.results)}) for hit in response.results: url = clean_url(hit.url, market["domains"]) if not url: continue if url in rows: if area not in rows[url]["found_for"]: rows[url]["found_for"].append(area) continue rows[url] = { "title": hit.title, "url": url, "original_url": hit.url, "source": hit.source, "content": hit.content, "found_for": [area], "publication_date": hit.publication_date or None, "retrieved_at": observed_at, "rent": 0.0, "period": "unknown", "whole_unit": False, "available": False, "fits_brief": False, "notes": "", } return {"brief": brief, "rows": list(rows.values()), "receipts": receipts} def reviewed_shortlist(rows, budget): selected = [] for row in rows: rent = monthly_rent(row["rent"], row["period"]) if (rent is not None and rent <= budget and row["whole_unit"] and row["available"] and row["fits_brief"]): selected.append({**row, "monthly_base_rent": round(rent, 2)}) return sorted(selected, key=lambda row: row["monthly_base_rent"]) There are two useful details here.
First, source can simply be "web". Keep the original URL as well, because that's how the renter gets back to the advertiser. The code also keeps tx_id values from successful responses for debugging.
Second, URL deduplication is deliberately modest. Removing utm_ tracking parameters can merge repeated links to the same page. It doesn't establish that two different URLs describe the same apartment. The code preserves other query parameters and URL fragments, such as #unit-2, because they may identify different units.
New candidates start with an unknown rent period and unchecked confirmations. We don't extract the first price from a multi-property page and attach it to an arbitrary apartment.
Step 4: Add the search and shortlist interface
Save this as app.py in the same folder.
The interface collects a rental brief, shows the returned source text, and provides a review table. Once you've checked a specific listing, enter its base rent and payment period, then confirm that it is the whole unit, available for your dates, and fits your other requirements.
import json from datetime import date, timedelta import streamlit as st from rental_search import MARKETS, discover, reviewed_shortlist st.set_page_config(page_title="Apartment discovery", layout="wide") st.title("Find your next apartment") st.caption("Search London or San Francisco. Review the source, then build your shortlist.") city = st.selectbox("Rental market", list(MARKETS)) market = MARKETS[city] with st.form(f"search-{city}"): areas = st.text_input("Neighbourhoods (up to three, separated by commas)", market["areas"]) left, middle, right = st.columns(3) budget = left.number_input(f"Monthly base-rent limit ({market['currency']})", min_value=1, value=market["budget"], step=100) bedrooms = middle.number_input("Bedrooms", min_value=1, max_value=5, value=1) move_in = right.date_input("Move-in date", date.today() + timedelta(days=30)) needs = st.text_input("Other needs to check", f"Commute to {market['destination']}; pet policy") submitted = st.form_submit_button("Find candidate pages") if submitted: selected_areas = list(dict.fromkeys(a.strip() for a in areas.split(",") if a.strip())) if not 1 <= len(selected_areas) <= 3: st.error("Enter one to three neighbourhoods.") st.stop() with st.spinner("Searching rental websites..."): st.session_state.discovery = discover({ "city": city, "areas": selected_areas, "budget": budget, "bedrooms": bedrooms, "move_in": str(move_in), "needs": needs, }) st.session_state.pop("rental-review", None) result = st.session_state.get("discovery") if not result: st.info("Start with a rental brief. Only the search button makes API calls.") st.stop() brief = result["brief"] if brief["city"] != citySource:https://dev.to/valyuai/find-your-next-apartment-with-search-api-a-london-and-san-francisco-guide-5fmRelated posts
Three dates that catch a hard-coded time-zone bug in a session clock
Laravel N+1 Query Problem: How to Find and Fix It
LongCat-2.5 at $0.30/$1.20: Thinking Locks Temperature, 1M Context, and Vendor Benches Between Opus 4.8 and 5
Shipped: two packages, a registry listing, and the first seals
AI Agent Memory Needs Garbage Collection, Not Just Storage

