in

如何爬取 Realtor.com 的房地产数据

如何爬取 Realtor.com 的房地产数据

在本网络抓取教程中,我们将了解如何抓取Realtor.com——美国最大的房地产市场。 在本指南中,我们将抓取 Realtor.com 房产页面上显示的价格信息、地址、照片和电话号码等房地产数据。 Realtor.com 很容易被抓取,在本指南中,我们将利用其隐藏的网络数据系统快速抓取整个房地产数据集。我们还将了解如何使用搜索系统查找特定区域的所有房产列表。 最后,我们还将介绍跟踪以抓取新上市或出售的房产或更新价格的房产 – 让我们在房地产竞标中占据上风! 我们将使用 Python 和一些社区包,这将使这个网络抓取工具变得轻而易举。让我们开始吧!

为什么要抓取 Realtor.com?

Realtor.com 是美国最大的房地产网站之一,使其成为最大的公共房地产数据集。包含房地产价格、挂牌地点和销售日期以及一般财产信息等字段。 这对于市场分析、住宅行业研究和竞争对手的总体概况来说是有价值的信息。

项目设置

在本教程中,我们将使用带有两个社区包的 Python:

  • httpx – HTTP 客户端库,可以让我们与 Realtor.com 的服务器进行通信
  • parsel – HTML 解析库,它将帮助我们解析网络抓取的 HTML 文件。

这些包可以通过pip install命令轻松安装:

$ pip install httpx parsel

或者,可以随意换成httpx任何其他 HTTP 客户端包,例如requests,因为我们只需要基本的 HTTP 功能,这些功能几乎可以在每个库中互换。至于,parsel另一个很好的选择是beautifulsoup包。

抓取属性数据

让我们深入了解如何抓取 realtor.com 上列出的单个房产的数据。然后,我们还将看看如何找到这些属性并扩展我们的抓取工具。 例如,让我们先看一下列表页面,看看页面上存储的所有信息在哪里。让我们选择一个随机的属性列表,例如: realtor.com/realestateandhomes-detail/149-3rd-Ave_San-Francisco_CA_94118_M16017-14990 我们可以看到该页面包含大量数据,使用CSS 选择器XPath 选择器解析所有内容将是一项繁重的工作。相反,让我们看看这个页面的页面源代码。 如果我们在页面源中查找一些唯一标识符(如房地产经纪人的电话号码或地址),我们可以看到该页面包含隐藏的 Web 数据,该数据包含整个属性数据集:

realtor.com 页面源代码中隐藏数据的插图
我们可以看到隐藏在脚本元素中的整个属性数据集

让我们看看如何使用 Python 抓取它。我们将检索属性 HTML 页面,找到<script>包含隐藏 Web 数据的 并将其解析为 JSON 文档:

import asyncio
import json
from typing import List

import httpx
from parsel import Selector
from typing_extensions import TypedDict

# First, we sneed to establish a persisten HTTPX session 
# with browser-like headers to avoid instant blocking
BASE_HEADERS = {
    "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36",
    "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
    "accept-language": "en-US;en;q=0.9",
    "accept-encoding": "gzip, deflate, br",
}
session = httpx.AsyncClient(headers=BASE_HEADERS)

# type hints fo expected results - property listing has a lot of data!
class PropertyResult(TypedDict):
    property_id: str
    listing_id: str
    href: str
    status: str
    list_price: int
    list_date: str
    ...  # and much more!


def parse_property(response: httpx.Response) -> PropertyResult:
    """parse Realtor.com property page"""
    # load response's HTML tree for parsing:
    selector = Selector(text=response.text)
    # find <script id="__NEXT_DATA__"> node and select it's text:
    data = selector.css("script#__NEXT_DATA__::text").get()
    if not data:
        print(f"page {response.url} is not a property listing page")
        return
    # load JSON as python dictionary and select property value:
    data = json.loads(data)

    return data["props"]["pageProps"]["initialProps"]["property"]


async def scrape_properties(urls: List[str]) -> List[PropertyResult]:
    """Scrape Realtor.com properties"""
    properties = []
    to_scrape = [session.get(url) for url in urls]
    # tip: asyncio.as_completed allows concurrent scraping - super fast!
    for response in asyncio.as_completed(to_scrape):
        response = await response
        if response.status_code != 200:
            print(f"can't scrape property: {response.url}")
            continue
        properties.append(parse_property(response))
    return properties
运行代码和示例输出
async def run():
    # some realtor.com property urls
    urls = [
        "https://www.realtor.com/realestateandhomes-detail/12355-Attlee-Dr_Houston_TX_77077_M70330-35605"
    ]
    results = await scrape_properties(urls)
    print(json.dumps(results, indent=2))


if __name__ == "__main__":
    asyncio.run(run())
生成的数据集太大,无法全部嵌入到本文中

我们可以看到,我们的简单抓取程序收到了一个广泛的财产数据集,其中包含我们在页面上看到的所有内容,如财产价格、地址、照片和房地产经纪人的电话号码,以及页面上不可见的元信息字段。 现在我们知道如何抓取单个属性,让我们看看接下来如何找到要抓取的属性!

查找 Realtor.com 属性

有多种方法可以在 Realtor.com 上查找房产,但最简单、最可靠的方法是使用他们的搜索系统。让我们来看看 Realtor.com 的搜索是如何工作的,以及我们如何抓取它。 如果我们在 Realtor 的搜索栏中输入一个位置,我们可以看到一些重要的信息:

realtor.com 搜索页面的屏幕截图
搜索页面包含元数据,例如总页数和总结果

搜索会自动将我们重定向到包含属性列表和分页元数据(该区域有多少列表可用)的结果 URL。如果我们进一步点击第二页,我们可以看到一个清晰的 URL 模式,我们可以在我们的爬虫中使用它:

realtor.com/realestateandhomes-search/<CITY>_<STATE>/pg-<PAGE>

知道了这一点,我们就可以编写我们的抓取工具,从给定的地理位置变量(城市和州)中抓取所有财产清单:

import asyncio
import json
import math
from typing import List, Optional

import httpx
from parsel import Selector
from typing_extensions import TypedDict

# 1. Establish persisten HTTPX session with browser-like headers to avoid blocking
BASE_HEADERS = {
    "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36",
    "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
    "accept-language": "en-US;en;q=0.9",
    "accept-encoding": "gzip, deflate, br",
}
session = httpx.AsyncClient(headers=BASE_HEADERS, follow_redirects=True)

...

# Type hints for search results
# note: the property preview contains a lot of data though not the whole dataset
class PropertyPreviewResult(TypedDict):
    property_id: str
    listing_id: str
    permalink: str
    list_price: int
    price_reduces_amount: Optional[int]
    description: dict
    location: dict
    photos: List[dict]
    list_date: str
    last_update_date: str
    tags: List[str]
    ...  # and more

# Type hint for search results of a single page
class SearchResults(TypedDict):
    count: int  # results on this page
    total: int  # total results for all pages
    results: List[PropertyPreviewResult]


def parse_search(response: httpx.Response) -> SearchResults:
    """Parse Realtor.com search for hidden search result data"""
    selector = Selector(text=response.text)
    data = selector.css("script#__NEXT_DATA__::text").get()
    if not data:
        print(f"page {response.url} is not a property listing page")
        return
    data = json.loads(data)
    return data["props"]["pageProps"]["searchResults"]["home_search"]


async def find_properties(state: str, city: str):
    """Scrape Realtor.com search for property preview data"""
    print(f"scraping first result page for {city}, {state}")
    first_page = f"https://www.realtor.com/realestateandhomes-search/{city}_{state.upper()}/pg-1"
    first_result = await session.get(first_page)
    first_data = parse_search(first_result)
    results = first_data["results"]

    total_pages = math.ceil(first_data["total"] / first_data["count"])
    print(f"found {total_pages} total pages ({first_data['total']} total properties)")
    to_scrape = []
    for page in range(1, total_pages + 1):
        assert "pg-1" in str(first_result.url)  # make sure we don't accidently scrape duplicate pages
        page_url = str(first_result.url).replace("pg-1", f"pg-{page}")
        to_scrape.append(session.get(page_url))
    for response in asyncio.as_completed(to_scrape):
        parsed = parse_search(await response)
        results.extend(parsed["results"])
    print(f"scraped search of {len(results)} results for {city}, {state}")
    return results
运行代码和示例输出
async def run():
    results_search = await find_properties("CA", "San-Francisco")
    print(json.dumps(results_search, indent=2))


if __name__ == "__main__":
    asyncio.run(run())
将生成属性预览项目列表:

[
    {
        "property_id": "1601714990",
        "list_price": 4780000,
        "primary": true,
        "primary_photo": { "href": "https://www.jingzhengli.com/wp-content/uploads/2023/06/3cbf77fa7d09fc28cc037c55ddbfe875l-m4228927009s-1.jpg" },
        "source": { "id": "SFCA", "agents": [ { "office_name": null } ], "type": "mls", "spec_id": null, "plan_id": null },
        "community": null,
        "products": { "brand_name": "basic_opt_in", "products": [ "co_broke" ] },
        "listing_id": "2949512103",
        "matterport": false,
        "virtual_tours": null,
        "status": "for_sale",
        "permalink": "149-3rd-Ave_San-Francisco_CA_94118_M16017-14990",
        "price_reduced_amount": null,
        "other_listings": { "rdc": [ { "listing_id": "2949512103", "status": "for_sale", "listing_key": null, "primary": true } ] },
        "description": {
            "beds": 4,
            "baths": 5,
            "baths_full": 4,
            "baths_half": 1,
            "baths_1qtr": null,
            "baths_3qtr": null,
            "garage": null,
            "stories": null,
            "type": "single_family",
            "sub_type": null,
            "lot_sqft": 3000,
            "sqft": 2748,
            "year_built": 1902,
            "sold_price": 1825000,
            "sold_date": "2021-03-29",
            "name": null
        },
        "location": {
            "street_view_url": "https://www.jingzhengli.com/wp-content/uploads/2023/06/3cbf77fa7d09fc28cc037c55ddbfe875l-m4228927009s.jpg"
            },
            {
                "href": "https://www.jingzhengli.com/wp-content/uploads/2023/06/3cbf77fa7d09fc28cc037c55ddbfe875l-m3453981500s.jpg"
            }
        ],
        "tags": [
            "central_air",
            "dishwasher",
            "family_room",
            "fireplace",
            "hardwood_floors",
            "laundry_room",
            "garage_1_or_more",
            "basement",
            "two_or_more_stories",
            "big_yard",
            "open_floor_plan",
            "floor_plan",
            "wine_cellar",
            "ensuite",
            "lake"
        ],
        "branding": [ { "type": "Office", "photo": null, "name": "Marcus & Millichap" }
        ],
        "home_photos": {
            "collection": [
                {
                    "href": "https://www.jingzhengli.com/wp-content/uploads/2023/06/3cbf77fa7d09fc28cc037c55ddbfe875l-m4228927009s-1.jpg"
                },
                {
                    "href": "https://www.jingzhengli.com/wp-content/uploads/2023/06/3cbf77fa7d09fc28cc037c55ddbfe875l-m3453981500s.jpg"
                }
            ],
            "count": 2
        }
    },
    ...
]

上面,我们的抓取器首先抓取第一页以获取结果以及此查询中总共有多少页。然后,它会同时抓取剩余的页面并返回属性 URL 列表。

跟随 Realtor.com 列表更改

Realtor.com 提供多种 RSS 提要,用于公布所列房产的变化,例如:

如果我们想跟踪房地产市场的事件,这些都是很好的资源。我们可以实时观察楼价变化、新盘和销售! 让我们来看看我们如何为这些提要编写一个跟踪器抓取器,以便定期抓取它们。 这些提要中的每一个都按美国各州划分,它是一个简单的 RSS XML 文件,其中包含公告和日期。例如,让我们看一下加利福尼亚的价格变化提要

<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title>Price Changed</title>
    <link>https://www.realtor.com</link>
    <description/>
    <atom:link href="https://pubsubhubbub.appspot.com/" rel="hub"/>
    <atom:link href="https://www.realtor.com/realestateandhomes-detail/sitemap-rss-price/rss-price-ca.xml" rel="self"/>
    <item>
      <link>https://www.realtor.com/realestateandhomes-detail/1801-Wedemeyer-St_San-Francisco_CA_94129_M94599-53650</link>
      <pubDate>Fri, 04 Nov 2022 08:54:48</pubDate>
    </item>
    <item>
      <link>https://www.realtor.com/realestateandhomes-detail/24650-Amador-St_Hayward_CA_94544_M96649-53504</link>
      <pubDate>Fri, 04 Nov 2022 08:55:03</pubDate>
    </item>
...

我们可以看到它包含指向属性和价格更改日期的链接。因此,要编写我们的跟踪器爬虫,我们所要做的就是:

  1. 每 X 秒抓取一次提要
  2. 解析<link>属性 URL 的元素
  3. 使用我们的财产抓取工具收集数据集
  4. 保存数据(到数据库或文件)并重复#1

让我们看看这在 Python 中会是什么样子。我们将每 5 分钟抓取一次此提要并将结果附加到 JSON 列表文件(每行 1 个 JSON 对象):

import asyncio
import json
import math
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from typing_extensions import TypedDict

import httpx
from parsel import Selector

...  # NOTE: include code from property scraping section

async def scrape_feed(url) -> Dict[str, datetime]:
    """scrapes atom RSS feed and returns all entries in "url:publish date" format"""
    response = await session.get(url)
    body = response.content.read()
    selector = Selector(text=body.decode(), type="xml")
    results = {}
    for item in selector.xpath("//item"):
        url = item.xpath("link/text()").get()
        pub_date = item.xpath("pubDate/text()").get()
        results[url] = datetime.strptime(pub_date, "%a, %d %b %Y %H:%M:%S")
    return results


async def track_feed(url: str, output: Path, interval: int = 60):
    """Track Realtor.com feed, scrape new listings and append them as JSON to the output file"""
    # to prevent duplicates let's keep a set of property IDs we already scraped
    seen = set()
    output.touch(exist_ok=True)  # create file if it doesn't exist
    try:
        while True:
            # scrape feed for listings
            listings = await scrape_feed(url=url)
            # remove listings we scraped in previous loops
            listings = {k: v for k, v in listings.items() if f"{k}:{v}" not in seen}
            if listings:
                # scrape properties and save to file - 1 property as JSON per line
                properties = await scrape_properties(list(listings.keys()))
                with output.open("a") as f:
                    f.write("\n".join(json.dumps(property) for property in properties))

                # add seen to deduplication filter
                for k, v in listings.items():
                    seen.add(f"{k}:{v}")
            print(f"scraped {len(properties)} properties; waiting {interval} seconds")
            await asyncio.sleep(interval)
    except KeyboardInterrupt:  # Note: CTRL+C will gracefully stop our scraper
        print("stopping price tracking")
运行代码和示例输出
async def run():
    # for example price feed for California
    feed_url = f"https://www.realtor.com/realestateandhomes-detail/sitemap-rss-price/rss-price-ca.xml"
    # or 
    await track_feed(feed_url, Path("track-pricing.jsonl"))

if __name__ == "__main__":
    asyncio.run(run())

在上面的示例中,我们编写了一个 RSS 提要爬虫来抓取 Realtor.com 公告。然后我们编写了一个无限循环的抓取工具,它将这个提要和完整的属性数据集抓取到一个 JSON 列表文件中。

常问问题

为了总结本指南,让我们看一下有关网络抓取 Realtor.com 数据的一些常见问题:

是的。Realtor.com 的数据是公开的;我们不会提取任何个人或私人信息。以缓慢、尊重的速度抓取 Realtor.com 属于道德抓取定义。 也就是说,在抓取非代理列表的个人数据(卖家姓名、电话号码等)时,应注意欧盟的 GDRP 合规性。

Realtor.com 是否有 API?

不,realtor.com 不提供财产数据的公共 API。然而,如本指南所示,使用 Realtro.com 的官方 RSS 提要,抓取房产数据和跟踪房产变化真的很容易。

如何抓取 Realtor.com?

像抓取一样,我们也可以通过跟踪每个属性页面上列出的相关租赁页面来抓取 realtor.com。为此,请参阅“抓取属性数据”部分relatedRentals中抓取的数据集中的字段

Realtor.com 爬取摘要

在本教程中,我们使用 Python 构建了一个Realtor.com爬虫。我们首先了解如何通过提取隐藏的 Web 数据来抓取单个属性页。 然后,我们了解了如何使用 Realtor.com 的搜索系统查找房产。我们根据给定的参数构建一个搜索 URL,并抓取查询分页中列出的所有列表。 最后,我们了解了如何通过抓取官方 RSS 提要来跟踪 realtor.com 上的变化,例如价格变化、销售和新上市公告。  

Written by 河小马

河小马是一位杰出的数字营销行业领袖,广告中国论坛的重要成员,其专业技能涵盖了PPC广告、域名停放、网站开发、联盟营销以及跨境电商咨询等多个领域。作为一位资深程序开发者,他不仅具备强大的技术能力,而且在出海网络营销方面拥有超过13年的经验。