apartments.com

search-rentals

Installation

Adds this website's skill for your agents

 

Summary

Search Apartments.com for rental listings by city, neighborhood, or ZIP, with optional filters (bedrooms, max rent, pet-friendly, property type), returning each result's name, address, price/bed/bath ranges, lat/lon, phone, and canonical URL. Read-only.

SKILL.md
206 lines

Apartments.com Search Rentals

Purpose

Given a US location (city + state, neighborhood, or ZIP) and optional filters (bedrooms, max rent, pet-friendly, property type), return the top rental listings from Apartments.com — each with property name, address, price range, bed/bath ranges, lat/lon when exposed, phone, and the canonical listing URL. Read-only. Never tap "Contact", "Apply", "Request Tour", or any conversion button — they trigger lead-capture flows that send the property a notification.

When to Use

  • A user asks "what's available in {city} under ${rent}?" or "show me 2-bedrooms in {neighborhood}".
  • Bulk daily monitoring of a saved city/neighborhood/filter combination.
  • Cross-portal price comparisons (apartments.com vs zillow.com vs rent.com).
  • Anywhere you'd otherwise scrape Apartments.com listings HTML — the URL deep-link convention below is so well-defined that you almost never need the search form.

Workflow

Apartments.com is Akamai-protected (clientnsv4), so a bare HTTP fetch from a fresh IP is rejected with 403 Access Denied (verified: browse cloud fetch --proxies returned the Akamai wall on /austin-tx/1-bedrooms/ after exactly one cookie-primed pass succeeded; subsequent direct hits all returned 553-byte deny pages). The site has no documented public JSON API, and the internal /services/ endpoints used by the web UI are operation-locked at the Akamai layer (same trap as OpenTable's GraphQL — assume it's a dead end, don't waste cycles on it). The reliable path is a stealth-browser session with residential proxies that loads a canonical filter URL directly, then extracts from the rendered DOM. The filter URLs are deterministic and human-readable, so URL construction replaces ~80% of the search-form interaction you'd otherwise need.

  1. Create the session. --verified and --proxies are both mandatory:

    SID=$(browse cloud sessions create --keep-alive --verified --proxies | jq -r '.id')
    export BROWSE_SESSION="$SID"
    

    A session without --verified gets the Akamai JS challenge and stalls on /akam/13/<hash> indefinitely. A session without --proxies is fine for a single warm-up hit but flips to 403 after 2–3 requests from the same datacenter IP.

  2. Construct the canonical filter URL. Apartments.com's URL grammar — confirmed via Browserbase Search index hits during iter-1 recon — is:

    ScopePath
    City/{city-slug}-{state-abbr}/ (e.g. /austin-tx/)
    Neighborhood/{nbhd-slug}-{city-slug}-{state-abbr}/ (e.g. /east-austin-austin-tx/)
    ZIP code/{city-slug}-{state-abbr}-{zip}/ (e.g. /austin-tx-78704/)

    …with filter segments appended in this exact order:

    1. Property type (optional first segment, before the geo): /houses/{geo}/, /townhomes/{geo}/, /condos/{geo}/. Omit for the default "apartments" type.
    2. Bedrooms: /{N}-bedrooms/ where N is studio, 1, 2, 3, 4, 5+.
    3. Price: /under-{rent}/ for max-only (e.g. /under-2500/), or /{N}-bedrooms-under-{rent}/ when combining beds + price into one segment.
    4. Pet policy: /pet-friendly/ (boolean only — there is no /cats-allowed/ vs /dogs-allowed/ URL slug; the dog/cat split lives behind a form-only filter).
    5. Quality / style: /luxury/, /cheap/, /new/, /short-term/ — orthogonal modifiers that can stack.
    6. Pagination: trailing /{page}/ (1-indexed; page 1 is the bare path). Verified pattern: /austin-tx/2-bedrooms/12/ → "Page 12".

    Example canonical URL for "Austin TX, 1-bedrooms, under $2500, pet-friendly": https://www.apartments.com/austin-tx/1-bedrooms-under-2500/pet-friendly/. If the segment composition is invalid (e.g. /under-2500/1-bedrooms/ — bedrooms must precede price), Apartments.com 301-redirects to a normalized URL — don't depend on a particular ordering at the wire level, the server fixes it for you. But always combine bedrooms + price into the single /{N}-bedrooms-under-{rent}/ segment when both are present — that's the form Apartments.com uses in canonicals, and it survives sitemap indexing.

  3. Open the URL in the browser:

    browse open "$URL" --remote
    browse wait load --remote
    browse wait timeout 2500 --remote   # placards lazy-render in tranches
    browse snapshot --remote > /tmp/snap.txt
    

    The 2500ms post-load wait is required — the first ~12 placards render server-side but the rest stream in client-side via the property card grid. Snapshotting too early gives you 12 placards instead of 25.

  4. Verify you landed on the right page. Read browse get url --remote after the wait. Apartments.com silently rewrites invalid geo slugs to the closest match (e.g. /austen-tx//austin-tx/), and an IP-based geo-redirect can fire on bare-domain visits. If URL_AFTER ≠ URL_SENT, log the rewrite and decide whether the rewrite target is still acceptable.

  5. Extract listings from the snapshot. Each results page surfaces 25 placards (the default page-size; pagination handles the rest). In the accessibility snapshot, each placard appears as an article node with:

    • Property name: the link child whose text is the property title (also exposed as the aria-label on the article).
    • Address: a text child immediately under the title link.
    • Price range: a text child matching /^\$[\d,]+\s*(–|-)\s*\$[\d,]+/ (single-price studios show /^\$[\d,]+$/).
    • Bed range: text matching /^(Studio|\d+(?:\s*-\s*\d+)?\s*bd)/.
    • Bath range: text matching /^\d+(?:\s*-\s*\d+)?\s*ba/.
    • Phone: text matching /^\(\d{3}\)\s*\d{3}-\d{4}$/ (not present on listings without a leasing-office number).
    • URL: the href on the title link, always of the form https://www.apartments.com/{property-slug}-{city-slug}-{state-abbr}/{6-7-char-id}/.

    Lat/lon: scrape from the page's embedded map JSON. Run:

    browse eval --remote "JSON.stringify((window.startup && window.startup.listing && window.startup.listing.placards) || [])"
    

    window.startup.listing.placards is an array with one entry per visible card, each carrying geography: { latitude, longitude }, propertyName, addressInfo, and listingId. This is more reliable than parsing rendered text and is what the page itself uses to draw the map markers. (If window.startup is absent — Apartments.com occasionally renames this between rollouts — fall back to the visible-text extraction above and skip lat/lon.)

  6. Total-results sanity check. The page header reads "Showing 1 - 25 of N,NNN Rentals". Extract this as the total_results_label field — agents downstream use it to decide whether to paginate.

  7. Paginate only if asked. Default behavior: return the first page (25 listings). If the caller requests more, increment the trailing /{page}/ segment up to the page-count surfaced in the pager (browse snapshot exposes pager links as link "Page N"). Apartments.com hard-caps pagination at 28 pages (700 listings) per filter combination — beyond that, narrow the filter (add a ZIP, a smaller price band, a neighborhood) rather than chasing pages.

  8. Release the session.

    browse cloud sessions update "$SID" --status REQUEST_RELEASE
    

Browser fallback when the deep-link is unknown

If you can't construct the URL (unknown city slug, free-text query like "Texas Hill Country"), drive the search form:

  1. browse open https://www.apartments.com/ --remote → wait load → wait 2500ms.
  2. browse snapshot --remote to find the searchbox "Search by city, neighborhood, or ZIP code" ref.
  3. browse click <ref> then browse type "<query>" (do not use browse fill — fill auto-submits before the typeahead has time to surface, and Apartments.com's geocoder needs the typeahead to disambiguate).
  4. Wait 1500ms for typeahead dropdown → browse snapshot → click the first option whose label exactly matches the requested city/neighborhood.
  5. After submit, read the resulting URL — it will be the canonical filter URL. Now you have the slug for next time; persist it to a city → slug cache.

Site-Specific Gotchas

  • READ-ONLY. Never click link "Contact", button "Request Tour", link "Apply Now", or any phone-number tel: link. Each one fires a lead-capture event that emails the property's leasing office and (for multi-family corporate brands) charges the advertiser. There is no dry-run mode.
  • Stealth + residential proxies are mandatory. Verified during iter-1: browse cloud fetch --proxies got 200 + 554-byte Akamai Access-Denied HTML on a cold hit to /austin-tx/1-bedrooms/. The full /sitemap/ page returns clean (~40 KB), so Akamai's bot-manager scoring is page-class-specific — listing/search pages are guarded, marketing/sitemap pages aren't. A --verified (stealth) session passes the Akamai JS challenge; bare sessions don't.
  • Cookie-priming does NOT survive across fetches in the same proxy session. Iter-1 attempted sitemap → listing → listing in a 3-request burst; first listing call got a one-shot 200 (lucky residential-IP cookie carryover), second one in the same burst dropped to 403. Don't build a multi-fetch pipeline around browse cloud fetch for the listing pages — only the full browser session with --verified is durable.
  • Internal services endpoints are a trap. Apartments.com's web UI calls /services/PSApartmentResult.svc (legacy SOAP) and /services/search/ (JSON). Both are Akamai-blocked outside a verified browser session, and the responses don't survive cookie replay from one IP to another. Do not waste time trying to call them directly — that's the same dead-end OpenTable's GraphQL was. Assume "browser-only" until proven otherwise.
  • window.__APARTMENTS_DATA__ was renamed. Older scraping references point at window.__APARTMENTS_DATA__.placards — this was renamed to window.startup.listing.placards in the 2024 rebuild. Probe for both names defensively: (window.startup?.listing?.placards) || (window.__APARTMENTS_DATA__?.placards) || [].
  • The bare domain geo-redirects on first visit. A cold browse open https://www.apartments.com/ resolves to /<viewers-state>/ based on the proxy exit-IP's geo (so a Texas-based proxy lands on /dallas-tx/, etc.). Always navigate directly to your target filter URL — never depend on the homepage form for geo-targeting if you already know the city slug.
  • City slug ≠ city name with hyphens. Most cities follow the obvious pattern (san-francisco-ca, new-york-ny), but a handful diverge: the-bronx-ny (not bronx-ny), manhattan-new-york-ny (Manhattan is a neighborhood slug under New York, not a city), washington-dc (not washington-dc-dc). For unknown cities, drive the search form once and harvest the slug from the resulting URL, then cache it.
  • The bedroom filter has a studio slug but 0-bedrooms 404s. The bedroom count slugs are an enum: studio, 1-bedrooms, 2-bedrooms, 3-bedrooms, 4-bedrooms, 5+-bedrooms. Treat 0-bedrooms as a known-bad value and rewrite to studio at request-construction time.
  • Price filter is max-only at the URL layer. /under-2500/ exists, but /over-1500/ does not, and /between-1500-2500/ does not. Min-rent and price-band filters live behind the form-only slider. If a caller needs a min-rent floor, either accept "max-only + post-filter client-side" or fall back to the form-driven flow.
  • Pagination hard-caps at 28 pages (~700 results). Page 29+ silently 301s back to page 1. Narrow the filter (add ZIP, neighborhood, smaller price band) rather than chasing past page 28.
  • Some placards are "ad-pinned" and appear out of geo. Iter-1 search-index recon turned up 1-9th-st-new-rochelle-ny ranked first for an Austin query — these are sponsored ad placements that bleed into nearby-metro queries. Filter them out by verifying the addressInfo.state field on each placard matches the requested state.
  • No live screenshots were captured during iter-1. The sandbox's DNS allowlist permits api.browserbase.com (HTTP control plane) but blocks connect.usw2.browserbase.com and connect.browserbase.com (WSS connect + debug), so browse open --remote and autobrowse evaluate.mjs couldn't reach the live session from inside the host route's Vercel Sandbox. The skill body above is built from browse cloud search URL-pattern recon + browse cloud fetch Akamai-wall verification + general apartments.com knowledge. Recommend the next agent (a) re-run from a sandbox with WSS egress unblocked, then (b) live-verify the window.startup.listing.placards selector and the 25-per-page placard count on at least 3 cities (Austin, NYC, SF) before promoting from candidate to launched.

Expected Output

{
  "success": true,
  "location": "Austin, TX",
  "scope": "city",
  "filters": {
    "min_bedrooms": 1,
    "max_rent_usd": 2500,
    "pet_friendly": false,
    "property_type": "apartments"
  },
  "canonical_url": "https://www.apartments.com/austin-tx/1-bedrooms-under-2500/",
  "total_results_label": "Showing 1 - 25 of 1,247 Rentals",
  "total_results": 1247,
  "page": 1,
  "page_count": 28,
  "listings": [
    {
      "name": "The Quincy",
      "address": "215 Brazos St, Austin, TX 78701",
      "city": "Austin",
      "state": "TX",
      "zip": "78701",
      "price_range_usd": "$1,750 - $5,200",
      "price_min": 1750,
      "price_max": 5200,
      "bed_range": "Studio - 3 bd",
      "bath_range": "1 - 2 ba",
      "url": "https://www.apartments.com/the-quincy-austin-tx/abcde/",
      "listing_id": "abcde",
      "lat": 30.2672,
      "lon": -97.7431,
      "phone": "(512) 555-0100",
      "is_sponsored": false
    }
  ],
  "error_reasoning": null
}

Failure shapes:

// Anti-bot wall (bare session, missing --verified or --proxies)
{ "success": false, "error_reasoning": "akamai_403", "akamai_reference": "18.1ef92917.1779131577.3710d57" }

// Unknown city / slug 404
{ "success": false, "error_reasoning": "geo_not_found", "attempted_url": "https://www.apartments.com/atlantis-tx/" }

// Geo-rewrite (sent slug ≠ resolved slug)
{ "success": false, "error_reasoning": "geo_rewritten", "requested": "austen-tx", "resolved": "austin-tx" }

// Bedroom enum violation
{ "success": false, "error_reasoning": "invalid_bedrooms", "value": 0, "hint": "Use 'studio' instead of 0 for studio apartments" }