# Rental Listing Normalizer (`piquant_zirconia_bnw/rental-listing-normalizer`) Actor

Turns public rental listing pages (Craigslist and Zillow search results) into clean, comparable facts like price, beds, baths, size, location and amenities, with source links and no contact details.

- **URL**: https://apify.com/piquant_zirconia_bnw/rental-listing-normalizer.md
- **Developed by:** [Show Before](https://apify.com/piquant_zirconia_bnw) (community)
- **Categories:** Real estate, AI
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Rental Listing Normalizer + Scam Signals

Turn public rental listing pages into **clean, comparable facts**, plus **rental-scam red flags** and **perceptual photo hashes** for duplicate detection. Built at Cal Hacks 13.0 for [Showbefore](https://showbefore.taojiekai.workers.dev), a housing tool covering the cities defined in `src/markets.js`.

The output retains up to five remote photo URLs for listing cards. It never contains image bytes, listing descriptions, street addresses, names, phone numbers or emails. You get facts you can compare, plus a link back to the original.

### What it does

| Input URL | Output |
|---|---|
| Craigslist search page (`…craigslist.org/search/apa`, `/roo`, `/sub`) | One item per result: monthly price, beds, baths, approx. location, kind (apartment / room / sublet), price basis |
| Craigslist real-estate search (`…/search/rea`) | Explicit `intent: "sale"` and `askingPrice`; never a monthly rent |
| Craigslist posting page | One detailed item: the facts above, plus laundry / parking / pets, red flags found in the ad text, and dHash of each photo |
| Zillow `homedetails` page | Calls the Store Actor `maxcopell/zillow-detail-scraper` (Actor-to-Actor), then normalizes its row the same way |
| Any page with schema.org JSON-LD / OpenGraph | A best-effort item from the structured data |
| Facebook / Instagram | Refused (`login_walled_site_not_supported`). Login-walled pages are out of scope by design |

#### Normalization you would otherwise write yourself

- **Price basis.** Student complexes often advertise rent *per bedroom* ("4 bd $549"). Rows like that get `priceBasis: "per_bed"` so they don't drag down whole-unit comps. Room shares get `room`; weekly or nightly prices get `unknown`.
- **Kinds.** Craigslist `apa`, `roo` and `sub` map to `apartment`, `room` and `sublet`. Zillow home types map to `house` or `apartment`.
- **Market.** Market ids come from the shared `src/markets.js` registry (eleven cities). ZIP, then city/state, then nearest bounded center for coordinate-only rows; unmatched rows remain `other`.
- **Location privacy.** Coordinates are rounded to 3 decimals (about 100 m). Zillow `homedetails` URLs are rewritten to the address-free `…/homedetails/<zpid>_zpid/` form.
- **Address key.** `addressKey` is a SHA-256 of the normalized street, unit and ZIP, so you can join the same unit across sites without storing the address.
- **Photos.** `photoUrls` keeps up to five HTTPS URLs from known listing image hosts. Search rows with no images fetch their posting page by default (`capturePhotos: false` disables this). Failed photo pages retain the original search facts with no photos; sale intent/prices are preserved. Craigslist sizes prefer 600×450; Zillow supports both snake_case and camelCase responsive-photo fields. These page requests use the existing Actor compute/proxy budget.

#### Scam signals

- `redFlags`: patterns from FTC rental-scam guidance, matched in the ad text. The text itself is not output; each match comes with a short excerpt.
  - `payment_wire_or_giftcard`: wire transfer, gift cards, crypto, or a cashier's check
  - `owner_abroad`: the owner says they are away or out of the country
  - `deposit_before_viewing`: money before a viewing, or keys sent by mail
  - `off_platform_contact`: pushes you to text, email or a messaging app
  - `urgency_pressure`: pressure to act fast
- `photoHashes`: a 64-bit dHash per photo. Each photo is downloaded into memory, hashed, and discarded. Compare hashes by Hamming distance (≤ 6 is a near-certain copy) to catch photos copied from another listing. Photos are hashed 4 at a time under one 40 s budget per listing; if it runs out, the listing is still returned with the hashes that finished and `hashing: "incomplete"`.

### Input

```json
{
  "startUrls": [
    { "url": "https://gainesville.craigslist.org/search/apa" },
    { "url": "https://sfbay.craigslist.org/search/sfc/roo" },
    { "url": "https://www.craigslist.org/view/d/<slug>/<id>" }
  ],
  "hashPhotos": true,
  "capturePhotos": true,
  "maxPhotosPerListing": 12,
  "maxItemsPerUrl": 500,
  "zillowViaActor": true,
  "proxyConfiguration": { "useApifyProxy": true }
}
```

### Output (one dataset item)

```json
{
  "mode": "listing",
  "sourceSite": "craigslist.org",
  "sourceId": "craigslist:<id>",
  "sourceUrl": "https://www.craigslist.org/view/d/<slug>/<id>",
  "market": "gainesville", "city": "Gainesville", "state": "FL", "zip": "32608",
  "lat": 29.613, "lng": -82.387,
  "kind": "apartment", "beds": 1, "baths": 1, "sqft": null,
  "priceMonthly": 1285, "priceBasis": "unit",
  "amenities": { "laundry": "in_unit", "parking": "assigned", "pets": "allowed", "furnished": null },
  "redFlags": [],
  "photoHashes": ["c8d4b2a29292b2f0", "…"],
  "photoUrls": [],
  "addressKey": null,
  "flags": [],
  "retrievedAt": "2026-10-24T23:02:12.987Z"
}
```

Rows that fail come back as `{ "sourceUrl", "error", "detail" }`, so you can see which URLs failed and why.

### How Showbefore uses it

- **Live comps.** The Worker starts this Actor on Craigslist `apa`, `roo` and `sub` searches for Gainesville and SF. It upserts the results into its `market_comps` table, with this run's ID as the provenance of every row.
- **"Is this listing legit?"** A renter pastes a listing URL. The Worker calls `run-sync-get-dataset-items` with a $0.10 spend cap. It compares the price with a distance- and recency-weighted band of real comps, and the photo hashes with hashes of other real listings. It also checks for the same address on another site at a different price, and for the red flags above.

### Pricing

Pay per event: `listing-normalized` is charged once per item output. Zillow calls are billed by `maxcopell/zillow-detail-scraper`.

### Responsible use

- Public, logged-out pages only.
- Use the output as **facts for comparison**, with attribution and a link to the original. Do not republish other people's photos or descriptions.
- Respect each site's terms. Craigslist and Zillow restrict automated collection and reuse. You are responsible for how you use this Actor.

### Local development

```bash
npm install
node --test test/*.test.mjs
APIFY_LOCAL_STORAGE_DIR=./storage CRAWLEE_STORAGE_DIR=./storage node src/main.js   # reads storage/key_value_stores/default/INPUT.json
apify push
```

# Actor input Schema

## `startUrls` (type: `array`):

Public, logged-out pages only. Craigslist search pages (e.g. https://gainesville.craigslist.org/search/apa, /roo, /sub) give one item per result. Craigslist posting pages, Zillow homedetails pages and any page with schema.org JSON-LD give one detailed item.

## `hashPhotos` (type: `boolean`):

For posting pages: download each photo in memory, compute a 64-bit dHash, discard the image. Only the hashes are output (for duplicate / copied-photo scam detection).

## `maxPhotosPerListing` (type: `integer`):

Upper bound on photos downloaded (in memory) and hashed per posting page.

## `maxItemsPerUrl` (type: `integer`):

Upper bound on offers output per search page.

## `zillowViaActor` (type: `boolean`):

Zillow blocks plain HTTP. When on, Zillow homedetails URLs are fetched by calling the public Store Actor maxcopell/zillow-detail-scraper (Actor-to-Actor) and normalized here (billed to your account by that Actor).

## `proxyConfiguration` (type: `object`):

Apify Proxy is recommended for Craigslist.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://gainesville.craigslist.org/search/apa"
    }
  ],
  "hashPhotos": true,
  "maxPhotosPerListing": 12,
  "maxItemsPerUrl": 500,
  "zillowViaActor": true,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `results` (type: `string`):

No description

# 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 = {
    "startUrls": [
        {
            "url": "https://gainesville.craigslist.org/search/apa"
        }
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("piquant_zirconia_bnw/rental-listing-normalizer").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 = {
    "startUrls": [{ "url": "https://gainesville.craigslist.org/search/apa" }],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("piquant_zirconia_bnw/rental-listing-normalizer").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 '{
  "startUrls": [
    {
      "url": "https://gainesville.craigslist.org/search/apa"
    }
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call piquant_zirconia_bnw/rental-listing-normalizer --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,piquant_zirconia_bnw/rental-listing-normalizer"
        }
    }
}
```

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/0ob02bRprAiGg8j99/builds/kPxIU3uFojtaBKccx/openapi.json
