# Redfin Listings + Investor Deal Scoring (`steadycrawl/redfin-deal-finder`) Actor

Pull live Redfin for-sale listings by city, bounding box, or search URL, and score every listing 0-100 for investor deal quality: $/sqft vs the batch median, days-on-market percentile, gross rent yield, and last-sale delta.

- **URL**: https://apify.com/steadycrawl/redfin-deal-finder.md
- **Developed by:** [Ian Rose](https://apify.com/steadycrawl) (community)
- **Categories:** Real estate, Automation, AI
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.70 / 1,000 listing scoreds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Redfin Deal Finder

Pull live Redfin for-sale listings by city, bounding box, or search URL, and
get every listing back with a transparent **0-100 investor deal score** --
not just a field dump.

### What it does

1. You give it one or more searches: `"Austin, TX"`, a raw bounding box, or
   a redfin.com search URL.
2. It queries Redfin's public `/stingray/api/gis` JSON endpoint directly
   (the same endpoint the redfin.com map view uses) -- no browser, no HTML
   scraping, no residential proxy spend.
3. Each listing comes back normalized (price, beds, baths, sqft, address,
   lat/lng, days on market, continuous listing age, year built, lot size,
   monthly HOA, the agent's listing remarks, and last-sale date where
   Redfin exposes it) **plus** four investor-relevant metrics and a single
   blended deal score, computed transparently from the listings in your own
   search batch.

### Why deal scores (and why this is different from the field-dump actors)

Most Redfin scrapers hand you the same raw JSON Redfin's map already shows
you. This actor adds the layer an investor actually wants: is this listing
*cheap for the area*, has it been *sitting long enough to negotiate*, does
it *cash-flow* at your assumed rent, and is it priced *below what it last
sold for*. Every input to the score is documented below -- nothing is a
black box.

### Sample output

One real listing, exactly as this actor pushed it -- normalized and scored
against a live 25-listing Boise batch (no rent assumption supplied, so the
rent-yield component is `null`; the 699-character `listingRemarks` string is
the only thing abbreviated here, for readability):

```json
{
  "propertyId": 181145809,
  "mlsId": "98997726",
  "price": 403990,
  "beds": 3,
  "baths": 2.5,
  "sqft": 1679,
  "streetLine": "12535 W Victory Rd",
  "city": "Boise",
  "state": "ID",
  "zip": "83709",
  "latitude": 43.575434,
  "longitude": -116.338079,
  "daysOnMarket": 1,
  "listingAgeDays": 1.2205244675925926,
  "propertyType": 13,
  "yearBuilt": 2022,
  "lotSize": 2657,
  "hoaMonthly": 230,
  "listingRemarks": "Welcome to 12535 W Victory Rd! Built in 2022, this spacious Boise townhome offers 3 bedrooms...",
  "url": "https://www.redfin.com/ID/Boise/12535-W-Victory-Rd-83709/home/181145809",
  "lastSaleDate": null,
  "lastSalePrice": null,
  "sashesRaw": [7],
  "ppsf": 240.61346039309112,
  "ppsfDeltaPct": -29.11062571765032,
  "domPercentile": 70,
  "grossRentYieldPct": null,
  "lastSaleDeltaPct": null,
  "dealScore": 88.3,
  "batch": {
    "search": "Boise, ID",
    "ppsfMedian": 339.42105263157896,
    "ppsfMedianReason": null,
    "batchSize": 25,
    "validPpsfCount": 25,
    "truncated": false,
    "truncatedTiles": 0,
    "capReachedEarly": false
  }
}
```

**Where 88.3 comes from** -- every number above is reproducible from the
weights table below:

- **$/sqft:** `403990 / 1679 = 240.61` per sqft vs. the batch median of
  `339.42` -> `ppsfDeltaPct = -29.11%` (29% cheaper per sqft than the
  batch). Mapped over `-30%..+30% -> 100..0`, that's a sub-score of
  `98.52`.
- **Days on market:** this listing has been up `1.22` days; 17 of the 25
  listings in the batch are newer, so its midrank percentile is
  `(17 + 0.5) / 25 = 70.0`. Percentiles are used directly as the sub-score.
- **Rent yield / last sale:** both `null` here (no `monthlyRentAssumption`
  given, and Redfin published no last-sale price for this home), so their
  weights drop out and the remaining two renormalize over `0.45 + 0.25 =
  0.70`.
- **Blend:** `98.52 x (0.45/0.70) + 70.0 x (0.25/0.70) = 63.33 + 25.00 =
  88.33` -> **`dealScore` 88.3**.

For contrast, the same batch's one genuinely stale listing (75.3 days up)
lands at `domPercentile` 98.0 -- the highest in the batch, above every
same-day listing -- but it's priced ~4% *above* the batch's $/sqft median,
so its blended score comes out at 63.2. High DOM alone doesn't buy a high
score; the score is the blend.

### Input examples

**1. City search (looked up in a bundled ~660-city table):**

```json
{ "searches": ["Austin, TX", "Boise, ID"], "radiusMiles": 8, "maxListingsPerSearch": 300 }
```

**2. Raw bounding box (`lat1,lng1,lat2,lng2`):**

```json
{ "searches": ["30.20,-97.80,30.32,-97.68"], "maxListingsPerSearch": 500 }
```

**3. Investor filters + rent assumption (enables the rent-yield metric):**

```json
{
  "searches": ["Tampa, FL"],
  "monthlyRentAssumption": 2200,
  "minPrice": 150000,
  "maxPrice": 400000,
  "minBeds": 2
}
```

### Scoring methodology

`dealScore` (0-100) is a weighted blend of up to four sub-scores. If a
component can't be computed for a given batch or listing (e.g. no rent
assumption supplied, or fewer than 8 listings in the batch), its weight is
dropped and the rest are renormalized -- a listing missing one metric is
still comparable to one with all four.

| Component | Weight | What it measures | Mapping |
|---|---|---|---|
| $/sqft advantage | 45% | Listing's price/sqft vs. the **median** price/sqft of your search batch | -30%..+30% delta -> 100..0 (cheaper = higher score) |
| Days-on-market percentile | 25% | How long this listing has sat vs. the rest of the batch | 0..100 **midrank** percentile, used directly (older = more negotiable = higher score) |
| Gross rent yield | 20% | `12 * monthlyRentAssumption / price * 100`, only if you supply `monthlyRentAssumption` | 0%..12% -> 0..100 |
| Last-sale delta | 10% | Current price vs. the listing's last recorded sale price, where Redfin exposes it | -20%..+20% delta -> 100..0 (priced below last sale = higher score) |

`ppsfMedian` (the batch's $/sqft baseline) requires at least **8** listings
with both a price and a sqft in the batch; below that, `ppsfMedian` and
every listing's `ppsfDeltaPct` come back `null` with a `ppsfMedianReason`
string explaining why, and the deal score falls back to whatever other
components are available.

**How the DOM percentile handles ties and fresh listings.** Redfin's own
`dom` field is whole days and pins at `1` for nearly every fresh listing
(22 of 25 in the live sample above), which makes it useless for ranking
within a normal batch. So the score is driven by `listingAgeDays` -- a
continuous listing age derived from Redfin's millisecond `timeOnRedfin`
field -- and falls back to `daysOnMarket` only when that's missing. Ties
are scored by **midrank** (listings strictly below, plus half the listings
tied with you): a group all tied at the freshest end lands mid-pack rather
than each member claiming the whole tied group as "older than me".

**What's in the batch.** Every scored row carries a `batch` object with the
search it came from, the median it was compared against, the batch size,
and a `truncated`/`truncatedTiles` pair -- see "Coverage and dedupe" below.

### Coverage and dedupe

- **Deduped across the whole run, not just within one search.** A listing
  is keyed by its Redfin `propertyId` and pushed at most once per run, so
  overlapping searches (`"Boise, ID"` plus a bounding box over the same
  neighborhood) never bill you twice for the same home or double it in the
  dataset. The first search to see a listing keeps it, and it is scored
  against that search's batch.
- **Dense areas are tiled.** Redfin's map endpoint truncates around 350
  homes per request, so a search box that hits that ceiling is split into
  quadrants and re-queried, up to 3 levels deep. If a tile is *still*
  saturated at the deepest level, the run logs a warning and every row from
  that search carries `batch.truncated: true` and a `batch.truncatedTiles`
  count. In practice the cap below usually stops the crawl before the depth
  limit is ever reached, so for dense searches the coverage signal to read
  is `batch.capReachedEarly`, not `truncated`. Narrow the search (smaller
  `radiusMiles` or a tighter bounding box) or raise the cap to get full
  coverage of an area that flags either one.
- **The cap stops the crawl, not just the output.** Tiles are fetched only
  until `maxListingsPerSearch` filter-passing listings are in hand; the rest
  of the quadtree is skipped. A dense search that stops this way carries
  `batch.capReachedEarly: true` -- the batch is the first N listings of a
  larger area, not the whole area. Raise `maxListingsPerSearch` (up to 1000)
  for fuller coverage. This is also what keeps the default input fast: the
  prefilled "Austin, TX" + "Boise, ID" run completes in a handful of requests
  instead of walking ~90 tiles.
- **Failed searches never charge you.** A search that can't be resolved
  (unparseable city, out-of-range bounding box, a Redfin URL with no
  viewport in it) is pushed to the dataset as an uncharged
  `{ searchInput, error }` row so you can see exactly what went wrong. If
  *every* search in a run fails, the run itself fails rather than reporting
  success on an empty dataset.

### Integration

**curl (Apify API):**

```bash
curl "https://api.apify.com/v2/acts/steadycrawl~redfin-deal-finder/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -X POST -H "Content-Type: application/json" \
  -d '{"searches": ["Austin, TX"], "maxListingsPerSearch": 100}'
```

**Python (apify-client):**

```python
from apify_client import ApifyClient

client = ApifyClient(APIFY_TOKEN)
run = client.actor("steadycrawl/redfin-deal-finder").call(
    run_input={"searches": ["Austin, TX"], "monthlyRentAssumption": 2000}
)
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["dealScore"], item["streetLine"])
```

**Make / n8n:** use the built-in Apify app/node -- run this actor, then feed
`defaultDatasetId` into a "Get dataset items" step and sort/filter on
`dealScore`.

**MCP:** this actor is callable from any MCP-compatible client via Apify's
[Actors MCP Server](https://mcp.apify.com) -- point your MCP client at it
and call `steadycrawl/redfin-deal-finder` like any other tool.

### Limitations (transparent, by design)

- **Extreme $/sqft outliers are excluded from scoring:** when a listing's $/sqft deviates more than ±60% from the batch median (land parcels, part-shares, data errors), its value axis is dropped and `ppsfAxisReason` explains why — the raw delta is still reported, but it can't fake a top deal score.

- **For-sale search results only, v1.** No sold-comps search, no
  price-drop history -- Redfin exposes those through endpoints that are
  edge-blocked for plain HTTP (see the "architecture" note below).

- **`lastSaleDate` is common; `lastSalePrice` is rare.** The date comes
  from Redfin's own top-level sold-date field and was populated for 16 of
  the 25 listings in the live sample above. The *price* is only picked up
  when Redfin ships it on a listing's `sashes` badge, and in that same
  sample every sash carried a blank placeholder (`lastSalePrice: ""`)
  rather than a real number -- so `lastSalePrice`, and with it the
  last-sale component of the deal score (10% weight), is genuinely
  best-effort. When it's absent its weight is redistributed across the
  other components, so scores stay comparable.

- **Rent yield needs your own assumption.** There's no automatic market
  rent estimate; supply `monthlyRentAssumption` to enable that metric.

- **`propertyType` is Redfin's internal numeric enum**, passed through
  as-is rather than decoded to a label (the enum table isn't publicly
  documented and guessing at it risked shipping wrong labels).

- **Architecture constraint:** this actor calls exactly one Redfin
  endpoint, `/stingray/api/gis`, and nothing else. Redfin's
  location-autocomplete, `/zipcode/` pages, and per-listing detail endpoint
  are all edge-WAF-blocked for plain HTTP and are never called. Monthly HOA
  (`hoaMonthly`) and the agent's listing remarks (`listingRemarks`) *are*
  included -- they ship in the map endpoint's own payload (HOA on 15 of 25
  and remarks on 25 of 25 listings in the live sample). What genuinely
  needs a blocked endpoint, and is therefore out of scope until that
  changes, is deeper per-listing detail: full price-drop history, tax
  history, and school/walkability data.

### Support

Issues get a same-day response. Open one on the actor's Issues tab with a
sample input and what you expected to see.

### Changelog

- **1.0.6** -- cap-aware crawl: stop fetching tiles once `maxListingsPerSearch`
  filter-passing listings are collected (new `batch.capReachedEarly` flag);
  the prefilled input now finishes in seconds instead of walking the whole
  quadtree (86 tiles / ~4 min for "Austin, TX"), which is what tripped
  Apify's 5-minute Store QA run. Local test harness now also runs the exact
  prefilled input as a timing gate.
- **1.0.5** -- run-start pricing gate (refuse to crawl when the
  `listing-scored` event carries no price), $/sqft outlier guard.
- **1.0.0** -- initial release: gis-only crawl, quadtree tile splitting,
  offline city lookup table (~660 US cities), 4-component deal score,
  pay-per-event pricing.

# Actor input Schema

## `searches` (type: `array`):

One or more search sources. Each item is either "City, ST" (looked up in a bundled ~660-city table), a bounding box "lat1,lng1,lat2,lng2", or a redfin.com search URL (viewport/poly parsed if present).

## `radiusMiles` (type: `integer`):

Half-width of the search box around a "City, ST" center. Ignored for bbox/URL searches.

## `maxListingsPerSearch` (type: `integer`):

Cap on deduped, filtered listings pushed per search item.

## `monthlyRentAssumption` (type: `integer`):

Optional. If set, enables the gross rent yield metric and its weight in the deal score.

## `minPrice` (type: `integer`):

Optional client-side filter.

## `maxPrice` (type: `integer`):

Optional client-side filter.

## `minBeds` (type: `integer`):

Optional client-side filter.

## `useApifyProxy` (type: `boolean`):

Always datacenter -- this actor's economics and the gis-only architecture assume datacenter proxy only. There is no group/residential picker: the code forces the datacenter pool regardless of input.

## Actor input object example

```json
{
  "searches": [
    "Austin, TX",
    "Boise, ID"
  ],
  "radiusMiles": 6,
  "maxListingsPerSearch": 200,
  "useApifyProxy": true
}
```

# Actor output Schema

## `scoredListings` (type: `string`):

Every Redfin for-sale listing found, normalized and enriched with a 0-100 investor deal score plus its component metrics ($/sqft vs batch median, days-on-market percentile, gross rent yield, last-sale delta). Failed searches appear as uncharged error records with a searchInput and error field.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searches": [
        "Austin, TX",
        "Boise, ID"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("steadycrawl/redfin-deal-finder").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "searches": [
        "Austin, TX",
        "Boise, ID",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("steadycrawl/redfin-deal-finder").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searches": [
    "Austin, TX",
    "Boise, ID"
  ]
}' |
apify call steadycrawl/redfin-deal-finder --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,steadycrawl/redfin-deal-finder"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/d57benqMUZa0jldqy/builds/Z4b7ZodTwjNU5h8FU/openapi.json
