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。
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,不要将其提交到版本控制系统。
搭建项目
创建虚拟环境以隔离项目依赖,然后安装爬虫所需的库。
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 选择器提取各个字段。json 和 csv 都是标准库,无需额外安装。
第 1 步:获取渲染后的 Zillow 页面
首先获取完整的页面。导入 CrawlingAPI 类,用你的 JS token 初始化,然后请求一个 Zillow 搜索 URL。Zillow 异步加载结果,因此传入 ajax_wait 和 page_wait 以等待动态内容在页面被捕获之前加载完成。在解析前检查 Crawlbase 的 pc_status 可以让失败情况显式暴露,而非悄无声息。
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 搜索标记,而非直接请求返回的外壳。这证明在编写任何选择器之前,渲染已经正常工作。
Zillow 需要在可信 IP 背后渲染页面,在一次调用中完成,这正是上面的 ajax_wait 和 page_wait 选项所设置的。Crawling API 接受 JS token,在真实浏览器中运行页面,在服务端轮换住宅 IP,并返回解析就绪的 HTML,因此你无需自行运行无头浏览器集群和代理池。先在免费套餐上对公开搜索页面进行测试。
第 2 步:从搜索页面收集房产链接
Zillow 的搜索页面是一个房产卡片网格,每张卡片都链接到完整的 listing。将渲染后的 HTML 加载到 BeautifulSoup 并从每张卡片的链接中提取 href。Zillow 将这些链接嵌套在其结果网格中,因此选择器从网格容器向下走到房产卡片链接。
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 列表:
[ "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 路径段进行分页,因此你先获取第一页以读取总页数,然后逐页收集链接。围绕获取函数的小型重试封装可以防止单个慢页面中断整个运行。
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,放弃后返回 None。collect_all_urls 从分页导航中读取最高页码(Zillow 将其放在列表靠近末尾的位置,因此用 nth-last-child(3)),将抓取限制在你的 max_pages 上限内以防止大市场无限延伸,并从每个页面收集链接。页面之间的 time.sleep(2) 控制运行节奏,避免冲击站点。
第 4 步:解析每个房产页面
有了完整的房产 URL 列表后,获取每个 listing 并提取字段。Zillow 将主要详情汇总在其 macro-data-view 区块中,因此以下选择器将价格、卧室数、浴室数、面积、地址和类型映射到各自的元素。每次查找都有保护,缺失字段返回 None 而非使运行崩溃。
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。
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 运行完整脚本,你将获得每套房产的整洁结构化记录,可用于分析、数据库或电子表格。
[ { "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 交付结果,适合在不长时间保持连接的情况下运行多个搜索页面。更完整的方法论,请参阅如何在不被封锁的情况下抓取网站。如果你想跨平台对比市场数据,相同的方法同样适用于抓取 Redfin、Realtor.com 和 Trulia。
抓取 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_wait和page_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 次请求免费,无需信用卡。
