Houzz 是最大的家居设计、家具和装修平台之一,将深度产品目录与编辑灵感内容相结合。其公开产品和房源页面包含驱动价格追踪、竞争对手研究和趋势分析所需的精确结构化数据:产品名称、价格、评分和评论数、卖家或品牌、分类,以及返回各产品页面的链接。在一个含有数百种产品的类别中手动提取这些数据既慢又容易出错。

本指南将向您展示如何用 Python 以可靠的方式抓取 Houzz 数据。您将构建一个小型可运行的爬虫,通过 Crawling API 获取渲染后的 Houzz 页面,从类别列表中采集产品链接,用 BeautifulSoup 解析所需字段,处理分页,并导出整洁的 JSON 和 CSV 文件。整个演示仅限于公开产品数据的范围,文末的合法性部分并非样板文字,请在将此方法用于任何实际规模前先行阅读。

您将构建什么

一个 Python 脚本,接收 Houzz 的公开类别 URL,采集产品页面链接,通过 Crawling API 获取每个渲染后的页面,并为每件产品提取结构化记录。示例以浴室梳妆台和水槽柜类别为例,提取以下字段:

  • 名称:卡片和产品页面上显示的产品名称。
  • 价格:该产品的标价。
  • 评分:平均星级评分。
  • 评论数:该评分背后的顾客评论数量。
  • 卖家:提供该产品的卖家、商店或品牌。
  • 分类:该产品所属的类别。
  • 链接:产品页面的规范 URL。

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

如果您使用基础 HTTP 客户端请求 Houzz 的类别或产品 URL,会得到状态码 200 的响应,但正文中只有一小部分数据。有两个因素会阻碍您。首先,Houzz 在浏览器中通过 JavaScript 渲染其大部分产品网格和产品详情,因此初始 HTML 只是一个薄壳,只有在页面脚本运行后才会填充内容。从该第一次响应中提取产品卡片,您只能捕获少数几个,甚至一个都没有。其次,Houzz 能快速识别自动化流量:数据中心 IP 和不像真实浏览器的模式,在到达渲染内容之前就会被限速、IP 封锁或被挑战。

因此,一个可用的 Houzz 爬虫在单次请求中需要两件事:能够实际渲染页面的浏览器,以及让平台认为是真实访客的 IP。您可以自行组装无头浏览器和轮换住宅代理池,但维护这些工具才是大部分工作所在。Crawling API 将两者合并为一次调用:您发送带有 JavaScript 令牌的 URL,它在可信 IP 后面渲染页面,并返回完整的 HTML 供您解析。如果您对 JavaScript 密集型目标还不熟悉,爬取 JavaScript 网站的指南对原因有更深入的介绍。

为什么需要 JS 令牌

Crawlbase 提供两种令牌类型。普通令牌获取静态 HTML;JavaScript (JS) 令牌则先在真实浏览器中渲染页面。Houzz 在客户端填充其产品网格和产品字段,因此此处需要 JS 令牌。普通令牌返回的结果与普通请求一样,只是一个薄壳,可解析的有用内容很少。

前提条件

开始编写代码前,您需要准备几样东西,每样都不需要很长时间。

基础 Python 知识。您应能编写和运行 Python 脚本,并使用 pip 安装包。如果您是解析方面的新手,BeautifulSoup 指南是本教程的好伴侣。

Python 3.8 或更高版本。使用 python --version 确认版本。如果尚未安装,请从 python.org 安装或通过 Anaconda 等发行版安装,并确保 Python 在您的 PATH 中。

Crawlbase 账户和 JS 令牌。注册后,打开控制台,从账户文档页面复制您的 JavaScript (JS) 令牌。Crawlbase 提供 1,000 次免费请求供起步使用,跟随本指南已完全足够。像对待密码一样保管令牌:它用于验证您的请求身份,请勿将其提交到版本控制系统。

项目设置

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

bash
python --version

python -m venv houzz_env
source houzz_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,使用 houzz_env\Scripts\activate 代替 source 行来激活环境。两个依赖项各司其职:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML 以便通过 CSS 选择器提取各字段。jsoncsv 均随标准库一同提供,因此导出步骤无需额外安装。

第一步:获取渲染后的 Houzz 页面

首先获取完整的页面。导入 CrawlingAPI 类,用您的 JS 令牌初始化,然后请求 Houzz 类别 URL。Houzz 异步加载其网格,因此需传入 ajax_waitpage_wait,在动态内容加载完成后再捕获页面。在解析前检查 Crawlbase 的 pc_status,可以让失败情况明显可见而不是悄悄忽略。

python
from crawlbase import CrawlingAPI

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

OPTIONS = {
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/122.0",
    "ajax_wait": "true",
    "page_wait": 5000,
}

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

if __name__ == "__main__":
    listing_url = "https://www.houzz.com/products/bathroom-vanities-and-sink-consoles/best-sellers--best-sellers"
    html = crawl(listing_url)
    print(html[:500] if html else "No HTML returned")

这两个等待选项对于像 Houzz 这样的客户端渲染目标至关重要。ajax_wait 告诉 API 等待异步内容加载完成,page_wait 则在加载后持续等待固定毫秒数,以便延迟渲染的卡片在捕获前出现。5 秒是合理的起点;如果结果返回较少则可以适当增加。使用 python houzz_scraper.py 运行脚本,您应该能看到真实的 Houzz 类别标记,而不是普通请求返回的空壳。这确认了在编写任何选择器之前渲染能正常工作。

Crawlbase Crawling API

Houzz 需要在一次调用中获得可信 IP 后面的渲染页面,这正是上述 ajax_waitpage_wait 选项所配置的。Crawling API 接受 JS 令牌,在真实浏览器中运行页面,在服务端轮换住宅 IP,并将完整的 HTML 交给您,让您无需自行运行无头浏览器群和代理池。先在免费套餐上指向公开类别页面试试看。

第二步:从类别页面采集产品链接

Houzz 的类别页面是一个产品卡片网格,每张卡片链接到完整的产品页面。将渲染后的 HTML 加载到 BeautifulSoup 中,从每张卡片的标题链接中提取 href。Houzz 将这些嵌套在产品列表容器中,因此选择器从列表向下遍历到卡片及其标题锚点。

python
from bs4 import BeautifulSoup

CARD_SELECTOR = (
    'div[data-container="Product List"] > div.hz-product-card '
    'a.hz-product-card__product-title'
)

def get_product_urls(html):
    soup = BeautifulSoup(html, "html.parser")
    return [a["href"] for a in soup.select(CARD_SELECTOR) if a.get("href")]

每张产品卡片带有 hz-product-card 类,其中的标题链接带有 hz-product-card__product-title 类,产品页面 URL 在其 href 中。对渲染后的类别 HTML 运行此函数,可得到一个整洁的产品页面 URL 列表:

json
[
  "https://www.houzz.com/products/the-sequoia-bathroom-vanity-acacia-30-single-sink-freestanding-prvw-vr~170329010",
  "https://www.houzz.com/products/bosque-bath-vanity-driftwood-42-single-sink-undermount-freestanding-prvw-vr~107752516",
  "https://www.houzz.com/products/render-bathroom-vanity-oak-white-prvw-vr~176775440",
  "https://www.houzz.com/products/the-wailea-bathroom-vanity-single-sink-42-weathered-fir-freestanding-prvw-vr~188522678"
]
选择器会漂移

Houzz 的类名和容器属性会无预告变动。将此处的选择器视为起始模板而非固定约定。当列表返回空时,请在浏览器开发者工具中重新检查线上页面并更新选择器。定期维护选择器对任何生产级爬虫来说都是正常的,并不意味着出了什么问题。

第三步:处理类别页面的分页

一个类别页面只是目录的一个切片。Houzz 在分页控件中提供了"下一页"链接,因此您可以获取一页,采集其链接,再跟随下一页链接,直到没有更多内容。在获取函数外包裹一个小型重试逻辑,可以防止单个响应缓慢的页面中断整个运行。

python
import time

BASE = "https://www.houzz.com"

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        html = crawl(page_url)
        if html:
            return html
        if attempt < max_retries:
            print(f"Retrying ({attempt + 1}/{max_retries})...")
            time.sleep(1)
    print(f"Unable to fetch {page_url}")
    return None

def get_next_page_url(soup):
    nxt = soup.select_one("a.hz-pagination-link--next")
    return BASE + nxt["href"] if nxt and nxt.get("href") else None

def collect_all_urls(start_url, max_pages):
    all_urls = []
    url = start_url
    page = 0
    while url and page < max_pages:
        html = fetch_html(url)
        if not html:
            break
        all_urls.extend(get_product_urls(html))
        soup = BeautifulSoup(html, "html.parser")
        url = get_next_page_url(soup)
        page += 1
        time.sleep(2)
    return all_urls

fetch_html 在失败时最多重试两次并短暂暂停,成功时返回 HTML,放弃后返回 Noneget_next_page_url 读取 hz-pagination-link--next 锚点,并将其相对 href 拼接到 Houzz 主机上。collect_all_urls 从一页跟随到下一页的"下一页"链接,将抓取上限设为您指定的 max_pages 上限以防止大型类别无限运行,并从每一页采集链接。页面之间的 time.sleep(2) 控制运行节奏,避免对网站造成过大压力。

第四步:解析每个产品页面

有了完整的产品 URL 列表后,获取每个页面并提取字段。Houzz 在其定价块周围组织标题详情,因此下面的选择器将名称、价格、评分、评论数、卖家和分类分别映射到各个元素。每次查找都有保护,确保缺失的字段返回 None 而不是导致运行崩溃。

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

def get_rating(soup):
    star = soup.select_one("span.star-rating")
    if star and star.get("aria-label"):
        return star["aria-label"].replace("Average rating: ", "")
    return None

def scrape_product(html, url):
    soup = BeautifulSoup(html, "html.parser")
    return {
        "link": url,
        "name": text_of(soup, "span.view-product-title"),
        "price": text_of(soup, "span.pricing-info__price"),
        "rating": get_rating(soup),
        "reviews": text_of(soup, "span.review-count"),
        "seller": text_of(soup, "a.seller-name"),
        "category": text_of(soup, "nav.breadcrumb li:last-child"),
    }

text_of 辅助函数查询单个元素并返回其去空格文本,若元素缺失则返回 None,因此省略某字段的产品不会中断循环。选择器直接来自 Houzz 的产品布局:name 读取 view-product-title span,price 读取 pricing-info__price span,评分位于 star-rating span 的 aria-label 中,其内容类似"4.9 out of 5 stars",由 get_rating 清理。评论数、卖家和分类位于周围的元数据中;如果其中任何一个返回 None,请重新检查线上页面,因为 Houzz 会定期修改这些包装元素。

第五步:组装完整脚本

现在将各部分整合成一个可运行的脚本:跨页面采集 URL,抓取每件产品,并将记录导出为 JSON 和 CSV 两种格式。

python
import csv
import json
import time
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

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

BASE = "https://www.houzz.com"

OPTIONS = {
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/122.0",
    "ajax_wait": "true",
    "page_wait": 5000,
}

CARD_SELECTOR = (
    'div[data-container="Product List"] > div.hz-product-card '
    'a.hz-product-card__product-title'
)

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

def fetch_html(page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        html = crawl(page_url)
        if html:
            return html
        if attempt < max_retries:
            time.sleep(1)
    return None

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

def get_rating(soup):
    star = soup.select_one("span.star-rating")
    if star and star.get("aria-label"):
        return star["aria-label"].replace("Average rating: ", "")
    return None

def get_product_urls(html):
    soup = BeautifulSoup(html, "html.parser")
    return [a["href"] for a in soup.select(CARD_SELECTOR) if a.get("href")]

def get_next_page_url(soup):
    nxt = soup.select_one("a.hz-pagination-link--next")
    return BASE + nxt["href"] if nxt and nxt.get("href") else None

def collect_all_urls(start_url, max_pages):
    all_urls = []
    url = start_url
    page = 0
    while url and page < max_pages:
        html = fetch_html(url)
        if not html:
            break
        all_urls.extend(get_product_urls(html))
        soup = BeautifulSoup(html, "html.parser")
        url = get_next_page_url(soup)
        page += 1
        time.sleep(2)
    return all_urls

def scrape_product(html, url):
    soup = BeautifulSoup(html, "html.parser")
    return {
        "link": url,
        "name": text_of(soup, "span.view-product-title"),
        "price": text_of(soup, "span.pricing-info__price"),
        "rating": get_rating(soup),
        "reviews": text_of(soup, "span.review-count"),
        "seller": text_of(soup, "a.seller-name"),
        "category": text_of(soup, "nav.breadcrumb li:last-child"),
    }

def save_outputs(records):
    with open("houzz_products.json", "w") as f:
        json.dump(records, f, indent=2)
    if not records:
        return
    with open("houzz_products.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=records[0].keys())
        writer.writeheader()
        writer.writerows(records)

def main():
    listing_url = "https://www.houzz.com/products/bathroom-vanities-and-sink-consoles/best-sellers--best-sellers"
    urls = collect_all_urls(listing_url, max_pages=2)

    records = []
    for url in urls:
        html = fetch_html(url)
        if html:
            records.append(scrape_product(html, url))
        time.sleep(2)

    save_outputs(records)
    print(f"Saved {len(records)} products")

if __name__ == "__main__":
    main()

脚本在最多两个类别页面中采集产品链接,使用重试包装器获取每个产品页面,将其解析为一条记录,并以两秒的停顿控制循环节奏。save_outputs 同时写入 JSON 文件和 CSV 文件,以第一条记录的键作为表头,因此您可以用下游工具处理任意格式的数据。根据您的目标调整 max_pages 和类别 URL。

输出示例

使用 python houzz_scraper.py 运行完整脚本,您将得到每件产品一条整洁的结构化记录,可直接用于分析、数据库或电子表格。

json
[
  {
    "link": "https://www.houzz.com/products/the-sequoia-bathroom-vanity-acacia-30-single-sink-freestanding-prvw-vr~170329010",
    "name": "The Sequoia Bathroom Vanity, Acacia, 30\", Single Sink, Freestanding",
    "price": "$948",
    "rating": "4.9 out of 5 stars",
    "reviews": "128 Reviews",
    "seller": "Cambridge Plumbing",
    "category": "Bathroom Vanities"
  },
  {
    "link": "https://www.houzz.com/products/render-bathroom-vanity-oak-white-prvw-vr~176775440",
    "name": "Render Bathroom Vanity, Oak White",
    "price": "$295",
    "rating": "4.5 out of 5 stars",
    "reviews": "43 Reviews",
    "seller": "Modway",
    "category": "Bathroom Vanities"
  }
]

对应的 CSV 包含相同的列,每件产品一行,可直接导入 pandas 或任何电子表格中按价格区间、评分或卖家进行筛选。如果您的目标是工作表而非脚本,这些记录可以直接输入电商抓取流水线,无需进一步处理。

大规模抓取时保持不被封锁

即使渲染已被处理,Houzz 仍会监控具有爬虫特征的流量。以下几个习惯可以让长时间运行保持健康,它们适用于任何高难度的商业目标。

  • 控制请求速率。在紧密循环中连续获取产品页面是触发限速或被挑战的最快方式。上述两秒停顿是下限而非上限;对于更大规模的任务请适当延长,并交替不同目标而不是全速爬取同一类别。
  • 依赖轮换。住宅 IP 池将请求分散到许多真实用户地址,不会让任何单一地址触发限速。Crawling API 为您处理这一切;如果您自建技术栈,这是最需要做对的部分。
  • 读取状态码。当运行开始返回非 200 的 pc_status 值时,说明当前速率或 IP 层级已不再足够。将其视为退让的信号,而不是可以忽略的噪声。

对于更大规模的抓取,异步 Crawler 可以将请求排队并通过 webhook 传递结果,适合在不保持长连接的情况下运行多个类别页面。更广泛的策略请参阅如何在不被封锁的情况下抓取网站

抓取 Houzz 是否合法?

抓取 Houzz 是否被允许,取决于 Houzz 的服务条款、您所在的司法管辖区以及您对数据的使用方式。Houzz 的服务条款限制自动访问和批量数据采集,因此无论您的工具多么谨慎,抓取都可能违反这些条款。本文中的代码并不会改变这一点,只是让技术层面的工作能够运行。请阅读 Houzz 的服务条款及其 robots.txt,遵守其设定的任何速率预期和禁止路径,并将两者都视为您采集内容的边界。将请求量控制在不给服务器造成压力的水平。

几条值得坚守的底线。只采集公开产品和房源数据:任何人无需账号即可看到的产品名称、价格、评分、评论数、卖家或品牌,以及分类。Houzz 还托管了大量用户生成的内容,包括项目照片、设计创意,以及与真实个人关联的评论;将任何与真实人物相关联的内容视为超出此范围的个人数据,并注意,一旦个人数据进入画面,GDPR 和 CCPA 即适用。Houzz 上的产品摄影和设计图片受卖家、设计师和 Houzz 本身的版权保护,因此采集图片 URL 并不赋予您再发布、重用或转载图片的权利。只取事实性的产品字段,而非媒体内容。

本指南有意将范围限定在公开产品页面,因为这是让工作具有可辩护性的边界。它不涵盖登录墙后的任何内容、账号或已保存的创意数据、房主或专业人士的个人信息,以及您会转载的受版权保护的照片。如果您的项目需要更多内容,正确的做法是签订许可协议:Houzz 为获批准的使用场景运营公开 API 和合作伙伴计划(如 Trade Program 和卖家集成),这才是商业或批量使用的正确路径,而不是设计更精巧的爬虫。

回顾

核心要点

  • Houzz 是客户端渲染的。普通请求返回的薄壳几乎不包含产品网格内容,因此必须先渲染页面再进行解析。
  • 渲染和可信 IP 需要同时具备。带有 JS 令牌的 Crawling API 在一次调用中完成两者;ajax_waitpage_wait 控制等待内容的时长。
  • 分两层处理。使用 hz-product-card__product-title 选择器从每个类别页面采集产品链接,然后获取并解析每件产品的名称、价格、评分、评论数、卖家和分类。
  • 分页并导出。沿 Houzz 的 hz-pagination-link--next 链接追踪至上限,以短暂停顿控制运行节奏,并将记录写入 JSON 和 CSV。
  • 坚守公开数据范围。遵守 Houzz 的服务条款和 robots.txt,只取事实性的产品字段,绝不触碰登录墙、个人数据或您会转载的受版权保护的图片,批量或商业使用请通过官方 API。

常见问题

为什么普通请求几乎不返回任何 Houzz 产品?

因为 Houzz 通过 JavaScript 在客户端加载其产品网格和产品详情。初始 HTML 是一个外壳,只有在浏览器中的页面脚本运行后才会填充内容,因此原始 HTTP 请求返回状态码 200 但大多数卡片和产品字段缺失。要获得完整内容,必须先渲染页面,这正是 Crawling API 的 JS 令牌为您处理的事情。

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

JS 令牌。普通令牌获取静态 HTML,而 Houzz 上的静态 HTML 与普通请求返回的薄壳相同。JS 令牌在返回 HTML 之前先在真实浏览器中渲染页面,因此当 BeautifulSoup 解析时产品卡片和产品字段已经存在。

我可以从 Houzz 产品页面抓取哪些数据?

公开产品字段:产品名称、价格、平均评分和评论数、卖家或品牌、分类,以及产品链接。只取任何访客无需账号即可看到的数据,避免项目照片、评论或任何与可识别个人相关的内容,这些超出了本指南涵盖的公开产品范围。

我的选择器返回 None,发生了什么变化?

几乎可以肯定是 Houzz 的标记发生了变化。hz-product-cardpricing-info__pricestar-rating 等类名以及容器属性会无预告变动,因此上个月有效的选择器可能已失效。请在浏览器开发者工具中重新检查线上页面并更新选择器。定期维护选择器对任何生产级爬虫来说都是正常的。

如何抓取 Houzz 类别的多个页面?

Houzz 提供了一个带有 hz-pagination-link--next 类的下一页锚点。读取其 href,将其拼接到 Houzz 主机上,并从一页追踪到下一页,直到链接不存在或达到您的 max_pages 上限。上面的 collect_all_urls 函数展示了完整循环,并在页面之间有短暂停顿。

我可以将抓取到的 Houzz 数据用于商业目的吗?

将其视为法律问题而非技术问题。Houzz 的服务条款限制再使用,大量产品图片受卖家和设计师版权保护,任何用户内容都属于个人数据,因此商业或批量使用通常需要获得许可。请审查相关条款,考虑使用 Houzz 的官方 API 和合作伙伴计划,并在基于这些数据构建产品之前寻求法律建议。

开始构建

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

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

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