Trulia是美国最繁忙的房地产市场平台之一,其搜索结果包含驱动价格追踪、市场研究和投资分析所需的精确结构化数据:要价、卧室数、浴室数、建筑面积、街道地址,以及每处房产详情页的链接。对于关注当地市场的任何人而言,这些房产列表页面是原始素材。问题在于,Trulia在客户端渲染其结果并对自动化流量严密防御,因此普通HTTP请求返回的是接近空白的外壳,而非您想要的房产列表。

本指南将向您展示用Python可靠地抓取Trulia的方法。您将构建一个小型可运行的爬虫,通过Crawling API获取渲染后的搜索结果页面,用BeautifulSoup解析每个房产列表,处理分页,并将数据导出为JSON和CSV格式。整个演练范围限定于公开房产列表,文末的合法性部分不只是样板文字,在您将其应用于任何实际规模之前请认真阅读。

您将构建什么

一个Python脚本,接受一个公开的Trulia搜索URL(例如,洛杉矶待售房产),通过Crawling API获取渲染后的HTML,并为页面上每个房产列表提取结构化记录。我们从每张房产卡片中提取以下字段:

  • 价格 房产列表上显示的要价。
  • 地址 房产的街道地址。
  • 卧室数 卧室的数量。
  • 浴室数 浴室的数量。
  • 面积 以平方英尺为单位的建筑面积。
  • 链接 房产详情页的URL。

为什么普通请求在Trulia上会失败

如果您用普通HTTP客户端请求一个Trulia搜索URL,您会得到状态200的响应,但响应体中几乎没有房产列表数据。有两个原因对您不利。首先,Trulia在浏览器中用JavaScript渲染大部分搜索结果,因此初始HTML是一个薄薄的外壳,只有页面脚本运行后才会填充。其次,网站迅速标记自动化流量:不像真实浏览器的数据中心IP和请求模式,在到达渲染列表之前就会受到挑战、被限速或被要求通过CAPTCHA。

因此,一个有效的Trulia爬虫在一次请求中需要两样东西:一个真正渲染页面的浏览器,以及一个平台视为真实访客的IP。您可以自己用无头浏览器加上轮换住宅代理池来实现,但将这些组合在一起并保持健康运行才是大部分工作量所在。Crawling API将两者折叠进一次调用:您发送带有JavaScript令牌的URL,它在受信任的IP背后渲染页面,并返回完整HTML供您解析。

为什么需要JS令牌

Crawlbase提供两种令牌类型。普通令牌获取静态HTML;JavaScript(JS)令牌先在真实浏览器中渲染页面。Trulia在客户端填充其房产卡片,因此这里需要使用JS令牌。使用普通令牌返回的是与普通请求相同的空白外壳,无法从中解析出任何有用内容。

前提条件

在编写任何代码之前,您需要准备几样东西,每样都不费多少时间。

Python基础。 您应该能够编写和运行Python脚本,并用pip安装包。如果您是Python新手,Python网络爬虫指南涵盖了本教程所假设的基础知识。

Python 3.8或更高版本。python --version确认版本,用pip --version确认pip存在。如果没有Python,请根据您的操作系统从python.org安装。

Crawlbase账号和JS令牌。 注册后获得您的前1,000次请求,打开仪表盘,从账号文档页面复制您的JavaScript(JS)令牌。请像对待密码一样对待令牌:它用于验证您的请求,所以不要将其放入版本控制。

搭建项目

创建虚拟环境以隔离项目依赖,然后安装爬虫所需的三个库。

bash
python --version

python -m venv trulia_env
source trulia_env/bin/activate

pip install crawlbase beautifulsoup4 pandas

在Windows上,用trulia_env\Scripts\activate代替source命令激活环境。三个依赖各司其职:crawlbase是Crawling API的官方客户端,beautifulsoup4通过CSS选择器从返回的HTML中提取字段,pandas在最后处理CSV导出。如果您之前没用过这个解析器,BeautifulSoup指南是本教程的好伴侣。

第1步:获取渲染后的搜索页面

首先获取完整页面。导入CrawlingAPI类,用您的JS令牌初始化,然后请求搜索URL。由于Trulia异步加载其卡片,传入ajax_waitpage_wait让API等到房产列表出现。在解析前检查状态,让失败可见而非静默。

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})

options = {"ajax_wait": "true", "page_wait": 8000}

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. pc_status: {response['headers']['pc_status']}")
    return None

if __name__ == "__main__":
    search_url = "https://www.trulia.com/CA/Los_Angeles/"
    html = crawl(search_url)
    print(html[:500] if html else "No HTML returned")

两个等待选项对于这种客户端渲染目标至关重要。ajax_wait指示API等待异步内容加载完毕,page_wait在加载后固定等待若干毫秒,让延迟渲染的卡片在页面被捕获之前出现。对于Trulia,8秒是合理的起点;如果房产列表返回为空,可以适当增加。Crawling API返回反映爬取结果的pc_status头部,因此检查它而非原始HTTP状态码。用python trulia_scraper.py运行脚本,您应该看到真实的房产列表标记,而非普通请求返回的空白外壳。这在您编写任何选择器之前就能确认渲染正常工作。

Crawlbase Crawling API

Trulia需要在受信任IP背后渲染的页面,一次调用完成,而您刚刚设置的ajax_waitpage_wait选项正是等待其客户端加载的方式。Crawling API接受JS令牌,在真实浏览器中运行页面,在服务端轮换住宅IP,并将完整HTML交给您,让您无需自己维护无头浏览器集群和代理池。先在免费层对一个公开搜索页面试试。

第2步:收集房产列表卡片

在提取单个字段之前,您需要先获取页面上的房产卡片集合。在Trulia上,每个房产列表都位于li元素内,而所有这些li元素都位于带有data-testid="search-result-list-container"属性的ul中。选取该容器的直接子元素,每个子元素对应一处房产。

python
from bs4 import BeautifulSoup

def get_listings(html):
    soup = BeautifulSoup(html, "html.parser")
    return soup.select('ul[data-testid="search-result-list-container"] > li')

这返回一个卡片元素列表。每个卡片都是一个独立的作用域,您可以在其中查询该房产的价格、地址等信息,从而使每个字段的选择器保持简单,并避免在房产之间混淆数据。

第3步:从每张卡片解析字段

手握卡片,通过其data-testid属性提取每个字段。Trulia在各个房产列表中对这些属性保持一致,因此它们比视觉类名更稳定。对每次查找进行包装,使缺失元素返回None而非抛出异常,因为并非每个房产列表都包含每个字段(例如,纯土地房产可能没有卧室或浴室数量)。

python
def text_at(listing, selector):
    el = listing.select_one(selector)
    return el.get_text(strip=True) if el else None

def parse_listing(listing):
    link_el = listing.select_one('a[data-testid="property-card-link"]')
    link = "https://www.trulia.com" + link_el["href"] if link_el else None

    return {
        "price": text_at(listing, 'div[data-testid="property-price"]'),
        "address": text_at(listing, 'div[data-testid="property-address"]'),
        "beds": text_at(listing, 'div[data-testid="property-beds"]'),
        "baths": text_at(listing, 'div[data-testid="property-baths"]'),
        "size": text_at(listing, 'div[data-testid="property-floorSpace"]'),
        "link": link,
    }

text_at辅助函数处理重复性工作:查询一个元素并返回其去除空白的文本,当元素不存在时返回None,确保一个缺失字段不会导致整个运行崩溃。价格在property-price中,街道地址在property-address中,卧室和浴室分别在property-bedsproperty-baths中,建筑面积在property-floorSpace中。详情页链接位于带data-testid="property-card-link"a元素上,由于该href是相对路径,您需要添加Trulia的域名前缀以获得绝对URL。

选择器会漂移

Trulia的data-testid值目前是稳定的,但不保证永远如此。当所有卡片的某个字段都返回None时,在浏览器开发者工具中重新检查实时房产列表并更新选择器。定期进行选择器维护是任何生产级爬虫的正常操作,并不意味着出了问题。

第4步:组装完整脚本

现在将获取、卡片收集和字段解析整合为一个可运行的脚本。获取渲染后的HTML,遍历卡片,将每张卡片解析为一条记录,并以JSON格式打印结果。

python
import json
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})
options = {"ajax_wait": "true", "page_wait": 8000}

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. pc_status: {response['headers']['pc_status']}")
    return None

def get_listings(html):
    soup = BeautifulSoup(html, "html.parser")
    return soup.select('ul[data-testid="search-result-list-container"] > li')

def text_at(listing, selector):
    el = listing.select_one(selector)
    return el.get_text(strip=True) if el else None

def parse_listing(listing):
    link_el = listing.select_one('a[data-testid="property-card-link"]')
    link = "https://www.trulia.com" + link_el["href"] if link_el else None
    return {
        "price": text_at(listing, 'div[data-testid="property-price"]'),
        "address": text_at(listing, 'div[data-testid="property-address"]'),
        "beds": text_at(listing, 'div[data-testid="property-beds"]'),
        "baths": text_at(listing, 'div[data-testid="property-baths"]'),
        "size": text_at(listing, 'div[data-testid="property-floorSpace"]'),
        "link": link,
    }

def main():
    search_url = "https://www.trulia.com/CA/Los_Angeles/"
    html = crawl(search_url)
    if not html:
        return

    listings = get_listings(html)
    results = [parse_listing(li) for li in listings]
    print(json.dumps(results, indent=2))

if __name__ == "__main__":
    main()

输出示例

python trulia_scraper.py运行完整脚本,您将得到一个干净的结构化记录列表,每页每个房产对应一条,可随时写入JSON、CSV或数据库。

json
[
  {
    "price": "$4,750,000",
    "address": "9240 W National Blvd, Los Angeles, CA 90034",
    "beds": "9bd",
    "baths": "9ba",
    "size": "6,045 sqft",
    "link": "https://www.trulia.com/p/ca/los-angeles/..."
  },
  {
    "price": "$1,499,999",
    "address": "245 Windward Ave, Venice, CA 90291",
    "beds": "4bd",
    "baths": "3ba",
    "size": "1,332 sqft",
    "link": "https://www.trulia.com/p/ca/venice/..."
  }
]

数据缺失的房产列表在相应字段返回null而非失败,这就是为什么纯土地或期房列表可能没有卧室、浴室或面积信息。这是预期行为,下游代码应将任何字段视为可选的。

处理分页和导出数据

一页只是演示;真实的工作需要覆盖整个城市。Trulia使用基于路径的分页方案:在搜索URL后附加连续的页面段,第一页是/1_p/,第二页是/2_p/,依此类推。递增这个数字即可遍历结果集,并在每一页上复用相同的crawl和解析函数。

python
import json
import time
import pandas as pd

def scrape_pages(base_url, num_pages):
    results = []
    for page in range(1, num_pages + 1):
        page_url = f"{base_url}/{page}_p/"
        html = crawl(page_url)
        if not html:
            print(f"Skipping page {page}: no HTML.")
            continue
        listings = get_listings(html)
        if not listings:
            break
        results.extend(parse_listing(li) for li in listings)
        time.sleep(2)
    return results

def export(results):
    with open("trulia_listings.json", "w") as f:
        json.dump(results, f, indent=2)
    pd.DataFrame(results).to_csv("trulia_listings.csv", index=False)
    print(f"Saved {len(results)} listings to JSON and CSV.")

if __name__ == "__main__":
    base = "https://www.trulia.com/CA/Los_Angeles"
    data = scrape_pages(base, num_pages=3)
    export(data)

页面之间的time.sleep(2)是刻意为之:它控制了运行节奏,使您不会大量请求网站,这是保持不被封锁的最有效习惯。当某页返回无卡片时,循环也会提前停止,确保您不会在最后一页结果之后继续请求。export函数同时写入trulia_listings.jsontrulia_listings.csv;pandas将字典列表转换为扁平表格,每个字段成为一列。根据您的目标市场调整页数和base中的城市标识符。

保持不被封锁

即使处理了渲染,Trulia仍会监控爬虫形态的流量。以下几个习惯能让抓取运行保持健康,它们适用于任何防御严密的商业目标。

  • 控制请求节奏。 紧密循环连续请求页面是最快被限速或触发CAPTCHA的方式。如上述sleep所示,分散请求,避免全速爬取同一路径。
  • 依赖轮换。 住宅IP池将请求分散到众多真实用户地址上,使单个地址不会触发速率限制。Crawling API为您处理这一切;如果您自己搭建,这是最需要做对的部分。
  • 关注状态码。 运行开始返回挑战或非200的pc_status值时,说明当前速率或IP级别已不够用。将此视为退缩的信号,而非可以忽略的噪音。

更广泛的操作手册请参阅如何在不被封锁的情况下抓取网站。如果您的目标网站大量使用JavaScript,JavaScript网站爬取指南更深入地介绍了渲染方面的内容。

抓取Trulia合法吗?

抓取Trulia是否被允许,取决于Trulia的服务条款、您所在的司法管辖区以及您对数据的使用方式。Trulia的条款限制自动化访问,因此无论您的工具多么谨慎,抓取行为都可能违反这些条款。这里的代码并不改变这一点,它只是让技术部分得以实现。请阅读Trulia的服务条款及其robots.txt,遵守其速率预期,将两者都视为您采集范围的边界。

有几条值得坚守的底线。仅采集公开房产列表数据:任何人无需账号即可在搜索或房产页面上看到的价格、地址、卧室数、浴室数、建筑面积和房产链接。避免与可识别个人相关的任何信息,包括卡片上显示的经纪人、中介或业主的联系方式,这些超出本指南涵盖的公开列表范围。房地产行业特有一个值得关注的细节:Trulia等网站上的大量底层房产数据源自多重上市服务(MLS)信息流,该信息流通常是获得许可的,具有自己的使用限制。大批量重新发布这些数据可能会触犯这些许可,即使页面本身是公开的。

本指南有意限定在公开搜索和房产列表页面范围内,因为这是保持工作可辩护的边界。它不涵盖登录后的任何内容、已保存的搜索或账号数据、个人联系方式,或任何绕过身份验证的尝试。如果您的项目需要超出公开列表字段的数据,正确的路径是获得许可的房地产数据信息流或正式协议,而不是更聪明的爬虫。如果网站提供官方API或数据合作,请优先选择;这样既能获得更干净的数据,同时也有明确的使用许可。

回顾

核心要点

  • Trulia是客户端渲染的。 普通请求返回空白外壳,因此您必须在解析之前先渲染页面。
  • 您需要同时具备渲染能力和受信任的IP。 使用JS令牌的Crawling API一次调用完成两者;ajax_waitpage_wait控制它等待卡片加载的时间。
  • 以稳定属性为目标。 Trulia的data-testid值(property-priceproperty-addressproperty-bedsproperty-bathsproperty-floorSpace)驱动每个字段的提取,每张卡片对应一个li
  • 通过路径分页并导出两种格式。 Trulia使用/N_p/页面段;循环遍历它们,解析每张卡片,并用pandas将结果写入JSON和CSV。
  • 坚守公开数据。 遵守Trulia的服务条款和robots.txt,仅采集公开列表字段,注意MLS数据通常是获得许可的,永远不要涉及账号、登录或个人联系方式。

常见问题

为什么普通请求从Trulia获取不到数据?

因为Trulia在客户端使用JavaScript渲染其搜索结果。初始HTML是一个外壳,只有页面脚本在浏览器中运行后才会填充,因此原始HTTP请求返回状态200,但价格、卧室数、浴室数和地址字段均为空。要获取真实数据,您必须先渲染页面,这正是Crawling API的JS令牌为您处理的工作。

Trulia需要普通令牌还是JS令牌?

JS令牌。普通令牌获取静态HTML,在Trulia上这与普通请求返回的空白外壳相同。JS令牌在将HTML交还之前先在真实浏览器中渲染页面,因此BeautifulSoup解析时房产卡片是存在的。

我可以从Trulia房产列表中抓取哪些数据?

公开列表字段:要价、街道地址、卧室和浴室数量、以平方英尺为单位的建筑面积,以及详情页链接。限于任何访客无需账号即可看到的数据,避免经纪人、中介或业主的个人联系方式,这些超出本指南涵盖的公开列表范围。

Trulia的分页是如何工作的?

Trulia使用基于路径的方案,在搜索URL后附加连续的页面段:第一页是/1_p/,第二页是/2_p/,依此类推。上述scrape_pages函数循环该数字,通过Crawling API获取每一页,解析卡片,并在某页无房产列表时停止。

我的选择器在所有卡片上都返回None。发生了什么变化?

几乎可以肯定是Trulia的标记发生了变化。该爬虫所针对的data-testid值可能在无通知的情况下发生变更,因此上个月还能用的选择器可能已经失效。在浏览器开发者工具中重新检查实时房产列表并更新选择器。定期进行选择器维护是任何生产级爬虫的正常操作。

如何以相同方式抓取其他房地产网站?

相同的模式可以迁移:渲染页面,收集房产列表卡片,将每个公开字段映射到选择器。具体实现因网站而异,请参阅关于如何抓取Zillow如何抓取Realtor.com的配套指南,或以租房为重点的Apartments.com演练,这些都复用了完全相同的获取和解析结构。

开始构建

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

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

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