Yelp 是网络上最密集的本地商业公开数据来源之一。每条搜索结果都包含商家名称、星级评分、评价数量、所属类别、街区或地址,以及指向其 Yelp 页面的链接。对于本地商业研究、竞争格局分析或构建区域服务提供商数据集,这些公开 listing 字段正是你所需要的结构化信号,无需登录即可查看。

本指南将演示如何以可靠的方式使用 Python 抓取 Yelp 数据。你通过 Crawling API 获取渲染后的搜索结果页面,用 BeautifulSoup 解析每张商家卡片以提取名称、评分、评价数、类别、地址和链接,然后遍历分页以覆盖完整的结果集,并导出为 JSON 或 CSV。本文所有内容都限定在公开的商业数据范围内,文末的合法性部分不是套话,请在将其应用于真实抓取量之前务必阅读。

你将构建的内容

一个小型 Python 爬虫,接受搜索查询词和地点,通过 Crawling API 获取渲染后的 Yelp 搜索结果页面,并为页面上的每家商家提取结构化记录。运行示例为旧金山(San Francisco, CA)的"Italian Restaurants",我们从每条 listing 中提取以下字段:

  • Business name listing 卡片上显示的主要标识名称。
  • Rating 该商家的综合星级评分。
  • Review count 支撑该评分的评价数量。
  • Category 该 listing 所属的商业类别。
  • Address 公开的街区或街道区域,用于任何地理分析。
  • Link 指向该商家 Yelp 页面的 URL。

为什么直接请求在 Yelp 上会失败

你可以将 requests 库指向 Yelp 搜索 URL,运气好时能拿到一些 HTML。但很快会出现两个问题。第一,Yelp 使用 JavaScript 在客户端渲染其大部分搜索结果内容,因此普通请求收到的原始 HTML 通常是一个尚未包含商家卡片的外壳。第二,Yelp 会监控爬虫形态的流量:它按 IP 进行限速,对看起来是自动化的请求提供 CAPTCHA,并封锁以机器式密集模式获取页面的数据中心地址。从你的笔记本发出单个请求可能成功;从同一 IP 发出数百个请求则不会。

因此,一个真正能完成任务的爬虫需要两样东西:像真实浏览器那样渲染页面,以及被识别为来自可信 IP 的真实访客的请求。你可以自行构建一个无头浏览器集群加轮换住宅代理池,但维护这套技术栈本身就是主要的工作量。Crawling API 将其整合为一次调用:你发送 URL,它渲染 JavaScript 并在服务端通过住宅 IP 路由请求,处理反爬虫层,并返回供你解析的完整 HTML。

使用哪种 token

Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML;JavaScript(JS)token 则先在真实浏览器中渲染页面。由于 Yelp 在客户端构建其搜索结果,这里需要使用 JS token,这样返回的 HTML 中才会包含商家卡片。只有对于在初始响应中就包含数据的目标,才应使用普通 token。

前提条件

首先需要准备好以下几项,每项都不会花太长时间。

Python 基础。 你应该能够运行脚本并使用 pip 安装包。如果你对选择器还不熟悉,如何使用 BeautifulSoup 的入门指南深入涵盖了解析方面的内容。

Python 3.8 或更高版本。 使用 python --version 确认。如果尚未安装,请从 python.org 或 Anaconda 等发行版进行安装。

Crawlbase 账号和 token。 注册后打开控制台,从账号文档页面复制你的 JavaScript token。最多 20,000 次请求免费,无需信用卡。请像对待密码一样保护你的 token,不要将其提交到版本控制系统。

搭建项目

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

bash
python --version

python -m venv yelp_env
source yelp_env/bin/activate

pip install crawlbase beautifulsoup4 pandas

在 Windows 上,使用 yelp_env\Scripts\activate 替代 source 那行来激活环境。三个依赖各司其职:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML 以便通过 CSS 选择器提取各个字段,pandas 负责最终将记录写出为 CSV。

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

首先获取一个结果页面。根据查询词和地点构建搜索 URL,导入 CrawlingAPI 类,用你的 token 初始化,然后开启 JavaScript 渲染请求该 URL。Yelp 搜索由两个 URL 参数驱动:商家类别使用 find_desc,地点使用 find_loc。在解析前检查状态可以让失败情况显式暴露,而非悄无声息。

python
from urllib.parse import urlencode
from crawlbase import CrawlingAPI

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

def build_url(query, location, start=0):
    base = "https://www.yelp.com/search?"
    params = {"find_desc": query, "find_loc": location, "start": start}
    return base + urlencode(params)

def crawl(page_url):
    response = api.get(page_url, {"ajax_wait": "true", "page_wait": "3000"})
    if response["headers"]["cb_status"] == "200":
        return response["body"].decode("latin1")
    print(f"Request failed: {response['headers']['cb_status']}")
    return None

if __name__ == "__main__":
    url = build_url("Italian Restaurants", "San Francisco, CA")
    html = crawl(url)
    print(html[:500] if html else "No HTML returned")

ajax_waitpage_wait 选项告知 Crawling API 渲染 JavaScript 并短暂暂停,以确保商家卡片在 HTML 返回之前完成加载。状态检查读取的是响应头中的 cb_status(legacy pc_status),这是请求的 Crawlbase 状态,与上游 HTTP 状态码有所区别。使用 python yelp_scraper.py 运行脚本,你应该看到真实的结果标记,而非验证页面或空外壳。这证明在编写任何选择器之前,获取路径已经正常工作。

Crawlbase Crawling API

Yelp 在客户端渲染结果,并对爬虫形态的流量发起验证挑战。Crawling API 在真实浏览器中渲染 JavaScript,并在服务端通过轮换住宅 IP 路由每次请求,处理 CAPTCHA 和封锁,返回解析就绪的 HTML,因此你无需自行运行无头浏览器集群和代理池。先在免费套餐上对公开搜索页面进行测试。

第 2 步:使用 BeautifulSoup 解析 listing

获得渲染后的结果页面后,将其加载到 BeautifulSoup 并遍历结果卡片。每家商家位于一个可预测容器下的卡片中,名称、评分、评价数、类别、地址和链接都对应各自的选择器。以防御性方式读取每个字段,在元素缺失时返回 None,可防止某个缺失值导致整个运行崩溃。

python
from bs4 import BeautifulSoup

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

def text_of(node):
    return node.get_text(strip=True) if node else None

def extract_business(card):
    name = card.select_one('div[class*="businessName"] h3 > span > a')
    rating = card.select_one('div.css-volmcs + div.css-1jq1ouh > span:first-child')
    reviews = card.select_one('div.css-volmcs + div.css-1jq1ouh > span:last-child')
    category = card.select('div[class*="priceCategory"] div > p > span:first-child a')
    address = card.select_one('div[class*="priceCategory"] div > p > span:last-child')

    return {
        "name": text_of(name),
        "rating": text_of(rating),
        "review_count": text_of(reviews),
        "category": ", ".join(c.get_text(strip=True) for c in category) if category else None,
        "address": text_of(address),
        "link": BASE + name["href"] if name and name.get("href") else None,
    }

def extract_businesses(html):
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select('div[data-testid="serp-ia-card"]:not(.ABP)')
    return [extract_business(card) for card in cards]

卡片通过 div[data-testid="serp-ia-card"]:not(.ABP) 选取,它选择自然结果卡片的同时跳过广告变体。businessName 锚点同时包含显示名称和指向该商家 Yelp 页面的相对 href,因此名称和链接来自同一个元素。评分和评价数是评分块之后的两个 span,类别和地址位于价格与类别行内。当卡片某个字段缺失时,text_of 辅助函数返回 None 而非抛出 .get_text() 异常,使提取具有弹性。

选择器会漂移

Yelp 的类名(尤其是哈希化的 css-* token)是动态生成的,随时可能在不通知的情况下发生变化,因此将这些选择器视为起始模板,而非合同。当某个字段对所有 listing 都返回 None 时,请在浏览器开发者工具中重新检查实时结果页面并更新选择器。data-testidbusinessName 属性钩子往往比哈希类名更稳定,尽量优先使用它们。定期维护选择器对任何生产级爬虫来说都是正常的。

第 3 步:整合代码

现在将获取和解析整合到一个可运行的单页脚本中。构建 URL,获取渲染后的 HTML,将其传给解析器,并以 JSON 格式打印结构化记录。

python
import json
from urllib.parse import urlencode
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})
BASE = "https://www.yelp.com"

def build_url(query, location, start=0):
    params = {"find_desc": query, "find_loc": location, "start": start}
    return BASE + "/search?" + urlencode(params)

def crawl(page_url):
    response = api.get(page_url, {"ajax_wait": "true", "page_wait": "3000"})
    if response["headers"]["cb_status"] == "200":
        return response["body"].decode("latin1")
    print(f"Request failed: {response['headers']['cb_status']}")
    return None

def text_of(node):
    return node.get_text(strip=True) if node else None

def extract_business(card):
    name = card.select_one('div[class*="businessName"] h3 > span > a')
    rating = card.select_one('div.css-volmcs + div.css-1jq1ouh > span:first-child')
    reviews = card.select_one('div.css-volmcs + div.css-1jq1ouh > span:last-child')
    category = card.select('div[class*="priceCategory"] div > p > span:first-child a')
    address = card.select_one('div[class*="priceCategory"] div > p > span:last-child')
    return {
        "name": text_of(name),
        "rating": text_of(rating),
        "review_count": text_of(reviews),
        "category": ", ".join(c.get_text(strip=True) for c in category) if category else None,
        "address": text_of(address),
        "link": BASE + name["href"] if name and name.get("href") else None,
    }

def extract_businesses(html):
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select('div[data-testid="serp-ia-card"]:not(.ABP)')
    return [extract_business(card) for card in cards]

def main():
    url = build_url("Italian Restaurants", "San Francisco, CA")
    html = crawl(url)
    if not html:
        return
    data = extract_businesses(html)
    print(json.dumps(data, indent=2))

if __name__ == "__main__":
    main()

输出示例

使用 python yelp_scraper.py 运行完整脚本,你将获得一个整洁的结构化记录列表,可直接写入 JSON、CSV 或数据库。

json
[
  {
    "name": "Bella Trattoria",
    "rating": "4.3",
    "review_count": "(1.9k reviews)",
    "category": "Italian, Bars, Pasta Shops",
    "address": "Inner Richmond",
    "link": "https://www.yelp.com/biz/bella-trattoria-san-francisco"
  },
  {
    "name": "Sotto Mare",
    "rating": "4.3",
    "review_count": "(5.2k reviews)",
    "category": "Seafood, Italian, Bars",
    "address": "North Beach/Telegraph Hill",
    "link": "https://www.yelp.com/biz/sotto-mare-san-francisco"
  }
]

卡片中缺失的任何字段都会以 null 返回,这是预期行为,也正是解析器以防御性方式读取每个字段的原因。评价数以显示字符串形式返回,例如 "(1.9k reviews)";如果你需要干净的整数进行分析,请在后续清理步骤中去掉括号并展开 k 后缀。

第 4 步:处理多个结果页面的分页

一个页面只是演示;真实任务需要覆盖完整的结果集。Yelp 通过 URL 中的 start 参数对搜索结果分页,该参数设置页面第一条结果的偏移量,每次步进十个。因此,遍历页面只需对一系列偏移量(0、10、20 等)进行循环。同样的 build_urlextract_businesses 函数无需修改即可复用,因此分页只是一个在请求之间控制节奏、并将合并结果写入 JSON 和 CSV 的外层循环。

python
import json
import time
import pandas as pd

def scrape_all_pages(query, location, max_pages=5):
    all_rows = []
    for page in range(max_pages):
        start = page * 10
        url = build_url(query, location, start)
        html = crawl(url)
        if not html:
            print(f"Stopping at offset {start}: no HTML")
            break
        rows = extract_businesses(html)
        if not rows:
            print(f"No results at offset {start}; reached the end")
            break
        all_rows.extend(rows)
        print(f"Offset {start}: {len(rows)} businesses")
        time.sleep(2)
    return all_rows

if __name__ == "__main__":
    rows = scrape_all_pages("Italian Restaurants", "San Francisco, CA", max_pages=5)

    with open("yelp_businesses.json", "w") as f:
        json.dump(rows, f, indent=2)

    pd.DataFrame(rows).to_csv("yelp_businesses.csv", index=False)
    print(f"Saved {len(rows)} businesses to JSON and CSV")

两个细节使这个循环适用于生产环境。当某页没有返回商家时提前停止,这样你就不会在最后一页之后浪费请求;请求之间休眠两秒,使运行不会以密集突发的形式到达。导出步骤从同一字典列表中写出两种格式:json.dump 用于结构化文件,pandas 用于可直接在电子表格中打开的 CSV。根据你的抓取量调整 max_pages 和休眠时间;越慢,越不引人注意。

保持不被封锁

即使有 Crawling API 处理渲染、IP 轮换和反爬虫层,一些习惯仍有助于保持抓取任务的顺利运行,这些习惯适用于任何本地目录目标。

  • 控制请求节奏。 上面的休眠不是装饰性的。紧密循环是被限速最快的方式;分散请求看起来更像是正常流量。
  • 善用轮换。 住宅 IP 池能将请求分散到许多真实用户地址,使单个 IP 不触发限速。Crawling API 为你处理这个问题;如果你自建技术栈,这是最需要做好的部分。
  • 读懂状态码。 如果某次运行开始返回验证挑战或错误,这是在告诉你当前的请求频率过于激进。把它当作退后重试的信号,而非可以忽略的噪音。

更完整的方法论,请参阅如何在不被封锁的情况下抓取网站。如果你的目标是比 Yelp 更广泛的本地商业目录类别,抓取本地商业 listing 指南Yellow Pages 实战教程涵盖了使用相同方法的同类数据源。如果你想要每家商家背后的评价文本而非搜索结果摘要,请参阅如何抓取 Yelp 评价,并牢记以下隐私说明。

抓取 Yelp 是否合法?

抓取 Yelp 是否被允许,取决于该网站的服务条款、你所在的司法管辖区以及你对数据的用途。这里的任何代码都无法改变这一点;它只是使技术层面得以实现。请阅读 Yelp 的服务条款及其 robots.txt,并将两者视为你采集内容和采集速度的边界。Yelp 的条款限制了自动化访问,因此对于小规模研究以外的任何用途,正确的路径是 Yelp 的官方渠道:Yelp Fusion API 提供 Yelp 支持的条款下的商业和搜索数据,这比抓取前端更安全、更持久。

如果你确实从公开页面采集数据,请将范围限定为公开商业数据:无需登录即可看到的商家名称、综合评分、评价数量、类别、公开街区或地址,以及指向 listing 的链接。评价文本本身需要更清晰的界定。综合评分和评价数量是商家级别的事实,但单条评价的文本以及撰写者的姓名则属于个人数据。将评价者身份视为个人数据:不要建立个人档案,不要将某人的评价与其姓名绑定后重新发布,并在个人数据涉及范围内适用 GDPR 或 CCPA 义务。

此方法不涵盖的内容同样重要。它不触碰任何需要登录的内容,也不绕过任何身份验证或访问控制来获取受限内容;这超出了本文范围,且违反了网站条款。尊重 Yelp 声明的速率预期,保持合理的请求量,不要给其服务器造成压力;如果你计划存储、丰富或商业化重用 Yelp 数据,请优先使用 Fusion API,并查阅适用于你的规则,而不要假设"公开"就意味着"不受限制"。

回顾

核心要点

  • Yelp 是结构化目录。 每条搜索结果都是一张包含名称、评分、评价数、类别、公开地址和链接的卡片,由 URL 中的 find_descfind_loc 参数驱动。
  • 直接请求会在两个层面失败。 Yelp 在客户端渲染结果,并封锁爬虫形态的流量;Crawling API 渲染 JavaScript,通过住宅 IP 路由,并在一次调用中返回解析就绪的 HTML。
  • BeautifulSoup 负责提取。 将名称、评分、评价数、类别、地址和链接映射到当前选择器,以防御性方式读取每个字段,并预期哈希化的 css-* 类名会随时间漂移。
  • 分页是对 start 偏移量的循环。 每次步进十个,复用同一解析器,在空页时提前停止,在请求之间休眠,并导出为 JSON 和 CSV。
  • 坚守公开商业数据。 遵守服务条款和 robots.txt,将评价者身份视为个人数据,切勿触碰需要登录的内容,并对超出小规模研究范围的任何用途优先使用官方 Yelp Fusion API。

常见问题

Yelp 需要普通 token 还是 JS token?

JavaScript token。Yelp 在客户端构建其搜索结果卡片,因此普通 token 获取通常返回一个不包含商家信息的 HTML 外壳。JS token 先在真实浏览器中渲染页面,这才是让卡片出现在你解析的 HTML 中的关键。配合 ajax_waitpage_wait 选项使用,以便内容在响应返回之前有时间加载完成。

如何处理 Yelp 上的分页?

Yelp 通过 URL 中的 start 参数暴露结果偏移量,该参数每次步进十个,因此你可以对一系列偏移量(0、10、20 等)进行循环,每页构建一个 URL,并对每个 URL 运行同一解析器。当某页返回零家商家时停止,这标志着结果集的结束;请求之间休眠几秒,使运行不会以密集突发的形式到达。

我的选择器返回 None。是什么改变了?

几乎可以肯定是 Yelp 的标记。css-volmcscss-1jq1ouh 等哈希类名是动态生成的,随时可能在不通知的情况下发生变化,因此上个月还能用的选择器现在可能已经失效。请在浏览器开发者工具中重新检查实时结果页面并更新选择器,尽量优先使用更稳定的 data-testidbusinessName 钩子。定期维护选择器对任何生产级爬虫来说都是正常的。

抓取 Yelp 评价是否合法?

综合评分和评价数量是可用于分析的商家级别事实,但单条评价的文本以及评价者的姓名属于个人数据。不要建立个人档案,不要将某人的评价与其身份绑定后重新发布,并在个人数据涉及范围内适用 GDPR 或 CCPA。对于任何规模的评价数据,Yelp Fusion API 是 Yelp 支持的路径,也是比抓取公开页面更安全的选择。

如何避免在抓取 Yelp 时被封锁?

保持每个 IP 的请求频率低,用延迟控制请求节奏,并通过轮换住宅 IP 路由,使单个地址不触发限速。Crawling API 为你管理渲染、轮换和反爬虫层;如果你自建技术栈,这是最值得投入的部分。密切关注状态码,一旦开始看到验证挑战就立即退后重试。

可以将抓取的数据导出到 Excel 吗?

可以。爬虫产生的是字典列表,pandas 只需两行就能将其转换为电子表格:pd.DataFrame(rows).to_excel("yelp_businesses.xlsx", index=False)。由于每条记录共享相同的键,列能整洁对齐,同样的结构也可以轻松导出为脚本已写出的 CSV 或数据库表。

开始构建

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

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

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