跳至内容
免费开始
返回博客

房地产网页抓取:如何用 Python 采集房产数据

Robert Munceanu最后更新于 5 min read
房地产网页抓取:如何用 Python 采集房产数据

开始提取

免费试用 WebScrapingAPI

领取 1,000 个免费 API 积分。无需信用卡。

开始使用
要点速览:房地产网页抓取是从网站上采集房源、价格和房产详情,并整理成结构化数据。动手抓取之前,先看看有没有开放数据、MLS 数据源或数据 API,因为 Zillow、Realtor.com 和 Redfin 都在使用条款中禁止自动化采集。本指南涵盖数据模型、数据来源、法律限制、Python 代码和质量检查。

迟早会有人让你"弄一份某个市场的房产数据":用于比价的挂牌价、报告要用的在售房源量,或者一个新房源的数据流。房地产数据在网上随处可见,所以抓取看起来是理所当然的办法。但大多数这类项目都栽在两个错误上。第一,抓取了使用条款明令禁止的门户网站。第二,把挂牌价当成成交价,在错误的数字上做分析。

先做决策:有哪些数据,哪种来源适合哪项任务,各大门户允许做什么。然后在一个你自己掌控的示例页面上,用 requests、BeautifulSoup 和 pandas 搭建 Python 爬虫,再从一个免费的开放数据文件中获取真实房价。

什么是房地产网页抓取?

房地产网页抓取是指从网站上自动采集房产信息,例如挂牌价、地址、卧室数、面积、照片和挂牌天数。爬虫抓取页面、提取字段,并把它们作为行保存到 CSV 文件或数据库中,方便你比较不同市场或追踪随时间发生的变化。

抓取只是获取这些信息的途径之一。同样的数字往往还能通过免费下载、授权的 MLS 数据源或数据 API 获得,这些途径通常维护成本更低,使用起来也更安全。写第一行代码之前,先把这些途径比较一下。

可以抓取哪些房地产数据?

大多数房源页面会显示地址、挂牌价或租金、卧室数、浴室数、居住面积、地块面积、房产类型、状态、挂牌日期、照片和挂牌经纪人。这些字段描述的其实是三样不同的东西:房产本身、宣传它的房源,以及你在某个日期观察到的每一个价格。

一套房子可能在五年内三次挂牌,经手两家经纪公司,出现五个不同的价格。如果把这些全塞进一行,你就丢掉了让数据有价值的历史记录。应该把数据分成三层来建模:

层级

含义

示例字段

去重键

房产

实体房屋,很少变化

地址、地块编号、卧室、浴室、居住面积、地块面积、建造年份

标准化地址或地块 ID

挂牌周期

房屋某一次在市场上挂牌

来源网站、房源 ID、状态、挂牌日期、挂牌经纪公司、URL

来源 + 房源 ID

观测记录

你在某一时刻看到的情况

价格、状态、挂牌天数、collected_at

房源 ID + 时间戳

价格同样需要谨慎对待。一个房源页面可能显示好几个看起来相似、含义却不同的数字:

价格类型

含义

是否为成交价?

挂牌价(要价)

卖方当前的要价,可能下调也可能上调

否

成交价

买方在交割时实际支付的价格

是

挂牌租金

房东每个周期(月或周)的要价

否,而且必须注明周期

自动估价

模型给出的估值,例如 Zestimate

否

计税评估价

地方政府用于计算房产税的价值

否

把每种类型放在单独的列中,或者给每个价格标注类型。我们的房源数据采用的也是同样的思路:一套房产、宣传它的房源,以及带日期的价格观测记录,因为房源只告诉你有人要价多少,而不是有人实际付了多少。

采集任何数据之前还有一条规则:在大多数隐私法律下,经纪人的姓名、电话号码和电子邮件地址都属于个人数据。除非你有合法理由保留,否则不要采集。

谁在用房地产抓取,用来做什么?

经纪人和经纪公司用它做可比成交分析和定价。投资者用它寻找被低估的房产、跟踪租金回报率。房地产科技(proptech)团队用它构建房源数据流和估值模型。研究人员和记者用它跟踪在售房源量和降价情况。买房者和租房者用它关注某个街区。

关键是把每项任务对应到它需要的字段,以及能以最低风险提供这些字段的来源:

使用场景

所需字段

首选来源

可比成交与定价

成交价、卧室、浴室、面积、成交日期

通过经纪公司获取的 MLS 数据源,或授权数据

市场监测

按都市区或邮政编码统计的在售房源量、挂牌价中位数、挂牌天数

开放数据下载

投资筛选

挂牌价、租金、税费、价格历史

数据 API 或授权数据,加上公开的租金指数

房源聚合

带照片的完整房源记录

MLS IDX 或授权数据源;在这一场景下抓取风险最高

潜在客户开发

业主或经纪人联系方式

务必谨慎:个人数据和反垃圾信息规则适用

个人找房

少量房源的提醒

门户网站自带的保存搜索提醒

如果你的任务属于前三行,可能根本不需要爬虫。

房地产数据应该抓取、下载还是购买授权?

如果你需要的是市场层面的数字,先下载,因为 Zillow、Redfin 和 Realtor.com 都发布免费的住房数据文件。如果需要大规模的房源级记录,就购买 MLS 数据源授权或使用数据 API。只抓取使用条款允许的页面,或者你自己的网站、合作伙伴的网站,以及允许自动访问的公共来源。

途径

提供的内容

成本与工作量

获取门槛

最适合

注意事项

开放数据下载(Zillow Research、Redfin Data Center、Realtor.com 数据库、FHFA HPI)

都市区、县和邮政编码级别的汇总数据

免费;几行 pandas 代码

无

趋势、报告、市场筛选

按月更新;没有房源级数据行

通过 RESO Web API 获取 MLS

权威房源记录,包括成交数据

授权费用和协议

高:需要会员资格、经纪公司或经批准的供应商

经纪公司、IDX 网站、估值

展示和使用规则

房地产数据 API 或数据集

无需自己运行爬虫的结构化房源记录

按报价

低到中

需要房源数据但没有解析器团队的产品

供应商条款、覆盖范围和时效性

Scraper API

你有权采集的页面的渲染后 HTML;解析器由你编写

按请求付费

低

经纪公司网站、合作伙伴数据源、允许访问的公共来源

解析器和合规检查仍由你负责

自建爬虫(requests、BeautifulSoup、Playwright)

你构建的任何内容

你的时间、服务器和代理

起步低,维护成本高

小型的、被允许的任务和原型

失效、封禁和合规问题

大多数团队最终会组合使用多种途径:用开放数据掌握市场全貌,用数据源或 API 获取房源记录,再用爬虫覆盖长尾的、允许采集的网站。

抓取 Zillow、Realtor.com 和 Redfin 合法吗?

它们的使用条款禁止这样做。Zillow 禁止"automated queries"(自动化查询),包括抓取和绕过验证码。Realtor.com 禁止屏幕抓取和数据库抓取。Redfin 禁止未经书面许可的自动爬取。违反条款可能导致账号被封、IP 被屏蔽或合同索赔,即便在抓取公开页面并不构成犯罪的地区也是如此。请改用它们提供的数据下载或授权来源。

以下是各大门户条款的原文表述(引文保留英文原文):

门户

条款内容

robots.txt

官方替代方案

Zillow

Zillow 使用条款(2025 年 10 月 28 日更新)禁止进行"automated queries (including screen and database scraping, spiders, robots, crawlers, bypassing 'captcha' or similar precautions…)"。只有在"without the aid of any automated processes"的情况下才允许复制。汇总的 Local-Info 数据可在注明"Data Provided by Zillow Group"的前提下重复使用。

文件很长,包含路径级规则

Zillow Research 数据下载;按其自身条款使用 Zillow Group API

Realtor.com

Realtor.com 服务条款禁止"screen scraping"和"database scraping",以及通过"robot, spider, script"等"by automated means"方式进行采集。条款还指出,robots.txt 的设置不会扩大你的权利。

存在,但不能凌驾于条款之上

Realtor.com 住宅数据库(CSV)

Redfin

Redfin 使用条款包含"No Scrubbing or Scraping"一条:"unless you have received prior express written permission",不得进行自动爬取或查询。房源页面上的 MLS 声明也重申了这一禁令。

存在,包含路径级规则

Redfin Data Center 数据下载

Rightmove(英国)

Rightmove 使用条款的页脚写明:"Rightmove prohibits the scraping of its content."

无论 robots.txt 如何规定,该禁令都适用

向 Rightmove 申请书面许可

其他门户(Zoopla、Idealista、realestate.com.au、Domain、Homes.com)

采集任何数据前,先阅读每个网站的条款页面

逐站核查

逐站核查

在美国,第九巡回上诉法院在 hiQ Labs v. LinkedIn(2022 年)一案中裁定,抓取可公开访问的页面很可能不构成《计算机欺诈和滥用法》(Computer Fraud and Abuse Act)所指的"未经授权的访问"。但这一裁决并不会让其他规则失效。使用条款是合同,房源照片和描述受版权保护,隐私法保护数据中涉及的个人。Zillow 自己的条款也写明,它不主张房源图片和描述的版权,版权属于其所有者。在美国以外,欧盟的数据库权利和 GDPR 也各有限制。

本文不构成法律建议。在从条款禁止抓取的网站采集数据之前,请咨询律师。如需更全面的概述,请阅读网页抓取的法律基础。

可以抓取 MLS 数据吗?

不能,至少没有正当途径。MLS 数据授权给会员使用,IDX 规则允许经纪人向消费者展示房源,但不允许他人复制。正当途径是 RESO Web API 数据源,可通过 MLS 会员资格、合作经纪公司或经批准的数据供应商获得。门户网站上来自 MLS 的页面也受同样的限制。

RESO Web API 是一项标准,而不是数据源。RESO 发布了一套共享的数据字典,因此 ListPrice、StandardStatus 和 BedroomsTotal 等字段在所有合规的 MLS 中含义相同。Web API 遵循 OData 约定,通过 REST 提供这些字段。不过,仍然需要有人为你开通访问权限。常见途径包括:

  • 登记经纪人(broker of record)。你任职或合作的持牌经纪公司向其 MLS 申请数据源。
  • MLS 认可的供应商。许多 MLS 依据供应商协议向技术供应商授权数据。
  • 多 MLS 聚合商。与众多 MLS 签有协议、转售标准化访问权限的公司。

还有两个缩写经常出现。IDX(Internet Data Exchange)允许参与的经纪人在自己的网站上展示彼此的房源。VOW(virtual office website)则向经纪人的注册客户展示更多细节。两者都是由 MLS 制定规则的展示计划,都不允许第三方把房源复制到自己的数据库中。

拿到凭证后,请求大致如下。这里只展示结构,具体的端点以及你有权读取的字段由你的 MLS 提供。

import os

import requests

# Shape only: your MLS or data vendor supplies the endpoint, token, and fields.
BASE_URL = os.environ["RESO_BASE_URL"]
TOKEN = os.environ["RESO_ACCESS_TOKEN"]

response = requests.get(
    f"{BASE_URL}/Property",
    headers={"Authorization": f"Bearer {TOKEN}"},
    params={
        "$select": "ListingKey,ListPrice,StandardStatus,BedroomsTotal,City",
        "$filter": "StandardStatus eq 'Active' and City eq 'Springfield'",
        "$top": 100,
    },
    timeout=30,
)
listings = response.json()["value"]

$select、$filter 和 $top 参数来自 OData,因此同样的写法适用于各个通过 RESO 认证的服务器,只是筛选语法略有差异。

房地产爬虫是如何工作的?

房地产爬虫分五步工作。它先从搜索页面或站点地图中发现房源 URL,然后抓取每个页面,必要时渲染 JavaScript,再从结构化数据或 HTML 中提取字段。接着,它对价格、面积和地址进行标准化。最后,它为每条观测记录加上时间戳并保存,以便后续运行能检测到价格和状态的变化。

发现房源 URL

房源 URL 来自站点地图、搜索结果页和分页链接。搜索页面往往在返回固定数量的结果后就不再显示更多,因此要把大区域按邮政编码或价格区间拆分成小区域。不要爬取每一种筛选组合。排序方式和筛选条件相互组合会产生数以百万计的 URL,而其中大部分只是在重复相同的房源。如果一个你有权采集的网站发布了站点地图,就从站点地图入手:它列出了房源的规范 URL,而 lastmod 日期会告诉你自上次运行以来哪些页面发生了变化。

抓取页面

有些网站直接返回完整的 HTML,有些则在浏览器中用 JavaScript 构建页面。能抓静态版本时就抓静态版本,因为更快也更便宜。缓存响应,保持较低的请求频率,并按照 Robots Exclusion Protocol 的规定遵守 robots.txt。记住,robots.txt 永远不会授予使用条款所拒绝的权限。

提取字段

按以下顺序检查数据来源。第一,使用 schema.org 类型(如 RealEstateListing 或 SingleFamilyResidence)的 JSON-LD 块。第二,网站前端框架嵌入在 script 标签中的 JSON。具体做法可参阅如何解析页面中嵌入的 JSON。第三,HTML 选择器。结构化数据的变化频率低于页面标记,因此爬虫更不容易失效。

标准化与去重

把"$1,250,000"转换成 1250000 并附上币种。把所有面积换算成同一单位。明确写出租金周期。标准化地址,使同一套房屋能在不同来源之间匹配,并按标准化后的地址或地块 ID 去重。

保存观测记录并检测变化

每次运行都追加新行,而不是覆盖旧行。在解析后的值旁边保留 collected_at、来源 URL 和原始值。把今天的数据与上周的数据对比,就能看出降价、状态变化和新房源。在 pandas 中,只需在两次运行之间按房源 ID 做一次合并:只出现在新一次运行中的行是新房源,只出现在旧一次运行中的行已经下架,两次都出现但价格不同的行就是价格变化。

如何用 Python 抓取房地产数据?

安装 requests、BeautifulSoup 和 pandas。抓取一个你有权采集的房源页面,先读取它的 JSON-LD,缺失的字段再用 HTML 选择器补齐。把价格和面积转换为数字,删除重复房源,然后导出为 CSV。如果页面需要渲染 JavaScript 或返回特定地区的结果,就使用抓取 API。

本教程使用一个包含虚构房源的小型示例网站,这样你可以运行每一步,而不会给任何人的服务器增加负担。只要换掉选择器,同样的代码也适用于你自己经纪公司的网站或合作伙伴的页面。

搭建项目

你需要 Python 3.12 或更高版本。创建一个文件夹和虚拟环境,然后安装这四个包:

mkdir property-scraper && cd property-scraper
python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install requests beautifulsoup4 lxml pandas

从示例房源页面开始

把下面的示例页面保存为 sample_listings.html。它包含四个虚构房源,其中两个也出现在一个 JSON-LD 块中,很多真实网站正是以这种方式提供结构化数据的。其中一个是出租房源,另一个是开间公寓(studio),所以解析器必须能同时处理这两种情况。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Homes for sale and rent in Springfield (sample page)</title>
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "ItemList",
    "itemListElement": [
      {
        "@type": "RealEstateListing",
        "identifier": "SPR-1001",
        "url": "https://example.com/listings/SPR-1001",
        "datePosted": "2026-10-12",
        "offers": {"@type": "Offer", "price": 425000, "priceCurrency": "USD"},
        "about": {
          "@type": "SingleFamilyResidence",
          "address": {"@type": "PostalAddress", "streetAddress": "123 Example St",
                      "addressLocality": "Springfield", "addressRegion": "ST", "postalCode": "00001"},
          "numberOfBedrooms": 3,
          "numberOfBathroomsTotal": 2,
          "floorSize": {"@type": "QuantitativeValue", "value": 1850, "unitCode": "FTK"}
        }
      },
      {
        "@type": "RealEstateListing",
        "identifier": "SPR-1002",
        "url": "https://example.com/listings/SPR-1002",
        "datePosted": "2026-10-20",
        "offers": {"@type": "Offer", "price": 1250000, "priceCurrency": "USD"},
        "about": {
          "@type": "SingleFamilyResidence",
          "address": {"@type": "PostalAddress", "streetAddress": "45 Sample Ave",
                      "addressLocality": "Springfield", "addressRegion": "ST", "postalCode": "00002"},
          "numberOfBedrooms": 5,
          "numberOfBathroomsTotal": 4.5,
          "floorSize": {"@type": "QuantitativeValue", "value": 4100, "unitCode": "FTK"}
        }
      }
    ]
  }
  </script>
</head>
<body>
  <ul class="results">
    <li class="listing-card" data-listing-id="SPR-1001" data-status="for_sale">
      <a class="listing-link" href="https://example.com/listings/SPR-1001">123 Example St, Springfield, ST 00001</a>
      <span class="price">$425,000</span>
      <span class="beds">3 bd</span> <span class="baths">2 ba</span> <span class="area">1,850 sqft</span>
    </li>
    <li class="listing-card" data-listing-id="SPR-1002" data-status="for_sale">
      <a class="listing-link" href="https://example.com/listings/SPR-1002">45 Sample Ave, Springfield, ST 00002</a>
      <span class="price">$1,250,000</span>
      <span class="beds">5 bd</span> <span class="baths">4.5 ba</span> <span class="area">4,100 sqft</span>
    </li>
    <li class="listing-card" data-listing-id="SPR-1003" data-status="for_rent">
      <a class="listing-link" href="https://example.com/listings/SPR-1003">7 Placeholder Ln, Unit 2B, Springfield, ST 00001</a>
      <span class="price">$2,400/mo</span>
      <span class="beds">2 bd</span> <span class="baths">1 ba</span> <span class="area">950 sqft</span>
    </li>
    <li class="listing-card" data-listing-id="SPR-1004" data-status="for_sale">
      <a class="listing-link" href="https://example.com/listings/SPR-1004">88 Demo Rd, Springfield, ST 00003</a>
      <span class="price">$389,900</span>
      <span class="beds">Studio</span> <span class="baths">1 ba</span> <span class="area">610 sqft</span>
    </li>
  </ul>
  <a class="next" href="sample_listings_2.html">Next</a>
</body>
</html>

"Next"链接指向第二个示例页面。把它保存为 sample_listings_2.html。它重复了第 1 页中的一个房源。真实网站上,新房源把旧房源挤到后面时就会出现这种情况:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Homes in Springfield, page 2 (sample page)</title></head>
<body>
  <ul class="results">
    <li class="listing-card" data-listing-id="SPR-1004" data-status="for_sale">
      <a class="listing-link" href="https://example.com/listings/SPR-1004">88 Demo Rd, Springfield, ST 00003</a>
      <span class="price">$389,900</span>
      <span class="beds">Studio</span> <span class="baths">1 ba</span> <span class="area">610 sqft</span>
    </li>
    <li class="listing-card" data-listing-id="SPR-1005" data-status="for_sale">
      <a class="listing-link" href="https://example.com/listings/SPR-1005">19 Fixture Ct, Springfield, ST 00002</a>
      <span class="price">$615,000</span>
      <span class="beds">4 bd</span> <span class="baths">2.5 ba</span> <span class="area">2,300 sqft</span>
    </li>
  </ul>
</body>
</html>

在本地为该文件夹启动一个 HTTP 服务,让爬虫像在真实网站上一样通过 HTTP 抓取页面:

python -m http.server 8000

让服务器保持运行,再打开第二个终端进行后续步骤。把本步骤及接下来五个步骤中的代码按顺序放进同一个名为 scraper.py 的文件中。先写导入语句和一个抓取函数,它会发送如实标识身份的 User-Agent,并在出现 HTTP 错误时明确报错:

import json
import re
import time
from datetime import datetime, timezone
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

HEADERS = {"User-Agent": "property-research/1.0 (you@example.com)"}


def fetch(url: str) -> str:
    response = requests.get(url, headers=HEADERS, timeout=30)
    response.raise_for_status()
    return response.text

优先提取 JSON-LD

JSON-LD 位于 <script type="application/ld+json"> 标签中。一个页面可能包含单个对象、对象列表,或者一个包裹房源的 ItemList,因此辅助函数会把这三种结构都拆开。然后它把每个 RealEstateListing 映射为扁平字段:

def iter_jsonld(soup: BeautifulSoup):
    """Yield every JSON-LD object on the page, unwrapping lists and ItemLists."""
    for tag in soup.find_all("script", type="application/ld+json"):
        try:
            data = json.loads(tag.string or "")
        except json.JSONDecodeError:
            continue
        for item in data if isinstance(data, list) else [data]:
            if item.get("@type") == "ItemList":
                for element in item.get("itemListElement", []):
                    yield element.get("item", element)
            else:
                yield item


def listings_from_jsonld(soup: BeautifulSoup) -> dict:
    found = {}
    for item in iter_jsonld(soup):
        if item.get("@type") != "RealEstateListing":
            continue
        home = item.get("about", {})
        addr = home.get("address", {})
        offer = item.get("offers", {})
        found[item["identifier"]] = {
            "listing_id": item["identifier"],
            "url": item.get("url"),
            "address": f"{addr.get('streetAddress')}, {addr.get('addressLocality')}, "
                       f"{addr.get('addressRegion')} {addr.get('postalCode')}",
            "price": offer.get("price"),
            "currency": offer.get("priceCurrency"),
            "beds": home.get("numberOfBedrooms"),
            "baths": home.get("numberOfBathroomsTotal"),
            "sqft": home.get("floorSize", {}).get("value"),
            "source": "json-ld",
        }
    return found

JSON-LD 中的值本身就是数字,省去了大部分清洗工作。

回退到 HTML 选择器

第 1 页的四张房源卡片中有两张没有 JSON-LD,而且没有一张在结构化数据中提供房源状态。HTML 解析器通过每张卡片的 data-* 属性和类名读取内容,并保留每个字段的原始文本。如果你刚接触选择器,可以先看我们的 BeautifulSoup 教程。

def text_of(card, selector: str) -> str | None:
    node = card.select_one(selector)
    return node.get_text(strip=True) if node else None


def listings_from_html(soup: BeautifulSoup, base_url: str) -> dict:
    found = {}
    for card in soup.select("li.listing-card"):
        link = card.select_one("a.listing-link")
        listing_id = card["data-listing-id"]
        found[listing_id] = {
            "listing_id": listing_id,
            "status": card.get("data-status"),
            "url": urljoin(base_url, link["href"]),
            "address": link.get_text(strip=True),
            "price_raw": text_of(card, ".price"),
            "beds_raw": text_of(card, ".beds"),
            "baths_raw": text_of(card, ".baths"),
            "sqft_raw": text_of(card, ".area"),
        }
    return found

在真实网站上,优先使用 data-* 属性和稳定的类名,而不是冗长的 CSS 路径。它们在网站改版后更不容易失效。

清洗与标准化字段

现在把原始字符串转换为数字。parse_price 去掉数字以外的所有字符,因此"$1,250,000"变成 1250000,"$2,400/mo"变成 2400。rent_period 把"/mo"信息单独存成一列。normalize 合并两个来源,在 JSON-LD 有值时以它为准,把每个价格标注为挂牌价或挂牌租金,并为该行加上 UTC 时间戳:

def parse_price(text: str | None) -> int | None:
    """'$1,250,000' -> 1250000 and '$2,400/mo' -> 2400."""
    if not text:
        return None
    digits = re.sub(r"[^\d]", "", text.split("/")[0])
    return int(digits) if digits else None


def rent_period(text: str | None) -> str | None:
    if text and re.search(r"/\s*(mo|month)", text, re.I):
        return "month"
    if text and re.search(r"/\s*(wk|week)", text, re.I):
        return "week"
    return None


def parse_number(text: str | None) -> float | None:
    if not text:
        return None
    if "studio" in text.lower():
        return 0.0
    match = re.search(r"\d[\d,]*(\.\d+)?", text)
    return float(match.group().replace(",", "")) if match else None


def normalize(html_row: dict, jsonld_row: dict | None) -> dict:
    raw_price = html_row.get("price_raw")
    row = {
        "listing_id": html_row["listing_id"],
        "status": html_row.get("status"),
        "price": parse_price(raw_price),
        "currency": "USD" if raw_price and "$" in raw_price else None,
        "rent_period": rent_period(raw_price),
        "beds": parse_number(html_row.get("beds_raw")),
        "baths": parse_number(html_row.get("baths_raw")),
        "sqft": parse_number(html_row.get("sqft_raw")),
        "address": html_row.get("address"),
        "url": html_row.get("url"),
        "source": "html",
    }
    # Structured data wins wherever it has a value.
    for key, value in (jsonld_row or {}).items():
        if value is not None:
            row[key] = value
    row["price_type"] = "asking_rent" if row["status"] == "for_rent" else "asking_price"
    row["sqft"] = int(row["sqft"]) if row["sqft"] is not None else None
    row["collected_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
    row["price_raw"] = raw_price
    return row


def parse_page(html: str, base_url: str) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")
    structured = listings_from_jsonld(soup)
    cards = listings_from_html(soup, base_url)
    return [normalize(card, structured.get(lid)) for lid, card in cards.items()]

source 列记录了每一行数据的来源。如果网站改版后页面上的 JSON-LD 消失了,这一列会在你的数字出错之前提醒你。

处理分页与重复项

爬虫会一直跟随"Next"链接直到没有下一页,在请求之间等待,并跳过已经见过的房源 ID。遇到空页面或达到 max_pages 时它也会停止,这样一个失效的链接就不会让它陷入死循环:

def crawl(start_url: str, max_pages: int = 20, delay: float = 2.0) -> list[dict]:
    rows, seen, url = [], set(), start_url
    for _ in range(max_pages):
        html = fetch(url)
        page_rows = parse_page(html, url)
        new_rows = [r for r in page_rows if r["listing_id"] not in seen]
        seen.update(r["listing_id"] for r in new_rows)
        rows.extend(new_rows)
        print(f"{url}: {len(page_rows)} cards, {len(new_rows)} new")

        next_link = BeautifulSoup(html, "lxml").select_one("a.next")
        if not page_rows or not next_link:
            break
        url = urljoin(url, next_link["href"])
        time.sleep(delay)
    return rows

在真实网站上,两秒或更长的延迟是合理的默认值。如果服务器变慢或返回错误,就把延迟调大。

导出为 CSV

最后一段代码运行爬取,并用 pandas 写入 listings.csv:

if __name__ == "__main__":
    import pandas as pd

    rows = crawl("http://localhost:8000/sample_listings.html", delay=1.0)
    df = pd.DataFrame(rows)
    df.to_csv("listings.csv", index=False)
    print(df[["listing_id", "price_type", "price", "rent_period",
              "beds", "baths", "sqft", "source"]].to_string(index=False, na_rep=""))

运行 python scraper.py。示例页面的输出如下:

http://localhost:8000/sample_listings.html: 4 cards, 4 new
http://localhost:8000/sample_listings_2.html: 2 cards, 1 new
listing_id   price_type   price rent_period  beds  baths  sqft  source
  SPR-1001 asking_price  425000               3.0    2.0  1850 json-ld
  SPR-1002 asking_price 1250000               5.0    4.5  4100 json-ld
  SPR-1003  asking_rent    2400       month   2.0    1.0   950    html
  SPR-1004 asking_price  389900               0.0    1.0   610    html
  SPR-1005 asking_price  615000               4.0    2.5  2300    html

六张卡片得到五个唯一房源:第 2 页重复的 SPR-1004 被跳过了。前两行的值来自 JSON-LD,其余来自 HTML。出租房源保留了按月计算的周期,开间公寓的卧室数为 0,而不是缺失值。CSV 中每一行还包含 status、currency、address、url、collected_at 以及原始价格文本。

用抓取 API 抓取依赖 JavaScript 的页面

示例网站是静态的。许多真实的房源页面用 JavaScript 加载数据,有些还会根据访客所在地显示不同的结果。这种情况下,抓取 API 可以通过真实浏览器、从你选定的国家抓取页面,并返回最终的 HTML。渲染和地理位置也正是最常决定你看到的页面是否与访客一致的两项设置。更多背景信息,请参阅如何避免封禁和速率限制。

下面的请求使用 WebScrapingAPI 的 v2 端点。从环境变量中读取密钥,切勿将其打印出来。TARGET_URL 是占位符,请替换为你有权采集的页面,例如你自己经纪公司或合作伙伴的网站:

import os

import requests

from scraper import parse_page

TARGET_URL = "https://www.your-brokerage.example/listings?city=springfield"

response = requests.get(
    "https://api.webscrapingapi.com/v2",
    params={
        "api_key": os.environ["WSA_API_KEY"],
        "url": TARGET_URL,
        "render_js": 1,
        "country": "us",
    },
    timeout=90,
)
response.raise_for_status()
rows = parse_page(response.text, TARGET_URL)
print(f"{len(rows)} listings parsed")

先在 shell 中用 export WSA_API_KEY=... 设置密钥。响应正文就是渲染后的 HTML,因此爬虫中的 parse_page 无需修改即可处理。只有选择器需要与目标网站匹配。

不抓取的话,如何获取房价?

直接下载。Realtor.com、Zillow 和 Redfin 都发布免费的住房市场文件,按都市区、县或邮政编码提供挂牌价中位数、在售房源量、挂牌天数和降价数据,每月更新。FHFA 房价指数(House Price Index)追踪重复销售价格。这些数据无需任何抓取就能回答市场层面的问题。

主要来源:

  • Realtor.com 住宅数据库。Realtor.com 住宅数据库按国家、州、都市区、县和邮政编码提供每月更新的 CSV 文件,包含当前版本和历史版本。
  • Zillow Research。Zillow Research 数据包括房屋价值指数和租金指数、在售房源量和成交价文件,供你自行分析。
  • Redfin Data Center。Redfin Data Center 发布市场追踪文件,包含成交价、在售房源量和降价数据。
  • FHFA。FHFA 房价指数根据同一房屋的重复销售来衡量价格变化。

下面的示例把 Realtor.com 的都市区文件直接读入 pandas,并列出挂牌价中位数最高的 10 个都市区:

import pandas as pd

CSV_URL = ("https://econdata.s3-us-west-2.amazonaws.com/"
           "Reports/Core/RDC_Inventory_Core_Metrics_Metro.csv")

metros = pd.read_csv(CSV_URL)
month = metros["month_date_yyyymm"].iloc[0]
top = (metros.dropna(subset=["median_listing_price"])
             .nlargest(10, "median_listing_price")
             [["cbsa_title", "median_listing_price",
               "active_listing_count", "median_days_on_market"]])
print(f"Data month: {month}")
print(top.to_string(index=False))

输出:

Data month: 202609
                        cbsa_title  median_listing_price  active_listing_count  median_days_on_market
                     Nantucket, MA             5873250.0                   145                   84.0
                Vineyard Haven, MA             2495000.0                   260                   99.0
                    Jackson, WY-ID             2150000.0                   418                  108.0
     Santa Maria-Santa Barbara, CA             1720750.0                   718                   58.0
                         Heber, UT             1594500.0                  1595                   90.0
                         Rifle, CO             1510750.0                   875                   93.0
                         Kapaa, HI             1507750.0                   385                  116.0
San Jose-Sunnyvale-Santa Clara, CA             1388472.0                  2112                   35.0
                       Salinas, CA             1289500.0                   551                   66.0
                          Napa, CA             1272500.0                   572                   81.0

来源:Realtor.com Residential Data Library,都市区核心指标,2026 年 9 月(month_date_yyyymm 202609)。

该文件还包含 price_reduced_share、new_listing_count、pending_listing_count 和 median_listing_price_per_square_foot,以及每项指标的环比和同比变化。每次运行脚本时都打印月份,因为文件按月更新,你的报告应注明描述的是哪个月。在你发布的任何图表或表格下方注明数据来源。

如需查看随时间变化的趋势,同一数据库为每个文件都提供了 _History 版本,每个都市区每月一行。替换 CSV_URL 中的文件名,然后按 cbsa_title 筛选,并以 month_date_yyyymm 为横轴绘制 median_listing_price。邮政编码级文件使用相同的指标列,只是地理列不同,所以代码几乎不用改。

如何检查抓取到的房产数据是否正确?

每次运行都按几条规则检查。每一行都有地址、价格和来源 URL。价格处于该市场的合理区间内。行数接近网站显示的总数。没有重复出现的房源 ID。售价、租金和估价绝不共用同一个价格列。

爬虫拿到 HTTP 200,照样可能返回垃圾数据。以下检查可以发现最常见的问题:

检查项

规则

失败时的处理

必填字段

每行都有地址、价格和来源 URL

暂停本次运行并检查这些行

价格合理性

价格大于零,处于该市场的正常区间,且已设置币种

标记这些行;检查是否少了一位数字,或者售价列中混入了租金

租金周期

每个出租房源都有周期(月或周)

加载前修复解析器

行数

接近网站针对该搜索显示的总数

检查是否存在分页上限或遗漏的页面

重复 ID

单次运行中没有重复的房源 ID

修复去重逻辑;检查房源 ID 是否在不同页面之间变化

字段漂移

每个字段的空值占比与上次运行接近

与上次运行对比;突然跳升说明某个选择器失效了

过期页面

已售和已下架的房源被正确标记,而不是计为在售

更新状态并保留历史记录

解析器失效

零行,或行数突然下降

发出告警,并继续使用昨天的数据

其中几项检查可以写成一个简短的 pandas 脚本,在每次爬取后运行:

import pandas as pd

df = pd.read_csv("listings.csv")
problems = []

missing = df[df[["address", "price", "url"]].isna().any(axis=1)]
if len(missing):
    problems.append(f"{len(missing)} rows missing address, price, or URL")

sale = df[df["price_type"] == "asking_price"]
if not sale["price"].between(10_000, 50_000_000).all():
    problems.append("sale price outside the plausible range")

rent = df[df["price_type"] == "asking_rent"]
if rent["rent_period"].isna().any():
    problems.append("rental without a rent period")

if df["listing_id"].duplicated().any():
    problems.append("duplicate listing IDs")

null_share = df.isna().mean().round(2)
print(null_share[null_share > 0].to_dict())
print("OK" if not problems else problems)

示例数据的输出:

{'rent_period': 0.8}
OK

只有 rent_period 存在空值,这在意料之中:五个房源中有四个是待售房源。把每次运行的空值占比保存下来,这样字段漂移检查才有对比的基准。

应该使用哪些房地产抓取工具?

根据你愿意自己承担多少运维工作来选择。requests 和 BeautifulSoup 等 Python 库适合静态页面。Playwright 可以处理依赖 JavaScript 的网站。抓取 API 提供渲染、代理和地理定位,无需自建基础设施。数据 API 或数据集直接提供结构化房源,无需解析器。MLS 数据源仍然是授权房源数据的权威来源。

类别

示例

你需要维护的部分

最适合

局限

HTTP + 解析库

requests、httpx、BeautifulSoup、lxml

全部:抓取、解析、调度、代理

静态页面、小型且被允许的任务

不支持 JavaScript;封禁问题需自行处理

浏览器自动化

Playwright、Selenium

浏览器、解析、服务器

依赖 JavaScript 的页面

单页速度更慢、资源消耗更大

抓取 API

WebScrapingAPI Scraper API 及同类服务

解析和调度

以任意规模获取被允许网站的渲染页面

解析器仍需你自己编写和修复

数据 API 和数据集

WebScrapingAPI Data API(Zillow、Redfin、Zoopla)及其他供应商

除了你自己的数据管道,无需维护其他部分

无需解析器团队即可获得结构化房源记录

覆盖范围和字段由供应商决定;按报价

无代码工具

可视化点选式爬虫和浏览器扩展

点选式配置

非开发人员的一次性采集

难以进行版本管理、测试和扩展

MLS 数据源和聚合商

通过 MLS 或认可供应商获取的 RESO Web API

集成和展示合规

权威的房源和成交数据

授权、协议、展示规则

如果你需要大规模的 Zillow 房源,由专业团队维护的 Zillow 数据源会由负责采集的供应商为你提供结构化记录,你不必自己构建、自己维护爬虫。但这并不会改变 Zillow 条款中关于你自行抓取的规定。如果你正在权衡自己写代码还是使用服务,请阅读自建还是购买爬虫。

哪里可以找到针对特定房地产网站的指南?

我们为多个房产网站准备了分步指南,每篇都附有相应的法律说明:

FAQ

抓取房地产网站合法吗?

这取决于网站、数据和你所在的国家。许多大型门户在使用条款中禁止抓取,房源照片也通常受版权保护。从允许自动访问的网站采集公开的非个人数据,风险要低得多。如果条款禁止抓取,请使用开放数据或授权数据源,并咨询律师。

我可以抓取 Zillow 吗?

Zillow 的使用条款禁止自动化查询,包括屏幕抓取、爬虫和绕过验证码。如需了解市场趋势,可以下载 Zillow Research 数据,这是 Zillow 专门发布供你自行分析的数据。如需房源级记录,请使用授权数据供应商或 MLS 数据源。不要把业务建立在违反条款采集的数据之上。

最好的房地产爬虫是什么?

没有唯一最好的工具。对于你有权采集的静态页面,用 Python 加 requests 和 BeautifulSoup 就足够了。对于依赖 JavaScript 的页面,请使用 Playwright 或抓取 API。如果你不想维护解析器,房地产数据 API 或授权数据集可以直接提供结构化房源。

有免费的房地产数据 API 吗?

房源级别的免费 API 很少。免费资源大多是市场层面的:Realtor.com、Zillow 和 Redfin 发布可下载的住房数据文件,FHFA 发布其房价指数。房源级 API 和 MLS 数据源通常需要付费套餐、授权或与经纪公司的合作关系。重复使用数据前,请先核查每个来源的条款。

房源数据应该多久抓取一次?

根据数据变化的速度来安排频率。在活跃市场中,每天运行可以捕捉新房源、降价和状态变化。每周运行适合节奏较慢的市场和研究用途。做趋势分析的话,每月的开放数据文件就够了。只重新抓取发生变化的房源,以减轻来源网站的负担。

抓取房地产网站需要代理吗?

如果是在允许采集的网站上进行小规模、有节制的任务,就不需要。大规模运行往往需要代理、JavaScript 渲染和特定地区的结果,而抓取 API 把这些功能打包在一起。代理并不会改变网站的使用条款。如果某个门户禁止抓取,通过轮换 IP 规避封禁只会增加法律风险。

如何大规模采集房产图片?

除非你拥有照片的权利,否则只保存图片 URL 和元数据,不要保存副本。房源照片通常属于摄影师、经纪人或 MLS,而不是门户网站。如需获得授权的再利用,请通过 MLS 或 IDX 数据源,或通过授权范围涵盖展示用途的数据供应商获取图片,并保留署名。

应该选择哪种方案?

从你需要回答的问题出发,而不是从爬虫出发:

  • 市场层面的数字,例如价格中位数、在售房源量和挂牌天数:下载开放数据。
  • 大规模的房源级记录:购买 MLS 数据源授权或使用数据 API。
  • 你自己的网站、合作伙伴的网站以及允许访问的公共来源:使用抓取 API 或本指南中的 Python 爬虫。

无论选择哪条路,都要把三个数据层分开,为每个价格标注类型,并在每次加载数据时运行质量检查。

无需维护爬虫,即可获取房产数据

需要你所在市场的房源记录、价格和历史数据?告诉我们数据来源和字段,我们会为你规划数据源方案。探索房地产数据

要从你自己的网站或允许访问的网站采集数据?立即开始使用 Scraper API,免费获得 5,000 次请求。JavaScript 渲染和地理定位不额外消耗积分,失败的请求不计费。查看 Scraper API 套餐

关于作者

Robert Munceanu, 全栈开发工程师 @ WebScrapingAPI

Robert Munceanu

全栈开发工程师

罗伯特·蒙塞阿努(Robert Munceanu)是 WebScrapingAPI 的全栈开发工程师,他在产品各领域均有贡献,并协助构建了支持该平台的可靠工具和功能。

开始构建

准备好扩展您的数据收集规模了吗?

加入2,000多家企业,使用WebScrapingAPI在无需任何基础设施开销的情况下,以企业级规模提取网络数据。