Shopping and listings

redfin.com

Redfin - For-sale listings by city or ZIP, plus listing details

Notes an AI agent reads before it works on redfin.com: what to open, what to call, what breaks. Written by browser-harness contributors after real runs, and kept on GitHub under the MIT license.

Give it to your agent

Any coding agent

Start the task with this line. The Markdown version is the same notes without the page around them.

Read https://browser-use.com/skills/redfin.md, then <your task>

Browser Harness

Save the files into your workspace and turn domain skills on. The code below calls its helpers, such as http_get and js. What Browser Harness is.

d="${BH_AGENT_WORKSPACE:-$HOME/.config/browser-harness/agent-workspace}/domain-skills/redfin"
mkdir -p "$d" && cd "$d"
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/redfin/scraping.md
export BH_DOMAIN_SKILLS=1

Field-tested against www.redfin.com on 2026-10-23 (reworked the same day to use only robots.txt-allowed pages) using a Browser Use cloud browser.

robots.txt (checked 2026-10-23) disallows /stingray/ for User-agent: *; this skill never requests it. It only loads search pages (/zipcode/<zip> and /city/<id>/<ST>/<City>, the two tested here; block 1 also accepts /neighborhood/... and /county/... paths), their /page-<n> pages, and listing pages (/<ST>/<City>/<address>/home/<id>). None of those is disallowed for that group.

Quick summary

  • A Redfin search page (/zipcode/78704, /city/30818/TX/Austin) renders about 41 listing cards per page and also embeds, in its HTML, the server-rendered map data for up to 350 homes: address, price, beds, baths, sqft, status, lot size, year built, HOA, days on market, MLS id, lat/long and URL.
  • A ZIP with 350 homes or fewer comes back complete from one page load: ZIP 78704 gave all 332 homes the page header counted.
  • Bigger results are paged through /page-<n> (the site's own pagination links). Homes beyond the first 350 come from the card itself (address, price, beds, baths, sqft, lat/long, URL) without status, lot, year built, HOA or MLS id. The city of Austin, TX (header: 5,788 homes + 120 early access) took 150 page loads in about 15 minutes and gave 5,796 unique homes, 112 (1.9%) short of the header, so with its default 1% tolerance block 1 raises there; details below.
  • Listing pages carry a ["Product", "RealEstateListing"] JSON-LD block (price, beds, baths, sqft, year built, geo, photos, description) and embed the detail data the page was rendered from (status, lot size, full/half baths, every MLS fact group).
  • Plain HTTP is blocked (status 429); this skill uses a normal browser session.

What works

1. Search a ZIP or city and collect every listing (browser)

Self-contained. Change SEARCH to a ZIP or to a search page path. Writes JSON and CSV to the system temp dir.

import json, time, csv, os, re, tempfile
 
SEARCH = "78704"     # a ZIP, or a Redfin search page path such as "/city/30818/TX/Austin"
MAX_PAGES = 200      # safety cap on list pages (raises if hit); Austin needed 150
PAUSE = 3.0          # seconds between page loads
MAX_SHORT = 0.01     # raise if more than this share of the header count is missing (a live count drifts a little)
BASE = "https://www.redfin.com"
 
def search_path(s):
    s = s.strip()
    if re.fullmatch(r"\d{5}", s):
        return f"/zipcode/{s}"
    s = re.sub(r"/page-\d+$", "", s.replace(BASE, "").split("?")[0].rstrip("/"))
    if not re.match(r"^/(city|zipcode|neighborhood|county)/", s):
        raise SystemExit(f"SEARCH must be a ZIP or a Redfin /city/, /zipcode/, /neighborhood/ or /county/ path, got {s!r}")
    return s
 
BLOCKED = re.compile(r"request could not be satisfied|ratelimited|access denied|are you a human|captcha", re.I)
CARDS = '[data-rf-test-name="basicNode-homeCard"]'
ZERO = ".ZeroResultsText, .nearbyHomesTitle"   # "We didn't find any exact matches" / "Homes within 1 mile of ..."
POLL_JS = ("JSON.stringify({old: !!window.__bhOld, u: location.href, t: document.title, r: document.readyState, "
           "n: document.querySelectorAll('%s').length, z: !!document.querySelector('%s')})" % (CARDS.replace("'", "\\'"), ZERO))
 
def open_page(url, budget=120, per_try=30):
    """Load url in the tab and wait for the new document. Returns the URL it landed on: url itself once result
    cards (or Redfin's no-exact-matches view) have rendered, or the redirect target, which the caller must check.
    A block or challenge page raises at once (stop and back off). A page that never renders is retried after
    10 s, then 20 s, all inside one budget-second deadline; then it raises."""
    end, pause, attempt = time.time() + budget, 10, 0
    while True:
        attempt += 1
        before = js("window.__bhOld = 1, location.href")   # tag the old document; the new one will not carry the tag
        try:
            goto_url(url)
        except Exception as e:       # Page.navigate often outlives the 5 s daemon wait on these heavy pages
            print("navigate still running:", str(e)[:80])
        stop, n, z, last = min(end, time.time() + per_try), -1, False, None
        while time.time() + 1 < stop:   # the last poll starts before the deadline
            time.sleep(1)
            try:
                st = last = json.loads(js(POLL_JS))
            except Exception:
                continue                 # page still busy; poll again
            if st["old"]:
                continue                 # still the document from before the navigation
            if BLOCKED.search(st["t"]) or "ratelimited" in st["u"]:
                raise RuntimeError(f"Redfin served a block/challenge page ({st['t']!r} at {st['u']}). Stop and back off.")
            if st["r"] != "complete":
                continue
            if st["u"].rstrip("/") != url.rstrip("/"):
                return st["u"]           # a new document at another URL: redirected
            if (st["n"] > 0 and st["n"] == n) or (st["z"] and z):
                return st["u"]           # card count (or the zero-results view) stable across two polls
            n, z = st["n"], st["z"]
        print(f"try {attempt}: {url} not ready (navigated from {before}; last state {last})")
        if time.time() + pause + 15 > end:
            break                        # no time left for a backoff and another try; no sleep after the last one
        time.sleep(pause)
        pause *= 2
    raise RuntimeError(f"{url} did not render within the {budget} s budget ({attempt} tries)")
 
def header_total():
    """'323 homes + 9 early access' -> 332."""
    t = js("(document.querySelector('.homes.summary, [data-rf-test-id=\"homes-description\"]') || {}).innerText || ''")
    nums = [int(x.replace(",", "")) for x in re.findall(r"([\d,]+) (?:homes?|early access)", t)]
    if not nums:
        if js("!!document.querySelector('%s')" % ZERO):
            return 0, "no exact matches"
        raise RuntimeError(f"could not read the result count from the page header: {t!r}")
    return sum(nums), t
 
# The search page HTML embeds the server-rendered map data (up to 350 homes) in __reactServerState.
GIS_JS = r"""(() => {
  const s = [...document.scripts].find(s => !s.src && s.textContent.includes('__reactServerState.InitialContext = '));
  if (!s) return null;
  const p = 'root.__reactServerState.InitialContext = ';
  const line = s.textContent.split('\n').find(l => l.startsWith(p));
  const dc = JSON.parse(line.slice(p.length, -1))['ReactServerAgent.cache'].dataCache;
  const k = Object.keys(dc).find(k => k.startsWith('/stingray/api/gis?'));
  return k ? dc[k].res.text : null;
})()"""
 
def embedded_homes():
    t = js(GIS_JS)
    if not t or not t.startswith("{}&&"):
        raise RuntimeError("no embedded map data on the search page: " + str(t)[:120])
    d = json.loads(t[4:])                # strip the "{}&&" prefix
    if d.get("resultCode") != 0 or not isinstance((d.get("payload") or {}).get("homes"), list):
        raise RuntimeError("unexpected embedded map data: " + t[:160])
    return {h["propertyId"]: h for h in d["payload"]["homes"]}
 
CARD_JS = r"""JSON.stringify([...document.querySelectorAll('[data-rf-test-name="basicNode-homeCard"]')].map(c => {
  const t = s => { const e = c.querySelector(s); return e ? e.textContent.trim() : null; };
  const a = c.querySelector('a.bp-Homecard__Address');
  let ld = null; const s = c.querySelector('script[type="application/ld+json"]');
  if (s) { try { ld = JSON.parse(s.textContent); } catch (e) {} }
  return {href: a && a.getAttribute('href'), address: a && a.textContent.trim(), price: t('.bp-Homecard__Price--value'),
    beds: t('.bp-Homecard__Stats--beds'), baths: t('.bp-Homecard__Stats--baths'), sqft: t('.bp-Homecard__Stats--sqft'),
    sashes: [...c.querySelectorAll(':scope > .bp-Homecard__Sashes [data-rf-test-id="home-sash"]')].map(e => e.textContent.trim()),
    ld};
}))"""
 
def num(s):
    m = re.search(r"[\d,.]+", s or "")
    return float(m.group().replace(",", "")) if m and m.group().strip(".,") else None
 
def v(x):
    return x.get("value") if isinstance(x, dict) else x
 
def from_embedded(h):
    ll = v(h.get("latLong")) or {}
    return {
        "property_id": h.get("propertyId"), "listing_id": h.get("listingId"), "mls_id": v(h.get("mlsId")),
        "address": v(h.get("streetLine")), "city": h.get("city"), "state": h.get("state"), "zip": h.get("zip"),
        "price": v(h.get("price")), "beds": h.get("beds"), "baths": h.get("baths"), "sqft": v(h.get("sqFt")),
        "lot_sqft": v(h.get("lotSize")), "year_built": v(h.get("yearBuilt")), "price_per_sqft": v(h.get("pricePerSqFt")),
        "hoa_month": v(h.get("hoa")), "days_on_market": v(h.get("dom")), "status": h.get("mlsStatus"),
        "neighborhood": v(h.get("location")), "lat": ll.get("latitude"), "lon": ll.get("longitude"),
        "url": BASE + h["url"] if h.get("url") else None, "source": "embedded",
    }
 
def from_card(c, pid):
    ld = c.get("ld") or []
    ld = [x for x in (ld if isinstance(ld, list) else [ld]) if isinstance(x, dict)]   # a card's JSON-LD can be one object or a list
    home = next((x for x in ld if x.get("address")), {})
    offer = next((x.get("offers") for x in ld if x.get("offers")), {}) or {}
    addr, geo = home.get("address") or {}, home.get("geo") or {}
    price = offer.get("price") or num(c.get("price"))
    sq, sqft, lot = c.get("sqft") or "", None, None
    if "(lot)" in sq:                    # land cards say "Land" and "0.38 acre (lot)" or "9,557 sq ft (lot)"
        n = num(sq)
        lot = None if n is None else round(n * 43560 if "acre" in sq else n)
    else:
        sqft = num(sq)
    return {
        "property_id": pid, "listing_id": None, "mls_id": None,
        "address": addr.get("streetAddress") or (c.get("address") or "").split(",")[0],
        "city": addr.get("addressLocality"), "state": addr.get("addressRegion"), "zip": addr.get("postalCode"),
        "price": int(float(price)) if price else None, "beds": num(c.get("beds")), "baths": num(c.get("baths")),
        "sqft": sqft, "lot_sqft": lot, "year_built": None, "price_per_sqft": None, "hoa_month": None,
        "days_on_market": None, "status": None, "neighborhood": None,
        "lat": geo.get("latitude"), "lon": geo.get("longitude"), "url": BASE + c["href"], "source": "card",
    }
 
path = search_path(SEARCH)
new_tab()
landed = open_page(BASE + path)
if landed.rstrip("/") != BASE + path:
    raise RuntimeError(f"{BASE + path} redirected to {landed}; check SEARCH")
total, header = header_total()
emb = embedded_homes() if total else {}
print(f"{path}: header says {header!r} -> {total}; embedded map data has {len(emb)} homes")
 
rows = {}
if len(emb) >= total:                     # the whole result set is in the page's embedded map data
    for pid, h in emb.items():
        rows[pid] = from_embedded(h)
else:                                     # page through the list (about 41 cards per page)
    page = 1
    while True:
        cards = json.loads(js(CARD_JS))
        new = 0
        for c in cards:
            m = re.search(r"/home/(\d+)", c.get("href") or "")
            if not m:
                continue
            pid = int(m.group(1))
            if pid not in rows:
                rows[pid] = from_embedded(emb[pid]) if pid in emb else from_card(c, pid)
                new += 1
        print(f"page {page}: {len(cards)} cards, {new} new, {len(rows)} unique so far")
        if len(rows) >= total or new == 0:
            break
        page += 1
        if page > MAX_PAGES:
            raise RuntimeError(f"hit MAX_PAGES={MAX_PAGES}; {len(rows)} of {total} rows so far. Raise MAX_PAGES and rerun.")
        time.sleep(PAUSE)
        want = f"{BASE}{path}/page-{page}"
        landed = open_page(want)
        if landed.rstrip("/") != want:
            print(f"page-{page} redirected to {landed}; treating it as the end of the list")
            break
    for pid, h in emb.items():            # embedded homes no card showed are still part of the result
        rows.setdefault(pid, from_embedded(h))
 
rows = list(rows.values())
short = total - len(rows)
if short > max(2, total * MAX_SHORT):
    raise RuntimeError(f"collected {len(rows)} of the {total} homes the page header reports ({header!r}), {short} missing. "
                       "Raise MAX_SHORT only if you accept an incomplete result.")
 
out = os.path.join(tempfile.gettempdir(), "redfin_listings")
with open(out + ".json", "w") as f:
    json.dump(rows, f)
with open(out + ".csv", "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=list(from_embedded({})))   # same columns even when rows is empty
    w.writeheader()
    w.writerows(rows)
print("saved", len(rows), "rows to", out + ".json", "and .csv;", sum(r["source"] == "card" for r in rows), "from cards only;",
      f"header count {total}, {max(short, 0)} short")
print(json.dumps(rows[:2], indent=1))

Observed output (trimmed):

/zipcode/78704: header says '323 homes + 9 early access' -> 332; embedded map data has 332 homes
saved 332 rows to /tmp/.../redfin_listings.json and .csv; 0 from cards only; header count 332, 0 short
[
 {"property_id": 31703545, "listing_id": 224677425, "mls_id": "3820245",
  "address": "503 S Park Dr", "city": "Austin", "state": "TX", "zip": "78704",
  "price": 1120000, "beds": 3, "baths": 2.5, "sqft": 1640, "lot_sqft": 11704, "year_built": 2023,
  "price_per_sqft": 683, "hoa_month": null, "days_on_market": 1, "status": "Active",
  "neighborhood": "SOUTH PARK SEC 2", "lat": 30.232246, "lon": -97.7638868,
  "url": "https://www.redfin.com/TX/Austin/503-S-Park-Dr-78704/home/31703545", "source": "embedded"},
 {"property_id": 31205095, "address": "1818 Margaret St", "price": 2995000, "beds": 3, "baths": 4.0,
  "status": "Coming Soon", "days_on_market": null, ...}
]

That ZIP run took one page load. Null counts across the 332 rows: address 1, price 1, baths 7, sqft 10, lot_sqft 19, year_built 10, price_per_sqft 11, hoa_month 173, days_on_market 9, neighborhood 1. Status values: Active 322, Coming Soon 9, New 1.

Results over 350 homes page through the list. SEARCH = "78745" (header 357 homes + 8 early access) took 10 page loads in 54 s and saved 369 rows, 19 of them from cards only. SEARCH = "/city/30818/TX/Austin", run with MAX_SHORT = 0.02 as the only other change, printed:

/city/30818/TX/Austin: header says '5,788 homes + 120 early access' -> 5908; embedded map data has 350 homes
page 1: 41 cards, 41 new, 41 unique so far
...
page 9: 22 cards, 22 new, 350 unique so far
page 10: 41 cards, 41 new, 391 unique so far
...
page 144: 22 cards, 22 new, 5597 unique so far
page 145: 41 cards, 40 new, 5637 unique so far
...
page 149: 36 cards, 36 new, 5796 unique so far
page 150: 41 cards, 0 new, 5796 unique so far
saved 5796 rows to /tmp/.../redfin_listings.json and .csv; 5446 from cards only

That crawl took 14 min 39 s and ran an earlier version of block 1, whose save line did not yet print the header count and shortfall. The 78745 paging run was repeated with the current block: same 10 pages and 369 rows, ending header count 365, 0 short. The 112 missing homes are close to the 120 early-access homes in the header count, so the paged list may leave most early-access homes out past the first 350; that was not checked home by home. With the default MAX_SHORT = 0.01 the same numbers raise instead of saving (112 > 59), and an earlier full Austin crawl the same afternoon (5,795 of 5,903) did raise. The paged list is the whole crawl this skill makes, so a big city's result can stay short of the header count; block 1 raises rather than report it as complete. Across the 5,446 Austin card rows: price null 14, beds 431, baths 402, sqft 412; 390 were land cards whose lot size went to lot_sqft.

2. Key facts from a listing detail page (browser)

Reads the first URL saved by block 1; pass any https://www.redfin.com/.../home/<id> URL to listing_details() instead. Everything comes from the one listing page: its JSON-LD plus the detail data embedded in its HTML.

import json, os, re, time, tempfile
 
# Take a listing URL from the search output (block above); any redfin.com/.../home/<id> URL works.
saved = os.path.join(tempfile.gettempdir(), "redfin_listings.json")
listings = json.load(open(saved)) if os.path.exists(saved) else []
URL = listings[0]["url"] if listings else None
if not URL:
    raise SystemExit("Run the search block first, or set URL to a redfin.com listing URL")
 
BLOCKED = re.compile(r"request could not be satisfied|ratelimited|access denied|are you a human|captcha", re.I)
READY_JS = ("[...document.querySelectorAll('script[type=\"application/ld+json\"]')].some(s => s.textContent.includes('RealEstateListing'))"
            " && [...document.scripts].some(s => !s.src && s.textContent.includes('__reactServerState.InitialContext = '))")
 
def open_listing(url, budget=120, per_try=30):
    """Load a listing page and wait for the new document. Returns the URL it landed on: url itself once its JSON-LD
    and embedded data are there, or the redirect target (a gone listing redirects away). A block or challenge page
    raises at once (stop and back off). A page that never finishes is retried after 10 s, then 20 s, all inside
    one budget-second deadline; then it raises."""
    end, pause, attempt = time.time() + budget, 10, 0
    while True:
        attempt += 1
        before = js("window.__bhOld = 1, location.href")   # tag the old document; the new one will not carry the tag
        try:
            goto_url(url)
        except Exception as e:       # Page.navigate often outlives the 5 s daemon wait on these heavy pages
            print("navigate still running:", str(e)[:80])
        stop, last = min(end, time.time() + per_try), None
        while time.time() + 1 < stop:   # the last poll starts before the deadline
            time.sleep(1)
            try:
                st = last = json.loads(js("JSON.stringify({old: !!window.__bhOld, u: location.href, t: document.title, "
                                          "r: document.readyState, ok: %s})" % READY_JS))
            except Exception:
                continue                 # page still busy; poll again
            if st["old"]:
                continue                 # still the document from before the navigation
            if BLOCKED.search(st["t"]) or "ratelimited" in st["u"]:
                raise RuntimeError(f"Redfin served a block/challenge page ({st['t']!r} at {st['u']}). Stop and back off.")
            if st["r"] == "complete" and (st["ok"] or st["u"].rstrip("/") != url.rstrip("/")):
                return st["u"]           # ready, or a new document at another URL (redirected)
        print(f"try {attempt}: {url} not ready (navigated from {before}; last state {last})")
        if time.time() + pause + 15 > end:
            break                        # no time left for a backoff and another try; no sleep after the last one
        time.sleep(pause)
        pause *= 2
    raise RuntimeError(f"{url} did not finish loading within the {budget} s budget ({attempt} tries)")
 
# The listing page HTML embeds the server-rendered detail data in __reactServerState.
STATE_JS = r"""(() => {
  const s = [...document.scripts].find(s => !s.src && s.textContent.includes('__reactServerState.InitialContext = '));
  const p = 'root.__reactServerState.InitialContext = ';
  const line = s.textContent.split('\n').find(l => l.startsWith(p));
  const dc = JSON.parse(line.slice(p.length, -1))['ReactServerAgent.cache'].dataCache;
  const pick = part => { const k = Object.keys(dc).find(k => k.split('?')[0].endsWith(part)); return k ? dc[k].res.text : null; };
  return JSON.stringify({info: pick('/home/details/initialInfo'), atf: pick('/home/details/aboveTheFold'),
                         btf: pick('/home/details/belowTheFold')});
})()"""
 
def payload(text, name):
    if not text or not text.startswith("{}&&"):
        raise RuntimeError(f"{name} is missing from the page's embedded data: {str(text)[:120]}")
    d = json.loads(text[4:])             # strip the "{}&&" prefix
    if d.get("resultCode") != 0 or not isinstance(d.get("payload"), dict):
        raise RuntimeError(f"unexpected embedded {name}: {text[:160]}")
    return d["payload"]
 
def strip_tags(s):
    return re.sub(r"<[^>]+>", "", s or "").strip()
 
def listing_details(url):
    landed = open_listing(url)
    if landed.rstrip("/") != url.rstrip("/"):
        print(f"{url} redirected to {landed}")
        return None                      # listing gone or moved: never read another page's facts
    # 1) JSON-LD block typed ["Product", "RealEstateListing"]
    ld = None
    for t in js("[...document.querySelectorAll('script[type=\"application/ld+json\"]')].map(s => s.textContent)") or []:
        try:
            d = json.loads(t)
        except ValueError:
            continue
        if isinstance(d, dict) and "RealEstateListing" in (d.get("@type") or []):
            ld = d
    if not ld:
        raise RuntimeError("no RealEstateListing JSON-LD on " + landed)
    me, offer = ld.get("mainEntity", {}), ld.get("offers", {})
    facts = {
        "url": url,
        "address": me.get("address", {}),
        "price": offer.get("price"),
        "availability": (offer.get("availability") or "").rsplit("/", 1)[-1],
        "date_posted": ld.get("datePosted"),
        "home_type": me.get("accommodationCategory"),
        "beds": me.get("numberOfBedrooms"),
        "baths": me.get("numberOfBathroomsTotal"),
        "sqft": (me.get("floorSize") or {}).get("value"),
        "year_built": me.get("yearBuilt"),
        "lat": (me.get("geo") or {}).get("latitude"),
        "lon": (me.get("geo") or {}).get("longitude"),
        "amenities": [a.get("name") for a in me.get("amenityFeature", [])],
        "photos": [i.get("url") for i in me.get("image", [])],
        "description": (ld.get("description") or "").strip(),
    }
    # 2) the same page's embedded detail data (no extra requests)
    st = json.loads(js(STATE_JS))
    p = payload(st["info"], "initialInfo")
    atf = payload(st["atf"], "aboveTheFold").get("addressSectionInfo")
    if not isinstance(atf, dict):
        raise RuntimeError("aboveTheFold has no addressSectionInfo on " + landed)
    btf = payload(st["btf"], "belowTheFold")
    facts.update({
        "property_id": p.get("propertyId"), "listing_id": p.get("listingId"), "mls_id": p.get("mlsId"),
        "status": (atf.get("status") or {}).get("displayValue"),
        "lot_sqft": atf.get("lotSize"),
        "price_per_sqft": atf.get("pricePerSqFt"),
        "full_baths": atf.get("numFullBaths"), "half_baths": atf.get("numPartialBaths"),
    })
    groups = {}
    for sg in (btf.get("amenitiesInfo") or {}).get("superGroups", []):
        for g in sg.get("amenityGroups", []):
            for e in g.get("amenityEntries", []):
                vals = ", ".join(strip_tags(x) for x in e.get("amenityValues", []))
                groups.setdefault(g.get("groupTitle") or "untitled", []).append(f'{e.get("amenityName") or e.get("referenceName")}: {vals}')
    facts["mls_details"] = groups
    return facts
 
d = listing_details(URL)
if d is None:
    print("listing page not available:", URL)
else:
    out = dict(d, description=d["description"][:120] + "...", photos=f'{len(d["photos"])} urls, first: {d["photos"][0] if d["photos"] else None}',
               mls_details={k: v[:3] for k, v in list(d["mls_details"].items())[:6]} if d.get("mls_details") else None)
    print(json.dumps(out, indent=1))

Observed output (trimmed):

{
 "url": "https://www.redfin.com/TX/Austin/503-S-Park-Dr-78704/home/31703545",
 "address": {"@type": "PostalAddress", "streetAddress": "503 S Park Dr", "addressLocality": "Austin",
             "addressRegion": "TX", "postalCode": "78704", "addressCountry": "US"},
 "price": 1120000, "availability": "InStock", "date_posted": "2026-10-23T18:45:28.294Z",
 "home_type": "Single Family Residential", "beds": 3, "baths": 2.5, "sqft": 1640, "year_built": 2023,
 "lat": 30.232246, "lon": -97.7638868,
 "amenities": ["Heating & cooling: Has A/C"],
 "photos": "22 urls, first: https://ssl.cdn-redfin.com/photo/92/bigphoto/245/3820245_0.jpg",
 "property_id": 31703545, "listing_id": 224677425, "mls_id": "3820245",
 "status": "Active", "lot_sqft": 11704, "price_per_sqft": 683, "full_baths": 2, "half_baths": 1,
 "mls_details": {
  "Bedroom Information": ["# of Main Level Bedrooms: 3"],
  "Bathroom Information": ["# of Full Bathrooms: 2", "# of Half Bathrooms: 1"],
  "Room 1 Information": ["Level: First"],
  "Laundry Information": ["Features: Inside"],
  ...
 }
}

A second listing (ZIP 78745, 4 bed / 2.5 bath) gave 40 photo URLs and MLS groups Parking, Virtual Tour, Bedroom Information, Bathroom Information, Laundry Information and Equipment.

Fields

Search rows. source says where the row came from: embedded (the search page's embedded map data, every field below) or card (a list card for a home outside the embedded 350; the fields marked "embedded only" are null). In the embedded data many numeric fields are wrapped as {"value": X, "level": 1}; a wrapper with no value means hidden.

Output fieldEmbedded map data (payload.homes[])CardExample
property_idpropertyId/home/<id> in the card link31703545
listing_id, mls_idlistingId, mlsId.valueembedded only224677425, "3820245"
addressstreetLine.value (includes unit, e.g. #1303)JSON-LD address.streetAddress"503 S Park Dr"
city / state / zipcity, state, zipJSON-LD address"Austin", "TX", "78704"
priceprice.value (USD int)JSON-LD offers.price, else the card price text1120000
beds / bathsbeds, bathscard text "3 beds", "2.5 baths" (floats; null on land cards, which say "Land")3, 2.5
sqftsqFt.valuecard text "1,640 sq ft" (float; null on land cards)1640
lot_sqftlotSize.valueland cards only: "0.38 acre (lot)" (x 43,560) or "9,557 sq ft (lot)"11704
year_built, price_per_sqftyearBuilt.value, pricePerSqFt.valueembedded only2023, 683
hoa_month, days_on_markethoa.value, dom.valueembedded onlynull, 1
statusmlsStatusembedded only"Active", "Coming Soon", "New"
neighborhoodlocation.valueembedded only"SOUTH PARK SEC 2"
lat / lonlatLong.value.latitude/longitudeJSON-LD geo30.232246, -97.7638868
url"https://www.redfin.com" + urlcard link.../home/31703545
source"embedded""card"
(not flattened)listingRemarks, keyFacts, listingTags, sashes, numPictures, stories, isNewConstruction, uiPropertyType, timeZone

Detail page:

FieldSourceExample
price, availabilityJSON-LD offers.price, offers.availability1120000, InStock
address, geoJSON-LD mainEntity.address, mainEntity.geoPostalAddress dict
home_typeJSON-LD mainEntity.accommodationCategory"Single Family Residential"
beds, baths, sqft, year_builtJSON-LD numberOfBedrooms, numberOfBathroomsTotal, floorSize.value, yearBuilt3, 2.5, 1640, 2023
amenitiesJSON-LD mainEntity.amenityFeature[].name["Heating & cooling: Has A/C"]
photosJSON-LD mainEntity.image[].url22 URLs
description, date_postedJSON-LD description, datePostedfull remarks text
property_id, listing_id, mls_idembedded initialInfo propertyId, listingId, mlsId31703545, 224677425, "3820245"
statusembedded aboveTheFold addressSectionInfo.status.displayValue"Active"
lot_sqft, price_per_sqft, full/half bathsembedded aboveTheFold lotSize, pricePerSqFt, numFullBaths, numPartialBaths11704, 683, 2, 1
mls_detailsembedded belowTheFold amenitiesInfo.superGroups[].amenityGroups[].amenityEntries[]{"Laundry Information": ["Features: Inside"]}

Gotchas

  • The embedded data sits in an inline script as one line, root.__reactServerState.InitialContext = {...};, under ReactServerAgent.cache.dataCache. Each entry is keyed by the internal URL the server used to render the page and holds the response text with a {}&& prefix; strip 4 characters before json.loads. The skill only reads this from the page HTML it already loaded.
  • The embedded map data is capped at 350 homes and is the same on every /page-<n> (Austin page 1 and page 2 embedded the identical first 350). Only the list cards change from page to page.
  • The list comes in chunks of 350: every 9th page rendered only 22 cards (8 x 41 + 22 = 350). Austin had 16 such pages (9, 18, ..., 144) and ZIP 78745 had page 9; the next page carries on with the next chunk. In ZIP 78745, 5 of page 10's 24 cards repeated earlier ones, so keep the property_id dedupe.
  • The end of the list is not one fixed signal. In one probe Austin page-144 and page-146 redirected to /city/30818/TX/Austin; in the full crawls the list ran to page 149 and page 150 showed 41 cards that were all repeats. Block 1 stops on a redirect, on a page with no new homes, or once it has the header's count.
  • The site counts nearby homes: ZIP 78704's embedded data held 3 homes with ZIP 78745, and the header total (323 + 9 = 332) included them. ZIP 78745 gave 369 unique rows against a header of 365. City results follow Redfin's boundary, not the mailing city (Austin's first 350 included Pflugerville, Manchaca, Del Valle, Manor and Cedar Park addresses).
  • The header count moves while you crawl (Austin's header read 5,789 on one load and 5,786 on a later one the same afternoon), so block 1 allows a shortfall of MAX_SHORT (1%) and raises beyond that.
  • goto_url() on these pages often raised Page.navigate timed out after 5s waiting for the daemon while the page went on loading. The open helpers swallow that and poll the page instead, so a slow load is not mistaken for a failure. If the navigation never starts, the old page stays up and looks ready (tested by replacing goto_url with a call that only raises: open_page read nothing and raised after 120 s), so each helper first tags the current document (window.__bhOld) and records its URL, then reads nothing until a new document without the tag is there, and compares its location.href with the requested URL: a different URL is returned as a redirect, never read as the requested page. Both checks matter here: a listing page matches the card selector too (31 cards on one), and /TX/Austin/503-S-Park-Dr-78704/home/1 redirected to a different listing in Seattle, for which block 2 returns None. Retries share one 120 s deadline (two backoffs, 10 s and 20 s, and no sleep after the last try).
  • A ZIP with no homes for sale has no header count. ZIP 20500 showed "We didn't find any exact matches" (.ZeroResultsText, 0 cards, empty embedded homes); ZIP 10020 showed 20 nearby homes under "Homes within 1 mile of 10020, NY" (.nearbyHomesTitle) and no homes key in the embedded data. Block 1 treats both as 0 homes and saves an empty JSON list and a header-only CSV.
  • There is no ZIP or city id lookup in this skill. ZIPs need no id (/zipcode/78704). City ids come from the address bar after searching on redfin.com by hand, or from the "Popular Cities" links on /state/<State> (16 cities on /state/Texas, including /city/30818/TX/Austin).
  • A card's JSON-LD is usually a list (SingleFamilyResidence or Accommodation, plus a Product with offers.price), but 3 of the 41 cards on Austin page 35 carried a single object with no Product. Block 1 accepts both and then takes the price from the card text.
  • Land cards put Land where beds go and the lot size where sqft goes (0.38 acre (lot), 9,557 sq ft (lot)); 7 of the 22 cards on Austin page 18 were land. Block 1 moves that number to lot_sqft.
  • Selectors: block 1 reads cards by data-rf-test-name="basicNode-homeCard" and the bp-Homecard__* class names (price, stats, address link). If cards stop being found, the block raises after its retries; check those names first.
  • The JSON-LD datePosted of the same listing changed in its milliseconds from load to load (.271Z, .291Z, .294Z), so do not use it as a change detector.
  • The page loads a OneTrust (cookielaw) consent script. Nothing in this skill required dismissing it.
  • Pacing used here: 3 s between search pages, one listing page at a time. No 429 or challenge page appeared in three back-to-back 150-page Austin crawls (about 15 minutes each), a partial one, and several dozen other page loads the same afternoon.

What does not work

  • Plain HTTP is blocked: curl https://www.redfin.com/robots.txt was redirected to ratelimited.redfin.com with status 429, so even robots.txt was read in the browser.
  • /stingray/ (the JSON API behind the site's map, autocomplete and detail panels) is disallowed for User-agent: *. This skill does not call it; the pages' own scripts still use it while they render.
  • Typing into the home page search box from the cloud browser did nothing: the box sat in a container marked disabled and the typed text never landed, so the skill takes a ZIP or a search page path instead.
  • /sitemap_com_city.xml (listed in robots.txt) returned a CloudFront 403 "Request blocked" page in the browser, so it is not a way to look up city ids.
  • Fields only the embedded data has (status, year built, HOA, days on market, MLS id, and lot size except on land cards) are null on card rows; open those listings with block 2 if you need them.

scraping.md · updated Oct 23, 2026 · 4,467 words

Sites change. These notes describe what worked when they were last updated, not a guarantee they still do. If something broke, the fix is a pull request to browser-harness.