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供您解析。
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)令牌。请像对待密码一样对待令牌:它用于验证您的请求,所以不要将其放入版本控制。
搭建项目
创建虚拟环境以隔离项目依赖,然后安装爬虫所需的三个库。
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_wait和page_wait让API等到房产列表出现。在解析前检查状态,让失败可见而非静默。
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运行脚本,您应该看到真实的房产列表标记,而非普通请求返回的空白外壳。这在您编写任何选择器之前就能确认渲染正常工作。
Trulia需要在受信任IP背后渲染的页面,一次调用完成,而您刚刚设置的ajax_wait加page_wait选项正是等待其客户端加载的方式。Crawling API接受JS令牌,在真实浏览器中运行页面,在服务端轮换住宅IP,并将完整HTML交给您,让您无需自己维护无头浏览器集群和代理池。先在免费层对一个公开搜索页面试试。
第2步:收集房产列表卡片
在提取单个字段之前,您需要先获取页面上的房产卡片集合。在Trulia上,每个房产列表都位于li元素内,而所有这些li元素都位于带有data-testid="search-result-list-container"属性的ul中。选取该容器的直接子元素,每个子元素对应一处房产。
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而非抛出异常,因为并非每个房产列表都包含每个字段(例如,纯土地房产可能没有卧室或浴室数量)。
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-beds和property-baths中,建筑面积在property-floorSpace中。详情页链接位于带data-testid="property-card-link"的a元素上,由于该href是相对路径,您需要添加Trulia的域名前缀以获得绝对URL。
Trulia的data-testid值目前是稳定的,但不保证永远如此。当所有卡片的某个字段都返回None时,在浏览器开发者工具中重新检查实时房产列表并更新选择器。定期进行选择器维护是任何生产级爬虫的正常操作,并不意味着出了问题。
第4步:组装完整脚本
现在将获取、卡片收集和字段解析整合为一个可运行的脚本。获取渲染后的HTML,遍历卡片,将每张卡片解析为一条记录,并以JSON格式打印结果。
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或数据库。
[ { "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和解析函数。
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.json和trulia_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_wait和page_wait控制它等待卡片加载的时间。 -
以稳定属性为目标。 Trulia的
data-testid值(property-price、property-address、property-beds、property-baths、property-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 次请求免费,无需信用卡。
