Zillow 是网络上访问量最高的房产平台之一,其 listing 页面包含驱动价格追踪、市场研究和投资分析所需的结构化数据:要价、卧室数、浴室数、建筑面积、房产类型和街道地址。对于研究本地市场的人来说,这些公开 listing 数据是原始素材,手动逐一查看数十套房产既耗时又容易出错。

本指南将演示如何以可靠的方式使用 Python 抓取 Zillow 数据。你将构建一个小型可运行的爬虫,通过 Crawling API 获取渲染后的 Zillow 页面,从搜索页面收集房产链接,用 BeautifulSoup 解析所需字段,处理分页,并导出为干净的 JSON 和 CSV。整个流程限定在公开的 listing 数据范围内,文末的合法性部分不是套话,请在将其应用于真实抓取量之前务必阅读。

你将构建的内容

一个 Python 脚本,接受某个地点的公开 Zillow 搜索 URL,收集房产页面链接,通过 Crawling API 获取每个渲染后的 listing,并为每套房产提取结构化记录。运行示例为华盛顿特区哥伦比亚高地(Columbia Heights)的待售房产。我们提取以下字段:

  • Price 该房产的要价。
  • Beds 卧室数量。
  • Baths 浴室数量。
  • Size 住宅的室内建筑面积。
  • Address listing 上显示的街道地址。
  • Type 房产类型,例如公寓、联排别墅或独户住宅。
  • Link 房产页面的规范 URL。

为什么直接请求在 Zillow 上会失败

如果用裸 HTTP 客户端请求 Zillow 的搜索或 listing URL,你会得到状态 200 的响应,但正文中只有部分数据。有两个因素对你不利。第一,Zillow 通过 JavaScript 和 Ajax 在浏览器中加载大部分搜索结果和 listing 详情,因此初始 HTML 是一个薄外壳,只有在页面脚本运行后才会填充。从第一个响应中提取房产链接,你只能得到几张卡片而非完整的结果集。第二,Zillow 很快就会标记自动化流量:不像真实浏览器的数据中心 IP 和请求模式,会在抵达渲染内容之前就被限速、封锁 IP 或受到验证挑战。

因此,一个可用的 Zillow 爬虫需要在一次请求中满足两点:一个真正渲染页面的浏览器,以及一个被平台识别为真实访客的 IP。你可以自行用无头浏览器配合轮换住宅代理池来实现,但将这两者整合在一起并保持正常运行本身就是主要的工作量。Crawling API 将两者合并为一次调用:你用 JavaScript token 发送 URL,它在可信 IP 背后渲染页面,并返回供你解析的完整 HTML。

为何使用 JS token

Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML;JavaScript(JS)token 则先在真实浏览器中渲染页面。Zillow 在客户端填充其搜索结果和 listing 字段,因此这里需要使用 JS token。普通 token 返回的薄外壳与直接请求的结果相同,其中几乎没有可解析的有用内容。

前提条件

在编写任何代码之前,需要准备好以下几项。每项都不会花太长时间。

Python 基础。 你应该能够编写和运行 Python 脚本,并使用 pip 安装包。如果你对解析方面还不熟悉,BeautifulSoup 指南是本教程很好的配套参考。

Python 3.8 或更高版本。 使用 python --version 确认你的版本。如果尚未安装,请从 python.org 或 Anaconda 等发行版进行安装,并确保 Python 在你的 PATH 中。

Crawlbase 账号和 JS token。 注册后打开控制台,从账号文档页面复制你的 JavaScript(JS)token。Crawlbase 提供 1,000 次免费请求,足够完成本指南的练习。请像对待密码一样保护你的 token,不要将其提交到版本控制系统。

搭建项目

创建虚拟环境以隔离项目依赖,然后安装爬虫所需的库。

bash
python --version

python -m venv zillow_env
source zillow_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,使用 zillow_env\Scripts\activate 替代 source 那行来激活环境。两个依赖各司其职:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML 以便通过 CSS 选择器提取各个字段。jsoncsv 都是标准库,无需额外安装。

第 1 步:获取渲染后的 Zillow 页面

首先获取完整的页面。导入 CrawlingAPI 类,用你的 JS token 初始化,然后请求一个 Zillow 搜索 URL。Zillow 异步加载结果,因此传入 ajax_waitpage_wait 以等待动态内容在页面被捕获之前加载完成。在解析前检查 Crawlbase 的 pc_status 可以让失败情况显式暴露,而非悄无声息。

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})

OPTIONS = {
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/122.0",
    "ajax_wait": "true",
    "page_wait": 5000,
}

def crawl(page_url):
    response = api.get(page_url, OPTIONS)
    if response["headers"]["pc_status"] == "200":
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['headers']['pc_status']}")
    return None

if __name__ == "__main__":
    serp_url = "https://www.zillow.com/columbia-heights-washington-dc/sale/"
    html = crawl(serp_url)
    print(html[:500] if html else "No HTML returned")

两个等待选项对于像 Zillow 这样的客户端渲染目标至关重要。ajax_wait 告知 API 等待异步内容加载完成,page_wait 在加载后固定等待一段毫秒数,确保延迟渲染的卡片在页面被捕获前出现。五秒是合理的起点;如果结果返回偏少,可以适当增加。使用 python zillow_scraper.py 运行脚本,你应该看到真实的 Zillow 搜索标记,而非直接请求返回的外壳。这证明在编写任何选择器之前,渲染已经正常工作。

Crawlbase Zillow Scraper

Zillow 需要在可信 IP 背后渲染页面,在一次调用中完成,这正是上面的 ajax_waitpage_wait 选项所设置的。Crawling API 接受 JS token,在真实浏览器中运行页面,在服务端轮换住宅 IP,并返回解析就绪的 HTML,因此你无需自行运行无头浏览器集群和代理池。先在免费套餐上对公开搜索页面进行测试。

第 2 步:从搜索页面收集房产链接

Zillow 的搜索页面是一个房产卡片网格,每张卡片都链接到完整的 listing。将渲染后的 HTML 加载到 BeautifulSoup 并从每张卡片的链接中提取 href。Zillow 将这些链接嵌套在其结果网格中,因此选择器从网格容器向下走到房产卡片链接。

python
from bs4 import BeautifulSoup

CARD_SELECTOR = (
    'div[id="grid-search-results"] > ul > li[class^="ListItem-"] '
    'article[data-test="property-card"] a[data-test="property-card-link"]'
)

def get_property_urls(html):
    soup = BeautifulSoup(html, "html.parser")
    return [a["href"] for a in soup.select(CARD_SELECTOR) if a.get("href")]

class^="ListItem-" 是一个前缀选择器:Zillow 在其生成的类名后附加哈希值,所以 ListItem- 能匹配所有列表项,不受后缀影响。对渲染后的搜索 HTML 运行此函数,将返回一个干净的房产页面 URL 列表:

json
[
  "https://www.zillow.com/homedetails/1429-Girard-St-NW-101-Washington-DC-20009/2053968963_zpid/",
  "https://www.zillow.com/homedetails/1439-Euclid-St-NW-APT-301-Washington-DC-20009/68081615_zpid/",
  "https://www.zillow.com/homedetails/1362-Newton-St-NW-Washington-DC-20010/472850_zpid/",
  "https://www.zillow.com/homedetails/1458-Columbia-Rd-NW-APT-300-Washington-DC-20009/82293130_zpid/"
]
选择器会漂移

Zillow 的生成类名和 data-test 属性随时可能在不通知的情况下发生变化。将这里的选择器视为起始模板,而非合同。当返回列表为空时,请在浏览器开发者工具中重新检查实时页面并更新选择器。定期维护选择器对任何生产级爬虫来说都是正常的,并不意味着出了问题。

第 3 步:处理搜索页面的分页

一个搜索页面只是结果集的一部分。Zillow 用 {pageNo}_p 路径段进行分页,因此你先获取第一页以读取总页数,然后逐页收集链接。围绕获取函数的小型重试封装可以防止单个慢页面中断整个运行。

python
import time

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        html = crawl(page_url)
        if html:
            return html
        if attempt < max_retries:
            print(f"Retrying ({attempt + 1}/{max_retries})...")
            time.sleep(1)
    print(f"Unable to fetch {page_url}")
    return None

def collect_all_urls(base_url, max_pages):
    first_html = fetch_html(f"{base_url}1_p/")
    if not first_html:
        return []

    soup = BeautifulSoup(first_html, "html.parser")
    last = soup.select_one("div.search-pagination > nav > li:nth-last-child(3)")
    total_pages = int(last.text) if last else 1
    pages = min(total_pages, max_pages)

    all_urls = get_property_urls(first_html)
    for page in range(2, pages + 1):
        html = fetch_html(f"{base_url}{page}_p/")
        if html:
            all_urls.extend(get_property_urls(html))
        time.sleep(2)
    return all_urls

fetch_html 在失败时最多重试两次并短暂暂停,成功时返回 HTML,放弃后返回 Nonecollect_all_urls 从分页导航中读取最高页码(Zillow 将其放在列表靠近末尾的位置,因此用 nth-last-child(3)),将抓取限制在你的 max_pages 上限内以防止大市场无限延伸,并从每个页面收集链接。页面之间的 time.sleep(2) 控制运行节奏,避免冲击站点。

第 4 步:解析每个房产页面

有了完整的房产 URL 列表后,获取每个 listing 并提取字段。Zillow 将主要详情汇总在其 macro-data-view 区块中,因此以下选择器将价格、卧室数、浴室数、面积、地址和类型映射到各自的元素。每次查找都有保护,缺失字段返回 None 而非使运行崩溃。

python
VIEW = 'div[data-testid="macro-data-view"]'
FACTS = (
    f'{VIEW} > div[data-renderstrat="inline"]:nth-child(2) '
    'div[data-testid="bed-bath-sqft-facts"]'
)

def text_of(soup, selector):
    el = soup.select_one(selector)
    return el.get_text(strip=True) if el else None

def scrape_property(html, url):
    soup = BeautifulSoup(html, "html.parser")
    return {
        "link": url,
        "price": text_of(soup, f'{VIEW} span[data-testid="price"] > span'),
        "address": text_of(soup, f'{VIEW} div[class^="styles__AddressWrapper-"] > h1'),
        "beds": text_of(soup, f'{FACTS} > div[data-testid="bed-bath-sqft-fact-container"]:first-child > span:first-child'),
        "baths": text_of(soup, f'{FACTS} > button > div[data-testid="bed-bath-sqft-fact-container"] > span:first-child'),
        "size": text_of(soup, f'{FACTS} > div[data-testid="bed-bath-sqft-fact-container"]:last-child > span:first-child'),
        "type": text_of(soup, f'{VIEW} > div[data-renderstrat="inline"]:nth-child(3) div.dBmBNo:first-child > span'),
    }

text_of 辅助函数查询一个元素并返回其去除空白后的文本,当元素不存在时返回 None,因此缺少某个字段的 listing 不会中断循环。选择器直接来自 Zillow 的 listing 布局:price 读取主价格 span,address 读取地址包装器内的 H1,卧室数、浴室数和面积都位于共享的 bed-bath-sqft-facts 容器中,通过位置区分。浴室数在 Zillow 的标记中位于 button 内,这也是该选择器与其他两个略有不同的原因。

第 5 步:组装完整脚本

现在将所有部分整合到一个可运行的脚本中:跨页面收集 URL,抓取每套房产,并将记录导出为 JSON 和 CSV。

python
import csv
import json
import time
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})

OPTIONS = {
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/122.0",
    "ajax_wait": "true",
    "page_wait": 5000,
}

CARD_SELECTOR = (
    'div[id="grid-search-results"] > ul > li[class^="ListItem-"] '
    'article[data-test="property-card"] a[data-test="property-card-link"]'
)
VIEW = 'div[data-testid="macro-data-view"]'
FACTS = (
    f'{VIEW} > div[data-renderstrat="inline"]:nth-child(2) '
    'div[data-testid="bed-bath-sqft-facts"]'
)

def crawl(page_url):
    response = api.get(page_url, OPTIONS)
    if response["headers"]["pc_status"] == "200":
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['headers']['pc_status']}")
    return None

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        html = crawl(page_url)
        if html:
            return html
        if attempt < max_retries:
            time.sleep(1)
    return None

def text_of(soup, selector):
    el = soup.select_one(selector)
    return el.get_text(strip=True) if el else None

def get_property_urls(html):
    soup = BeautifulSoup(html, "html.parser")
    return [a["href"] for a in soup.select(CARD_SELECTOR) if a.get("href")]

def collect_all_urls(base_url, max_pages):
    first_html = fetch_html(f"{base_url}1_p/")
    if not first_html:
        return []
    soup = BeautifulSoup(first_html, "html.parser")
    last = soup.select_one("div.search-pagination > nav > li:nth-last-child(3)")
    total_pages = int(last.text) if last else 1
    pages = min(total_pages, max_pages)
    all_urls = get_property_urls(first_html)
    for page in range(2, pages + 1):
        html = fetch_html(f"{base_url}{page}_p/")
        if html:
            all_urls.extend(get_property_urls(html))
        time.sleep(2)
    return all_urls

def scrape_property(html, url):
    soup = BeautifulSoup(html, "html.parser")
    return {
        "link": url,
        "price": text_of(soup, f'{VIEW} span[data-testid="price"] > span'),
        "address": text_of(soup, f'{VIEW} div[class^="styles__AddressWrapper-"] > h1'),
        "beds": text_of(soup, f'{FACTS} > div[data-testid="bed-bath-sqft-fact-container"]:first-child > span:first-child'),
        "baths": text_of(soup, f'{FACTS} > button > div[data-testid="bed-bath-sqft-fact-container"] > span:first-child'),
        "size": text_of(soup, f'{FACTS} > div[data-testid="bed-bath-sqft-fact-container"]:last-child > span:first-child'),
        "type": text_of(soup, f'{VIEW} > div[data-renderstrat="inline"]:nth-child(3) div.dBmBNo:first-child > span'),
    }

def save_outputs(records):
    with open("zillow_properties.json", "w") as f:
        json.dump(records, f, indent=2)
    if not records:
        return
    with open("zillow_properties.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=records[0].keys())
        writer.writeheader()
        writer.writerows(records)

def main():
    serp_url = "https://www.zillow.com/columbia-heights-washington-dc/sale/"
    urls = collect_all_urls(serp_url, max_pages=2)

    records = []
    for url in urls:
        html = fetch_html(url)
        if html:
            records.append(scrape_property(html, url))
        time.sleep(2)

    save_outputs(records)
    print(f"Saved {len(records)} properties")

if __name__ == "__main__":
    main()

该脚本从最多两个搜索页面收集房产链接,用重试封装获取每个 listing,将其解析为一条记录,并用两秒休眠控制循环节奏。save_outputs 将 JSON 文件和 CSV 都写出,CSV 以第一条记录的键作为列头,因此你拥有下游工具所需的任何格式的数据。调整 max_pages 和搜索 URL 以适应你的目标地点。

输出示例

使用 python zillow_scraper.py 运行完整脚本,你将获得每套房产的整洁结构化记录,可用于分析、数据库或电子表格。

json
[
  {
    "link": "https://www.zillow.com/homedetails/1008-Fairmont-St-NW-Washington-DC-20001/473889_zpid/",
    "price": "$850,000",
    "address": "1008 Fairmont St NW, Washington, DC 20001",
    "beds": "3",
    "baths": "4",
    "size": "1,801",
    "type": "Townhouse"
  },
  {
    "link": "https://www.zillow.com/homedetails/1438-Meridian-Pl-NW-APT-106-Washington-DC-20010/467942_zpid/",
    "price": "$385,000",
    "address": "1438 Meridian Pl NW APT 106, Washington, DC 20010",
    "beds": "2",
    "baths": "2",
    "size": "634",
    "type": "Condominium"
  }
]

对应的 CSV 包含相同的列,每套房产一行,可直接导入 pandas 或任何电子表格,按价格区间、卧室数或房产类型进行筛选。

大规模抓取时保持不被封锁

即使有渲染处理好了,Zillow 仍然会监视爬虫形态的流量。以下几个习惯有助于较长时间的抓取任务保持顺利运行,这些习惯适用于任何高度防御的商业目标。

  • 控制请求节奏。 在紧密循环中不断请求 listing 是被限速或被验证挑战最快的方式。上面的两秒休眠是底线,而非上限;对于更大的任务,适当加宽,并多样化目标而非以全速抓取单一路径。
  • 善用轮换。 住宅 IP 池能将请求分散到许多真实用户地址,使单个 IP 不触发限速。Crawling API 为你处理这个问题;如果你自建技术栈,这是最需要做好的部分。
  • 读懂状态码。 如果某次运行开始返回非 200 的 pc_status 值,这是在告诉你当前的请求频率或 IP 等级已不够用。把它当作退后重试的信号,而非可以忽略的噪音。

对于较大规模的抓取,异步 Crawler 将请求排队并通过 webhook 交付结果,适合在不长时间保持连接的情况下运行多个搜索页面。更完整的方法论,请参阅如何在不被封锁的情况下抓取网站。如果你想跨平台对比市场数据,相同的方法同样适用于抓取 RedfinRealtor.comTrulia

抓取 Zillow 是否合法?

抓取 Zillow 是否被允许,取决于 Zillow 的服务条款、你所在的司法管辖区以及你对数据的用途。Zillow 的条款限制了自动化访问和数据采集,因此无论你的工具多么谨慎,抓取都可能违反这些条款。Zillow 历史上也曾就抓取问题提起诉讼,追究了大规模采集其 listing 的当事方,因此这不是一个假设性的风险。这里的任何代码都无法改变这一点;它只是使技术层面得以实现。请阅读 Zillow 的使用条款及其 robots.txt,并将两者视为你所采集内容的边界。

以下是几条值得遵守的原则。只采集公开的 listing 数据:无需账号即可看到的要价、卧室数、浴室数、建筑面积、房产类型和街道地址。避免任何与可识别个人相关的内容,包括页面上出现的经纪人、房东或其他人的联系方式,这些属于个人数据,超出了公开 listing 的范围。尊重 Zillow 声明的速率预期,保持足够低的请求量,不要给其服务器造成压力。还需注意,房产平台上的大量底层房产和销售数据来源于 MLS 数据馈送,通常是经过授权的,并带有自己的再分发限制,因此采集数据并不授予你重新发布的权利。

本指南有意将范围限定为公开 listing 页面,因为这是使工作具备可辩护性的边界。它不涉及任何需要登录的内容、已保存的搜索或账号数据、个人或联系方式,以及任何绕过身份验证的尝试。仅限公开 listing 数据。如果你的项目需要的不止于此,正确的路径是授权安排:Zillow 为许可的使用场景提供官方 API 和合作伙伴计划,授权的 MLS 或房产数据提供商则涵盖其余情况。对于商业或批量用途,那才是正确的途径,而非更高明的爬虫。

回顾

核心要点

  • Zillow 是客户端渲染的。 直接请求只返回带有部分结果的薄外壳,因此必须在解析之前渲染页面。
  • 渲染和可信 IP 需要同时具备。 带 JS token 的 Crawling API 在一次调用中同时满足;ajax_waitpage_wait 控制等待内容的时长。
  • 分两层工作。 使用 property-card-link 选择器从每个搜索页面收集房产链接,然后获取并解析每个 listing 以提取价格、卧室数、浴室数、面积、地址和类型。
  • 分页并导出。 按上限遍历 Zillow 的 {pageNo}_p 页面,用短暂休眠控制运行节奏,并将记录写入 JSON 和 CSV。
  • 坚守公开数据。 遵守 Zillow 的服务条款和 robots.txt,注意其曾就抓取提起诉讼且 MLS 数据通常是经过授权的,切勿触碰登录、账号或个人信息。

常见问题

为什么直接请求只返回部分 Zillow 结果?

因为 Zillow 使用 JavaScript 和 Ajax 在客户端加载其搜索结果和 listing 详情。初始 HTML 是一个外壳,只有在浏览器执行页面脚本后才会填充,因此裸 HTTP 请求返回状态 200,但大部分卡片和 listing 字段都缺失。要获取完整的结果集,必须先渲染页面,这正是 Crawling API 的 JS token 为你处理的事情。

Zillow 需要普通 token 还是 JS token?

JS token。普通 token 获取静态 HTML,在 Zillow 上与直接请求返回的薄外壳相同。JS token 在将 HTML 返回之前先在真实浏览器中渲染页面,因此 BeautifulSoup 解析时搜索卡片和 listing 字段都已存在。

我可以从 Zillow listing 中抓取哪些数据?

公开的 listing 字段:要价、卧室数和浴室数、建筑面积、房产类型、街道地址和 listing 链接。坚守任何访客无需账号即可看到的数据,避免经纪人或房东的个人联系方式,这些超出了本指南所涵盖的公开 listing 范围。

我的选择器返回 None。是什么改变了?

几乎可以肯定是 Zillow 的标记。其生成的类名和 data-test 属性(ListItem- 前缀、macro-data-view 区块、bed-bath-sqft-facts 容器)随时可能在不通知的情况下发生变化,因此上个月还能用的选择器现在可能已经失效。请在浏览器开发者工具中重新检查实时页面并更新选择器。定期维护选择器对任何生产级爬虫来说都是正常的。

如何处理某个地点的 listing 的分页?

Zillow 在搜索路径中附加一个 {pageNo}_p 段。先获取第一页以从分页导航中读取总页数,将抓取限制在 max_pages 上限内,然后逐页收集房产链接。上面的 collect_all_urls 函数展示了完整的循环,页面之间有短暂的休眠。

我可以将抓取的 Zillow 数据用于商业用途吗?

将其视为法律问题,而非技术问题。Zillow 的大量房产数据来源于有各自再分发条款的授权 MLS 馈送,Zillow 自身的使用条款也限制了重用,因此商业或批量用途通常需要许可。请审阅条款,考虑 Zillow 的官方 API 或合作伙伴计划,并在基于这些数据构建产品之前寻求法律建议。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量