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 网站

为什么用 JS token

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 当成密码对待:它用于认证你的请求,所以不要把它纳入版本控制。

搭建项目

创建一个虚拟环境,让项目依赖保持隔离,然后安装抓取器所需的两个库。

bash
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 在加载后固定等待若干毫秒,让那些延迟渲染的元素在页面被捕获前出现。在解析之前检查状态,能让失败显式暴露而不是悄无声息。

python
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 运行脚本,你应当看到真实的搜索页标记,而不是普通抓取返回的空壳。这能在你写下任何一个选择器之前确认渲染是有效的。

Crawlbase Crawling API

那一次 make_crawlbase_request 调用替你完成了最难的部分。Homes.com 需要一个可信 IP 背后的渲染页面,而 Crawling API 接收你的 JS token,在真实浏览器中运行页面,在服务端轮换住宅 IP,并交回处理完的 HTML,于是你就省去了自己运行无头浏览器集群和代理池的麻烦。先在免费档位上把它指向一个公开的搜索页吧。

第 2 步:用 BeautifulSoup 解析房源卡片

拿到渲染后的 HTML 后,把它加载进 BeautifulSoup 并抽取每张卡片。在浏览器的开发者工具里检视一个 Homes.com 搜索页,你会发现每条房源都被包裹在一个 class 为 for-rent-content-containerdiv 中。把它们全部选出来,然后从每一个里读取各个字段。标题位于 p.property-name 中,地址位于 p.address 中,而价格、卧室数和卫生间数则来自 ul.detailed-info-container 内的 li 项,且按此顺序排列。

python
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-nameaddress 字段,以及 detailed-info-container 行)会在没有任何通知的情况下变化。把上面的选择器当成一个起始模板,而不是一份契约。当某个字段返回为 "N/A" 时,在浏览器开发者工具里重新检视实时页面并更新选择器。对任何生产级抓取器来说,定期维护选择器都是常态,并不代表哪里坏了。

第 3 步:遍历分页

一个页面只是演示;真正的任务会跑遍整个结果集。Homes.com 在搜索路径后追加一个页码片段,所以某个城市的房源位于 .../homes-for-rent/p1/.../p2/ 等处。在固定数量的页面上循环,通过同一个请求函数获取每一页,解析它的卡片,并把一切汇集到一个列表里。页面之间稍作停顿能避免让运行把站点压垮。

python
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 则可直接进入电子表格用于排序和制图。两个小助手函数即可覆盖两者。

python
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 并运行它。

python
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 或数据库。

json
[
    {
        "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 版本每条房源占一行,带有 titleaddresspricebedsbathssizelink 列,可在任何电子表格中干净地打开,按价格排序或按社区筛选。

扩展到房产详情页

搜索抓取器给你的是卡片级别的字段。当你想要单套房产的完整详情时,跟进你已经捕获的 link 并解析房产页面,它携带更丰富的字段,比如地块面积、更长的描述,以及房源经纪人的公开联系信息行。房产页面使用它自己的选择器:地址位于 div.property-info-address,价格位于 span#price,卧室数和卫生间数位于 span.feature-bedsspan.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_waitpage_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_waitpage_wait 选项告诉渲染器要为那部分内容等待多久。

我能从 Homes.com 房源抓取哪些数据?

公开的房源字段:标题、街道地址、挂牌价或月租金、卧室数和卫生间数、有显示时的建筑面积,以及指向房产页面的链接。守在任何访客无需账户即可看到的数据上,并避开经纪人或业主的个人详情,那些落在本指南所涵盖的公开房源范围之外。

我的选择器返回 "N/A"。是什么变了?

几乎可以肯定是 Homes.com 的标记变了。for-rent-content-container 卡片、property-nameaddress 字段,以及 detailed-info-container 行都会在没有任何通知的情况下变化,所以上个月还能用的选择器可能会失效。在浏览器开发者工具里重新检视一个实时页面并更新选择器。对任何生产级抓取器来说,定期维护选择器都是常态。

我该如何处理一个城市房源里的分页?

Homes.com 在搜索路径后追加一个 p{page} 片段,所以你在循环里获取 .../p1/.../p2/ 等等,解析每一页上的卡片,并把它们汇集进一个列表。请求之间保留一个短暂的 sleep,并在你选定的页数上限处停止。上面的 scrape_search 函数展示了完整的循环。

抓取 Homes.com 时我该如何避免被封禁?

把你的单 IP 请求速率保持得低,用一个短延迟给请求设置节奏,把目标变换一下而不是循环爬取同一条路径,并经由轮换住宅 IP 路由,让没有任何单个地址触发速率限制。Crawling API 替你管理轮换和一个可信 IP 池;如果你搭建自己的技术栈,那就是要投入精力的部分。盯住状态码,并在开始看到验证质询时退一步。

开始构建

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

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

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