Homes.com 收录了全美的房产数据,其搜索页和房源页恰好携带了驱动价格追踪、市场调研和投资分析所需的结构化字段:房源标题、街道地址、挂牌价格、卧室数、卫生间数、建筑面积,以及指向每套房产的链接。把这些拉进电子表格,你就能对比不同社区、观察价格随时间的变化,并发现值得进一步关注的房源,而无需手动逐页点击上百个页面。
本指南将向你展示如何用 Python 以可靠的方式抓取 Homes.com。你会构建一个小巧、可直接运行的抓取脚本,它通过 Crawling API 获取渲染后的搜索页,用 BeautifulSoup 解析你需要的字段,遍历分页,并将结果写入 JSON 和 CSV。整个演示始终限定在公开房源数据范围内,而结尾附近的合法性章节并非套话,所以在把它指向任何真实规模的抓取之前,请先阅读那一节。
你将构建什么
一个 Python 脚本:它接收一个公开的 Homes.com 搜索 URL,通过 Crawling API 取回渲染后的 HTML,遍历多个结果页,并为每条房源提取一条结构化记录。我们将以单个城市的搜索作为贯穿全文的示例,抓取以下字段:
- 标题 房源类型,例如 "House for Rent"(出租房屋)或 "Condo for Rent"(出租公寓)。
- 地址 房产的街道地址。
- 价格 卡片上显示的挂牌价或月租金。
- 卧室数 卧室的数量。
- 卫生间数 卫生间的数量。
- 面积 房源标注的建筑面积(若有)。
- 链接 指向完整房产页面的绝对 URL。
为什么普通请求在 Homes.com 上会失败
如果你用一个简单的 HTTP 客户端请求 Homes.com 搜索 URL,你会得到一个状态为 200 的响应,但正文中几乎没有任何房源数据。有两点对你不利。首先,Homes.com 的大部分内容是用 JavaScript 在浏览器中渲染的,所以最初的 HTML 只是一个空壳,要等页面脚本运行后才会填充进去。其次,该站点会迅速标记自动化流量:数据中心 IP 以及看起来不像真实浏览器的请求模式,在到达渲染后的内容之前就会被限速、被发起验证质询,或被送上 captcha。
所以一个能正常工作的 Homes.com 抓取器在一次请求中需要两样东西:一个真正能渲染页面的浏览器,以及一个被平台读作真实访客的 IP。你可以自己用无头浏览器加上一池轮换住宅代理把它拼起来,但把它们缝合在一起并保持其健康运行才是大部分工作量所在。Crawling API 把两者折叠进单次调用:你把带 JavaScript token 的 URL 发给它,它在一个可信 IP 背后渲染页面,并把处理完的 HTML 返回给你解析。关于为什么动态站点需要这样做的更多内容,请参阅如何抓取 JavaScript 网站。
Crawlbase 提供两种 token。普通 token 获取静态 HTML;JavaScript(JS)token 会先在真实浏览器中渲染页面。Homes.com 在客户端填充其房源字段,所以这里你需要 JS token。使用普通 token 返回的是和普通抓取一样的空壳,里面没有任何有用的东西可供解析。
前置条件
在写任何代码之前,你需要准备好几样东西。它们都花不了多少时间。
Python 基础。你应当能够自如地编写并运行 Python 脚本,以及用 pip 安装软件包。如果你刚接触这门语言,用 Python 抓取网站这篇演示涵盖了本教程默认你已掌握的基础知识。
Python 3.8 或更高版本。用 python --version 确认你的版本。如果你还没有,请从 python.org 安装,或通过 Anaconda 这类发行版安装。
一个 Crawlbase 账户和 JS token。注册、打开你的仪表盘,并从账户文档页复制你的 JavaScript(JS)token。前 1,000 次请求免费,且无需信用卡。把这个 token 当成密码对待:它用于认证你的请求,所以不要把它纳入版本控制。
搭建项目
创建一个虚拟环境,让项目依赖保持隔离,然后安装抓取器所需的两个库。
python --version python -m venv homes_scraping_env source homes_scraping_env/bin/activate pip install crawlbase beautifulsoup4
在 Windows 上,用 homes_scraping_env\Scripts\activate 激活环境,而不是那一行 source。两个依赖各司其职:crawlbase 是 Crawling API 的官方客户端,而 beautifulsoup4 解析返回的 HTML,让你能用 CSS 选择器逐个抽取字段。如果你以前没用过这个解析器,BeautifulSoup 指南是本教程的好搭档。
第 1 步:获取渲染后的搜索页
先从拿到处理完的页面开始。导入 CrawlingAPI 类,用你的 JS token 初始化它,并请求搜索 URL。对于客户端渲染的目标,两个等待选项很关键:ajax_wait 告诉 API 等待异步内容加载完成,而 page_wait 在加载后固定等待若干毫秒,让那些延迟渲染的元素在页面被捕获前出现。在解析之前检查状态,能让失败显式暴露而不是悄无声息。
from crawlbase import CrawlingAPI crawling_api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) options = { "ajax_wait": "true", "page_wait": 10000, "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", } def make_crawlbase_request(url): response = crawling_api.get(url, options) if response["headers"]["pc_status"] == "200": return response["body"].decode("utf-8") print(f"Failed to fetch the page. Crawlbase status: {response['headers']['pc_status']}") return None if __name__ == "__main__": url = "https://www.homes.com/los-angeles-ca/homes-for-rent/p1/" html = make_crawlbase_request(url) print(html[:500] if html else "No HTML returned")
该函数读取 response["headers"]["pc_status"],即 Crawling API 随正文一起返回的每次请求状态,只有当它读到 "200" 时才交回 HTML。对于 Homes.com,10 秒的 page_wait 是个合理的起点;如果房源字段返回为空就把它调高。用 python homes_scraper.py 运行脚本,你应当看到真实的搜索页标记,而不是普通抓取返回的空壳。这能在你写下任何一个选择器之前确认渲染是有效的。
那一次 make_crawlbase_request 调用替你完成了最难的部分。Homes.com 需要一个可信 IP 背后的渲染页面,而 Crawling API 接收你的 JS token,在真实浏览器中运行页面,在服务端轮换住宅 IP,并交回处理完的 HTML,于是你就省去了自己运行无头浏览器集群和代理池的麻烦。先在免费档位上把它指向一个公开的搜索页吧。
第 2 步:用 BeautifulSoup 解析房源卡片
拿到渲染后的 HTML 后,把它加载进 BeautifulSoup 并抽取每张卡片。在浏览器的开发者工具里检视一个 Homes.com 搜索页,你会发现每条房源都被包裹在一个 class 为 for-rent-content-container 的 div 中。把它们全部选出来,然后从每一个里读取各个字段。标题位于 p.property-name 中,地址位于 p.address 中,而价格、卧室数和卫生间数则来自 ul.detailed-info-container 内的 li 项,且按此顺序排列。
from bs4 import BeautifulSoup BASE_URL = "https://www.homes.com" def parse_listings(html): soup = BeautifulSoup(html, "html.parser") cards = soup.select("div.for-rent-content-container") properties = [] for card in cards: title_elem = card.select_one("p.property-name") address_elem = card.select_one("p.address") info_container = card.select_one("ul.detailed-info-container") info = info_container.find_all("li") if info_container else [] link_elem = card.select_one("a") properties.append({ "title": title_elem.text.strip() if title_elem else "N/A", "address": address_elem.text.strip() if address_elem else "N/A", "price": info[0].text.strip() if len(info) > 0 else "N/A", "beds": info[1].text.strip() if len(info) > 1 else "N/A", "baths": info[2].text.strip() if len(info) > 2 else "N/A", "size": info[3].text.strip() if len(info) > 3 else "N/A", "link": BASE_URL + link_elem["href"] if link_elem and link_elem.get("href") else "N/A", }) return properties
当某个元素缺失时,每个守卫都会返回 "N/A" 而不是抛出异常,所以单个缺失字段不会让整次运行崩溃。详情行是按位置排列的:Homes.com 把价格、卧室数、卫生间数和面积作为有序的 li 项布局,所以代码按索引读取它们,并先做长度边界检查。链接以相对路径的形式取自卡片的锚点,所以前缀上 BASE_URL 就能给你一个绝对 URL,可直接跟进到房产页面。
Homes.com 的类名(for-rent-content-container 卡片,property-name 和 address 字段,以及 detailed-info-container 行)会在没有任何通知的情况下变化。把上面的选择器当成一个起始模板,而不是一份契约。当某个字段返回为 "N/A" 时,在浏览器开发者工具里重新检视实时页面并更新选择器。对任何生产级抓取器来说,定期维护选择器都是常态,并不代表哪里坏了。
第 3 步:遍历分页
一个页面只是演示;真正的任务会跑遍整个结果集。Homes.com 在搜索路径后追加一个页码片段,所以某个城市的房源位于 .../homes-for-rent/p1/、.../p2/ 等处。在固定数量的页面上循环,通过同一个请求函数获取每一页,解析它的卡片,并把一切汇集到一个列表里。页面之间稍作停顿能避免让运行把站点压垮。
import time SEARCH_URL = "https://www.homes.com/los-angeles-ca/homes-for-rent" MAX_PAGES = 3 def scrape_search(): properties = [] for page in range(1, MAX_PAGES + 1): url = f"{SEARCH_URL}/p{page}/" print(f"Scraping page {page}: {url}") html = make_crawlbase_request(url) if html: properties.extend(parse_listings(html)) time.sleep(2) return properties
页面之间的 time.sleep(2) 是有意为之:它为运行设置节奏,让你不至于把站点压垮,而这是保持不被封禁最有效的单一习惯。调整 MAX_PAGES 以及 SEARCH_URL 中的城市 slug 来匹配你的目标。要改为抓取芝加哥的出租房,换成 chicago-il;要抓取待售房屋,把 homes-for-rent 改成待售路径。
第 4 步:导出为 JSON 和 CSV
手里有了一份记录列表后,按你下游工作所需的任何形态把它写出来。JSON 让结构保持完整,便于读回它的代码;CSV 则可直接进入电子表格用于排序和制图。两个小助手函数即可覆盖两者。
import json import csv def save_to_json(properties, filename="properties.json"): with open(filename, "w") as f: json.dump(properties, f, indent=4) def save_to_csv(properties, filename="properties.csv"): if not properties: return with open(filename, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=properties[0].keys()) writer.writeheader() writer.writerows(properties)
CSV 写入器从第一条记录的键中读取它的列标题,所以这些列会与你在第 2 步中解析的字段保持一致。如果你在那里新增或重命名了某个字段,两种导出都会自动跟进。
把它们组合起来
这就是完整、可直接运行的抓取器:通过 Crawling API 获取每个搜索页,解析卡片,遍历分页,并把结果写入 JSON 和 CSV。填入你的 token 并运行它。
import json import csv import time from crawlbase import CrawlingAPI from bs4 import BeautifulSoup crawling_api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) BASE_URL = "https://www.homes.com" SEARCH_URL = "https://www.homes.com/los-angeles-ca/homes-for-rent" MAX_PAGES = 3 options = { "ajax_wait": "true", "page_wait": 10000, "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", } def make_crawlbase_request(url): response = crawling_api.get(url, options) if response["headers"]["pc_status"] == "200": return response["body"].decode("utf-8") print(f"Failed to fetch the page. Crawlbase status: {response['headers']['pc_status']}") return None def parse_listings(html): soup = BeautifulSoup(html, "html.parser") cards = soup.select("div.for-rent-content-container") properties = [] for card in cards: title_elem = card.select_one("p.property-name") address_elem = card.select_one("p.address") info_container = card.select_one("ul.detailed-info-container") info = info_container.find_all("li") if info_container else [] link_elem = card.select_one("a") properties.append({ "title": title_elem.text.strip() if title_elem else "N/A", "address": address_elem.text.strip() if address_elem else "N/A", "price": info[0].text.strip() if len(info) > 0 else "N/A", "beds": info[1].text.strip() if len(info) > 1 else "N/A", "baths": info[2].text.strip() if len(info) > 2 else "N/A", "size": info[3].text.strip() if len(info) > 3 else "N/A", "link": BASE_URL + link_elem["href"] if link_elem and link_elem.get("href") else "N/A", }) return properties def scrape_search(): properties = [] for page in range(1, MAX_PAGES + 1): url = f"{SEARCH_URL}/p{page}/" print(f"Scraping page {page}: {url}") html = make_crawlbase_request(url) if html: properties.extend(parse_listings(html)) time.sleep(2) return properties def save_to_json(properties, filename="properties.json"): with open(filename, "w") as f: json.dump(properties, f, indent=4) def save_to_csv(properties, filename="properties.csv"): if not properties: return with open(filename, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=properties[0].keys()) writer.writeheader() writer.writerows(properties) if __name__ == "__main__": listings = scrape_search() save_to_json(listings) save_to_csv(listings) print(f"Saved {len(listings)} listings to properties.json and properties.csv")
输出长什么样
用 python homes_scraper.py 运行完整脚本,你会为每条房源得到一条干净的记录,可直接写入 JSON、CSV 或数据库。
[ { "title": "Condo for Rent", "address": "3824 Keystone Ave Unit 2, Culver City, CA 90232", "price": "$3,300 per month", "beds": "2 Beds", "baths": "1.5 Baths", "size": "1,100 Sq Ft", "link": "https://www.homes.com/los-angeles-ca/homes-for-rent/property/3824-keystone-ave-culver-city-ca-unit-2/2er2mwklw8zq6/" }, { "title": "House for Rent", "address": "3901 Alonzo Ave, Encino, CA 91316", "price": "$17,000 per month", "beds": "4 Beds", "baths": "3.5 Baths", "size": "3,400 Sq Ft", "link": "https://www.homes.com/los-angeles-ca/homes-for-rent/property/3901-alonzo-ave-encino-ca/879negnf45nee/" } ]
同一份数据的 CSV 版本每条房源占一行,带有 title、address、price、beds、baths、size 和 link 列,可在任何电子表格中干净地打开,按价格排序或按社区筛选。
扩展到房产详情页
搜索抓取器给你的是卡片级别的字段。当你想要单套房产的完整详情时,跟进你已经捕获的 link 并解析房产页面,它携带更丰富的字段,比如地块面积、更长的描述,以及房源经纪人的公开联系信息行。房产页面使用它自己的选择器:地址位于 div.property-info-address,价格位于 span#price,卧室数和卫生间数位于 span.feature-beds 和 span.feature-baths,地块面积位于 span.property-info-feature.lotsize。复用 make_crawlbase_request 来获取页面,然后像处理卡片那样映射这些选择器。像搜索循环一样,用一个短暂的 sleep 给每次房产获取设置节奏。
保持不被封禁
即便处理好了渲染,Homes.com 仍会监视具有抓取器特征的流量。几个习惯能让一次运行保持健康,且它们适用于任何难啃的商业目标。
-
给请求设置节奏。在一个紧凑的循环里猛打页面是最快招致限速或被送上 captcha 的方式。把请求分散开,就像上面的
sleep那样,并把目标变换一下,而不是全速爬取同一条路径。 - 依靠轮换。一池住宅 IP 把请求分散到许多真实用户地址上,让没有任何单个 IP 触发速率限制。Crawling API 替你处理这件事;如果你自己搭建技术栈,这就是要做对的部分。
- 读懂状态码。一次开始返回验证质询或错误的运行,是在告诉你当前的速率或 IP 档位已经不够了。把它当成该退一步的信号,而不是可以忽略的噪声。
关于更广的实战手册,请参阅如何在不被封禁的情况下抓取网站。如果你更愿意让自己的流量经由一个轮换池,而不是使用托管 API,那么 Smart AI Proxy(也称 AI Proxy)会以一个即插即用的代理端点形式,给你与之相同的住宅 IP 轮换。同样的方法可延伸到其他房地产站点:参见我们关于抓取 Zillow、抓取 Redfin 和抓取 Realtor.com 的指南。
抓取 Homes.com 合法吗?
抓取 Homes.com 是否被允许,取决于 Homes.com 的服务条款、你所在的司法管辖区,以及你拿数据做什么。其条款限制自动化访问,所以无论你的工具多么谨慎,抓取都可能违反那些条款。这里的代码不会改变这一点;它只是把技术部分跑通而已。请阅读 Homes.com 的服务条款及其 robots.txt,尊重任何明示的速率限制,并把两者都当成你所采集内容的边界。
有几条值得守住的底线。只采集公开的房源数据:标题、地址、价格、卧室数、卫生间数、建筑面积,以及任何人无需账户即可看到的链接。把你的请求量保持得足够低,让你不至于给站点的服务器造成压力。避开任何与可识别个人相关的内容,包括房源经纪人、业主或物业管理者在页面上展示的姓名和联系方式。这些都是个人数据,采集或存储它们可能会牵涉到像 GDPR 和 CCPA 这样的隐私法律,所以除非你有清晰的合法依据和真实的需求,否则把它们排除在外。
还有一点专门针对房地产:很多底层房源数据源自多重房源系统(MLS),而这类数据常常在限制再分发的条款下授权。如果你的项目需要那种深度,或任何批量的商业再利用,正确的路径是获得授权的 MLS 数据源或官方数据协议,而不是更巧妙的抓取器。本指南有意限定在公开房源页面,因为那是让这项工作站得住脚的界线。它不涉及登录之后的任何内容、账户或保存搜索数据、经纪人或业主的个人详情,也不涉及任何绕过认证的尝试。只针对公开房源数据。
核心要点
- Homes.com 是客户端渲染的。普通抓取返回一个空壳,所以你必须先渲染页面再解析它。
-
你需要渲染和可信 IP 二者兼具。带 JS token 的 Crawling API 在一次调用里同时做到两点;
ajax_wait和page_wait控制它等待内容的时长。 -
由 BeautifulSoup 完成抽取。把标题、地址、价格、卧室数、卫生间数、面积和链接映射到
for-rent-content-container卡片选择器上,并预期这些选择器会漂移。 -
遍历分页,然后导出。迭代
p{page}片段,收集每张卡片,用一个短暂的 sleep 给运行设置节奏,并把结果写入 JSON 和 CSV。 - 守在公开数据上。尊重 Homes.com 的 ToS 和 robots.txt,只采集公开房源字段,把经纪人和业主的个人详情排除在外,并对任何商业或批量需求使用获得授权的 MLS 数据源。
常见问题
为什么普通请求从 Homes.com 返回不到数据?
因为 Homes.com 用 JavaScript 在客户端渲染其房源内容。最初的 HTML 是一个空壳,要等页面脚本在浏览器中运行后才会填充,所以一个原始的 HTTP 请求返回状态 200,而价格、卧室数、卫生间数和面积字段都是空的。要拿到真实数据,你必须先渲染页面,而这正是 Crawling API 的 JS token 替你处理的事。
对 Homes.com 我该用普通 token 还是 JS token?
JS token。普通 token 获取静态 HTML,而在 Homes.com 上那就是普通请求返回的同一个空壳。JS token 会先在真实浏览器中渲染页面再交回 HTML,所以当 BeautifulSoup 解析时房源字段都在。ajax_wait 和 page_wait 选项告诉渲染器要为那部分内容等待多久。
我能从 Homes.com 房源抓取哪些数据?
公开的房源字段:标题、街道地址、挂牌价或月租金、卧室数和卫生间数、有显示时的建筑面积,以及指向房产页面的链接。守在任何访客无需账户即可看到的数据上,并避开经纪人或业主的个人详情,那些落在本指南所涵盖的公开房源范围之外。
我的选择器返回 "N/A"。是什么变了?
几乎可以肯定是 Homes.com 的标记变了。for-rent-content-container 卡片、property-name 和 address 字段,以及 detailed-info-container 行都会在没有任何通知的情况下变化,所以上个月还能用的选择器可能会失效。在浏览器开发者工具里重新检视一个实时页面并更新选择器。对任何生产级抓取器来说,定期维护选择器都是常态。
我该如何处理一个城市房源里的分页?
Homes.com 在搜索路径后追加一个 p{page} 片段,所以你在循环里获取 .../p1/、.../p2/ 等等,解析每一页上的卡片,并把它们汇集进一个列表。请求之间保留一个短暂的 sleep,并在你选定的页数上限处停止。上面的 scrape_search 函数展示了完整的循环。
抓取 Homes.com 时我该如何避免被封禁?
把你的单 IP 请求速率保持得低,用一个短延迟给请求设置节奏,把目标变换一下而不是循环爬取同一条路径,并经由轮换住宅 IP 路由,让没有任何单个地址触发速率限制。Crawling API 替你管理轮换和一个可信 IP 池;如果你搭建自己的技术栈,那就是要投入精力的部分。盯住状态码,并在开始看到验证质询时退一步。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
