Apartments.com Rental Search

Site apartments.comTask search-rentals-33icwzVersion v1Updated Jul 13, 2026Category real-estate

Search Apartments.com for rental listings in a city (optionally filtered by price, bedrooms, or type) and return each property's name, address, rent range, bed range, phone, and listing URL plus the total result count. Read-only. This skill was captured from a live agent session on apartments.com and publishes here verbatim, exactly as an agent receives it.

NoteSelectors and URL schemes drift as sites change. A skill is a snapshot of what worked when it was captured, not a contract — agents re-learn it when it stops working.

Purpose

Search Apartments.com for rental listings in a given location (city/state, optionally filtered by price, bedrooms, or property type) and return the page of matching properties — community name, full address, rent range, bedroom range, phone, and the canonical listing URL — plus the site's reported total result count. Read-only: never contacts a property, never submits a lead form.

When to Use

  • "Find rentals in {city} under ${price}/mo" / "2-bedroom apartments in {city}".
  • Bulk monitoring of available rentals in a metro on a schedule.
  • Feeding a downstream agent a structured list of candidate properties with links to drill into.
  • Anywhere you'd otherwise scrape Apartments.com search HTML — the page embeds clean structured data (placard DOM + JSON-LD) that extracts in a single JS eval.

Workflow

Apartments.com is browser-only. There is no usable API or HTTP-fetch shortcut: a plain GET of any search URL — even through residential proxies — returns 403 AkamaiGHost because Akamai Bot Manager requires a real browser that solves its JS challenge. You must drive a stealth + residential-proxy browser session via browserless_agent. Deep-link the search URL directly (no need to type into the search box), then extract listings with one evaluate reading the result placards.

Run the whole flow — navigate → wait → extract — inside one browserless_agent call so the session (cookies, Akamai clearance) persists across the commands in its commands array. Batching this way saves round-trips and avoids accidentally dropping the session config; the session itself persists across calls, keyed by proxy, so a later call with the same proxy reconnects to it while dropping or changing proxy lands you in a different, blank session.

1. Use a stealth + residential-proxy session (MANDATORY)

Set a residential proxy on the browserless_agent call:

proxy: { proxy: "residential" }   // optionally proxyCountry: "us"

Stealth is on by default. A proxy-less session is denied immediately on the search page. Even a fully-stealthed + proxied session is blocked intermittently — see Gotchas; budget for fresh-call retries (issue a new browserless_agent call to rotate the session / proxy IP).

2. Build the search URL and navigate

URL grammar: https://www.apartments.com/{city}-{state-abbr}/ plus optional filter path segments. Examples:

IntentURL
All rentals in a cityhttps://www.apartments.com/chicago-il/
Max price $2,500https://www.apartments.com/chicago-il/under-2500/
Min price $3,000https://www.apartments.com/new-york-ny/min-3000/
2 bedroomshttps://www.apartments.com/chicago-il/2-bedrooms/
2 bedrooms + max pricehttps://www.apartments.com/chicago-il/2-bedrooms-under-2500/
Range + pricehttps://www.apartments.com/chicago-il/1-to-2-bedrooms-under-2000/
{ "method": "goto", "params": { "url": "https://www.apartments.com/chicago-il/under-2500/", "waitUntil": "load", "timeout": 45000 } },
{ "method": "waitForTimeout", "params": { "time": 3000 } }

Read the page title with an evaluate returning document.title — expect "Apartments for Rent ... in Chicago IL | Apartments.com".

If the title returns "Access Denied", the session got challenge-blocked. Issue a { "method": "reload", "params": { "waitUntil": "load" } } and wait ~6s — it frequently renders on the 2nd try in the same session. If it's still denied after 2 reloads, abandon this call and start a fresh browserless_agent call. Do NOT loop reloading the same session many times (that wastes turns and never recovers).

Do not use the snapshot command — the page has thousands of nodes; the snapshot is enormous and unnecessary.

3. Extract all placards with one evaluate

The search results are article.placard elements (~40 per page). Run this as the evaluate command's content (it returns a compact JSON string — proven across multiple runs; the return value comes back under .value):

(() => {
  const out = { total_results: null, listings: [] };
  // total count lives in a results header, e.g. '9,533 Rentals Available' — NOT always the title
  const cnt = document.querySelector(
    ".searchResults, .resultSummary, [class*=resultsCount]",
  );
  const m =
    (cnt ? cnt.textContent : document.title || "").match(/([\d,]+)\s+Rentals/i) ||
    (document.body.innerText || "").match(/([\d,]+)\s+Rentals/i);
  if (m) out.total_results = parseInt(m[1].replace(/,/g, ""));
  for (const card of document.querySelectorAll("article.placard")) {
    const name = (card.querySelector(".js-placardTitle, .title") || {}).textContent;
    const address = (card.querySelector(".property-address") || {}).textContent;
    const link = card.querySelector("a.property-link");
    const url = link ? link.href.replace(/#.*$/, "").replace(/\?.*$/, "") : null;
    const beds = [],
      prices = [];
    for (const box of card.querySelectorAll(".bedRentBox")) {
      const b = box.querySelector(".bedTextBox"),
        p = box.querySelector(".priceTextBox");
      if (b) beds.push(b.textContent.trim());
      if (p) prices.push(p.textContent.trim());
    }
    const nums = prices
      .map((p) => parseInt(p.replace(/[^\d]/g, "")))
      .filter((n) => n);
    let rent = null;
    if (nums.length === 1) rent = prices[0];
    else if (nums.length > 1)
      rent =
        "$" +
        Math.min(...nums).toLocaleString() +
        " - $" +
        Math.max(...nums).toLocaleString();
    const bedRange =
      beds.length === 1
        ? beds[0]
        : beds.length > 1
          ? beds[0] + " - " + beds[beds.length - 1]
          : null;
    const phone = (card.querySelector(".phone-link span, a.phone-link") || {})
      .textContent;
    if (name || url)
      out.listings.push({
        name: name ? name.trim() : null,
        address: address ? address.trim() : null,
        url,
        rent,
        beds: bedRange,
        baths: null,
        phone: phone ? phone.trim() : null,
      });
  }
  out.result_count_on_page = out.listings.length;
  return JSON.stringify(out);
})();

Parse the returned .value string into your output object. The eval yields ~40 clean listings per page (run-004: 40/40 with name, address, url, rent, beds, phone populated).

4. Emit output

Set total_results and result_count_on_page from the eval. Do not paste all ~40 listings verbatim into a single LLM turn — that exceeds the per-turn output token budget. The evaluate's returned value is the source of truth; surface a representative slice (e.g. first 12) inline and note that result_count_on_page reflects the full page.

Cross-check / alternative field source — JSON-LD

The page also embeds <script type="application/ld+json"> whose @graph has one node per listing where mainEntity['@type'] === 'ApartmentComplex', carrying name, the canonical URL in @id (strip the #apartmentcomplex fragment), a full PostalAddress, geo lat/lon, amenityFeature[], and an offers AggregateOffer with lowPrice/highPrice. Use it to obtain exact coordinates, postal code, or amenities, or to cross-check rent. It does not contain beds/baths — that's why the placard DOM above is the primary path.

Site-Specific Gotchas

  • No API / HTTP-fetch path — confirmed blocked. Direct GET of search URLs returns 403 AkamaiGHost even with residential proxies (verified via a raw HTTP fetch both with and without a residential proxy → 403). Don't waste time hunting for a JSON endpoint; you must use a full stealth browser.
  • Stealth + a residential proxy are both mandatory. A bare session is denied on the search page. Set proxy: { proxy: "residential" } on the browserless_agent call (stealth is on by default).
  • Akamai "Access Denied" is frequent and only partly recoverable. Even a correctly stealthed session is intermittently served an Akamai block page (<title>Access Denied</title>, body referencing errors.edgesuite.net). Behavior observed during testing: sometimes the first navigation is blocked but a single reload command recovers it in the same session (run-002); other times every fresh session is blocked for a sustained stretch (4+ consecutive fresh sessions denied during one window). Treat success as probabilistic: retry with a fresh stealth + residential-proxy browserless_agent call, up to ~3–4 attempts, with a reload fallback per session. The block rate appears to climb after repeated hits from the same proxy pool, so space requests out.
  • Total count is NOT reliably in <title>. Some renders title the page "Apartments for Rent under $2,500 in Chicago IL | Apartments.com" (no number); others include "9,522 Rentals". Read the on-page results header ("N Rentals Available") and fall back to body.innerText matching /([\d,]+)\s+Rentals/.
  • baths is not on the summary cards. Placards expose bed ranges + per-bed pricing but not bathroom counts; baths will be null. Bathrooms only appear on the individual property detail page (url), which this skill does not open.
  • Don't dump page text. A text command on body / #placardContainer returns ~300KB and blows the inner agent's max output tokens (truncated run-001 and run-003). Always extract with the targeted evaluate above.
  • Don't use the snapshot command on the results page — thousands of a11y nodes; not needed and expensive.
  • a.property-link href carries tracking fragments/queries. Strip #… and ?… to get the canonical https://www.apartments.com/{slug}-{city}-{state}/{id}/ URL.
  • Listing inventory drifts. Total result count changed between runs minutes apart (9,522 → 9,533) — expected; it's live availability, not an extraction error.

Expected Output

{
  "success": true,
  "location": "Chicago, IL",
  "filters": { "max_price": 2500 },
  "total_results": 9533,
  "result_count_on_page": 40,
  "listings": [
    {
      "name": "Presidential Towers",
      "address": "555 W Madison St, Chicago, IL 60661",
      "url": "https://www.apartments.com/presidential-towers-chicago-il/dsd9v8j/",
      "rent": "$1,641 - $1,993",
      "beds": "Studio - 1 Bed",
      "baths": null,
      "phone": "(708) 725-1991"
    },
    {
      "name": "4901 S Drexel",
      "address": "4901 S Drexel Blvd, Chicago, IL 60615",
      "url": "https://www.apartments.com/4901-s-drexel-chicago-il/xveplbn/",
      "rent": "$1,030 - $1,800",
      "beds": "Studio - 1 Bed",
      "baths": null,
      "phone": "(855) 589-9093"
    }
  ],
  "error_reasoning": null
}

Blocked outcome (Akamai wall, after exhausting fresh-session retries):

{
  "success": false,
  "location": "Chicago, IL",
  "filters": { "max_price": 2500 },
  "total_results": null,
  "result_count_on_page": 0,
  "listings": [],
  "error_reasoning": "Akamai 'Access Denied' on every attempt across 4 fresh stealth + residential-proxy sessions; proxy pool IP reputation appears flagged for this window."
}

Call it

GET https://production-sfo.browserless.io/skills?token=TOKEN-HERE&domain=apartments.com&task=search-rentals-33icwz