本地商家信息是网络上最实用的公开数据集之一。像"奥斯汀水管工"或"丹佛餐厅"这样的目录搜索,会返回一个结构化的商家网格,每条记录包含名称、地址、电话号码、类别,以及带评论数量的星级评分。销售、营销和研究团队会获取这些数据,用于按城市构建潜在客户列表、用经过核实的联系方式丰富 CRM 记录,以及描绘各市场的竞争对手分布。手动收集这些信息无法超过几个结果的规模,因此这项工作属于脚本的用武之地。

本指南介绍如何使用 Python 以可靠的方式抓取本地商家信息。你将构建一个小型可运行的爬虫,通过 Crawling API 获取渲染后的目录结果页面,使用 BeautifulSoup 解析每条商家卡片,并提取每家商家的整洁记录:名称、地址、电话、类别、评分、评论数和网站。整个教程仅限于公开的商业信息,靠近末尾的合法性说明不是套话,请在正式抓取大量数据之前认真阅读。

你将构建什么

一个 Python 脚本:接收类别和城市,通过 Crawling API 获取渲染后的商家列表页面,并为每家商家提取结构化记录。我们以 Yellow Pages 搜索为贯穿全文的示例,从每条结果卡片中提取以下字段:

  • 名称 商家名称,例如 "Austin Plumbing Co"。
  • 地址 卡片上显示的街道地址。
  • 电话 商家公开列出的电话号码。
  • 类别 目录将该商家归入的主要类别。
  • 评分 平均星级评分(如有)。
  • 评论数 该评分背后的评论数量。
  • 网站 商家自有网站的链接(如有列出)。

为什么普通请求在商家列表网站上会失败

大规模收集商家信息并不像发送请求然后解析 HTML 那么简单,原因有两个,一旦超过少量查询就会叠加放大。

第一,结果与地理位置相关。像"水管工"这样的裸查询,根据请求看起来来自奥斯汀、丹佛还是凤凰城,会返回完全不同的商家。要获得一致的数据集,你必须同时控制查询内容(包含城市)和请求来源地(地理定向),否则结果会在每次运行中不可预期地变化。

第二,现代目录会防御自动化流量,并且越来越多地在客户端渲染商家列表。许多平台返回一个薄 HTML 壳,然后用 JavaScript 注入真实的商家卡片,因此标准 HTTP 请求给你的是一个没有任何商家信息的页面。一旦超过几个请求,平台还会开始施加 IP 封锁、CAPTCHA 挑战和请求限速。因此,一个可用的爬虫需要在单次请求中同时具备两点:一个能渲染页面的浏览器,以及一个被平台识别为真实访客的 IP。你可以自己搭建无头浏览器加轮换住宅代理池,但维护这套组合才是大部分工作量所在。Crawling API 将两者合并为一次调用:你发送 URL,它在可信住宅 IP 后渲染页面,并将完整 HTML 返回给你解析。

普通令牌与 JS 令牌

Crawlbase 提供两种令牌类型。普通令牌获取静态 HTML;JavaScript(JS)令牌先在真实浏览器中渲染页面。静态目录页面用普通令牌即可正常解析,但在客户端注入商家列表的平台(如 Google Maps、Yelp)则需要 JS 令牌。根据页面选择令牌:简单页面用普通令牌,动态页面用 JS 令牌。

前提条件

在编写代码之前,你需要准备好以下几样东西。每一项都不费时。

基础 Python 知识。你应该能够编写和运行 Python 脚本,并使用 pip 安装包。如果你是 BeautifulSoup 新手,如何在 Python 中使用 BeautifulSoup 这篇入门指南涵盖了本教程所依赖的选择器基础。

Python 3.8 或更高版本。使用 python --version 确认你的版本。如果尚未安装,可从 python.org 下载,或通过 Anaconda 等发行版安装。

Crawlbase 账号和令牌。注册账号,打开控制台,从账号文档页面复制你的令牌。请像保管密码一样保管该令牌:它用于验证你的请求身份,不要将其提交到版本控制系统。

搭建项目

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

bash
python --version

python -m venv listings_env
source listings_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,将激活命令中的 source 行替换为 listings_env\Scripts\activate。两个依赖各司其职:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML,让你可以通过 CSS 选择器从商家卡片中提取每个字段。

理解商家列表页面结构

目录结果页面以一列商家卡片的形式展示,每家商家占一张。每张卡片携带相同的几个字段:商家名称、街道地址、电话号码、所属类别,以及带评论数量的评分。当商家提供了网站链接时,卡片上还会有"访问网站"链接。列表底部是分页控件,让你可以浏览同一查询的更多结果页。

编写选择器之前,先在浏览器中打开一个结果页,右键单击某张商家卡片,选择"检查"。在 Yellow Pages 中,每条结果包裹在 div.result 容器中,名称在 a.business-name,地址在 div.street-addressdiv.locality,电话在 div.phones,主要类别在 div.categories,评分通过 div.result-rating 上的 class 暴露,评论数在 span.count,网站在 a.track-visit-website。这些就是你的目标选择器。

第一步:获取渲染后的商家列表页面

首先获取完整的页面。导入 CrawlingAPI 类,用你的令牌初始化,由类别和城市构建搜索 URL,然后发起请求。在解析之前检查状态码,可以让失败尽早暴露而非静默发生。

python
from urllib.parse import quote_plus
from crawlbase import CrawlingAPI

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

def build_url(category, city):
    terms = quote_plus(category)
    geo = quote_plus(city)
    return f"https://www.yellowpages.com/search?search_terms={terms}&geo_location_terms={geo}"

def crawl(page_url):
    options = {"country": "US"}
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("latin1")
    print(f"Request failed: {response['status_code']}")
    return None

if __name__ == "__main__":
    url = build_url("plumbers", "Austin, TX")
    html = crawl(url)
    print(html[:500] if html else "No HTML returned")

build_url 辅助函数通过两个查询参数拼接搜索 URL:search_terms 表示类别,geo_location_terms 表示城市,两者均经过 URL 编码,确保空格和逗号能正确传递。country 选项将请求固定在美国 IP,这是地理定向的关键:对"奥斯汀水管工"的查询,只有当请求看起来也源自正确市场时,才会返回合理的结果。响应体使用 latin1 解码,因为目录页面混有一些严格 UTF-8 解码可能会报错的字符。运行脚本,你应该能看到真实的商家列表标记,而不是空壳或封锁页面。这证明在你编写任何选择器之前,数据获取已正常工作。

Crawlbase Crawling API

那一次 api.get 调用完成了通常需要一周才能搞定的工作:它在固定到正确国家的可信住宅 IP 后获取了商家列表页面,使目录返回了真实卡片而非封锁页面。Crawling API 为你处理渲染、IP 轮换和地理定向,让你跳过自行运行无头浏览器集群和代理池的麻烦。先在免费套餐中指向一个城市进行测试。

第二步:用 BeautifulSoup 解析商家卡片

拿到 HTML 后,将其加载到 BeautifulSoup 中,找到所有商家卡片,并通过选择器提取每个字段。每家商家包裹在 div.result 容器中,名称、地址、电话、类别、评分、评论数和网站各自通过对应的 class 暴露。将每张卡片包裹在 try/except 中,确保一条格式错误的记录不会导致整个运行崩溃。

python
from bs4 import BeautifulSoup

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

def parse_rating(card):
    el = card.select_one("div.result-rating")
    if not el:
        return None
    words = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5}
    rating = None
    for cls in el.get("class", []):
        base = cls.replace("-half", "")
        if base in words:
            rating = words[base] + (0.5 if "-half" in cls else 0)
    return rating

def scrape_results(html):
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select("div.result")
    results = []
    for card in cards:
        try:
            website = card.select_one("a.track-visit-website")
            results.append({
                "name": text_of(card, "a.business-name"),
                "address": text_of(card, "div.street-address"),
                "locality": text_of(card, "div.locality"),
                "phone": text_of(card, "div.phones"),
                "category": text_of(card, "div.categories"),
                "rating": parse_rating(card),
                "reviews": text_of(card, "span.count"),
                "website": website["href"] if website else None,
            })
        except Exception as e:
            print(f"Skipped a card: {e}")
    return results

text_of 辅助函数在卡片内查询单个元素,当元素缺失时返回 None,而不是对空元素调用 .get_text() 引发异常。这使得提取更具容错性,因为并非每条商家记录都有网站或评分。parse_rating 辅助函数从 div.result-rating 的 class 列表中读取星级评分,目录以 fourfour half 等单词形式写入分数,并将其转换为数字。评论数来自 span.count,网站从锚点的 href 读取而非其文本。名称、地址、电话和类别分别映射到各自的选择器。

选择器会漂移

目录的 class 名称会无通知变更。将上述选择器视为起始模板,而非固定合同。当某个字段对每张卡片都返回 None 时,在浏览器开发者工具中重新检查线上结果页并更新选择器。定期维护选择器是任何生产级爬虫的正常工作,不代表出了什么问题。

第三步:整合并导出

现在将获取和解析逻辑串联成一个可运行的脚本,并将记录同时写入 JSON 和 CSV,使其可以直接导入电子表格或数据库。获取渲染后的页面,传给解析器,然后输出结构化记录。

python
import csv
import json
from urllib.parse import quote_plus
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

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

def build_url(category, city):
    terms = quote_plus(category)
    geo = quote_plus(city)
    return f"https://www.yellowpages.com/search?search_terms={terms}&geo_location_terms={geo}"

def crawl(page_url):
    response = api.get(page_url, {"country": "US"})
    if response["status_code"] == 200:
        return response["body"].decode("latin1")
    print(f"Request failed: {response['status_code']}")
    return None

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

def parse_rating(card):
    el = card.select_one("div.result-rating")
    if not el:
        return None
    words = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5}
    rating = None
    for cls in el.get("class", []):
        base = cls.replace("-half", "")
        if base in words:
            rating = words[base] + (0.5 if "-half" in cls else 0)
    return rating

def scrape_results(html):
    soup = BeautifulSoup(html, "html.parser")
    results = []
    for card in soup.select("div.result"):
        try:
            website = card.select_one("a.track-visit-website")
            results.append({
                "name": text_of(card, "a.business-name"),
                "address": text_of(card, "div.street-address"),
                "locality": text_of(card, "div.locality"),
                "phone": text_of(card, "div.phones"),
                "category": text_of(card, "div.categories"),
                "rating": parse_rating(card),
                "reviews": text_of(card, "span.count"),
                "website": website["href"] if website else None,
            })
        except Exception as e:
            print(f"Skipped a card: {e}")
    return results

def save(rows, name):
    with open(f"{name}.json", "w") as f:
        json.dump(rows, f, indent=2)
    if rows:
        with open(f"{name}.csv", "w", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=rows[0].keys())
            writer.writeheader()
            writer.writerows(rows)

def main():
    url = build_url("plumbers", "Austin, TX")
    html = crawl(url)
    if not html:
        return
    data = scrape_results(html)
    save(data, "listings")
    print(json.dumps(data, indent=2))

if __name__ == "__main__":
    main()

输出示例

使用 python scraper.py 运行完整脚本,你将得到一个整洁的记录列表,每家商家一条,同时写入 listings.jsonlistings.csv 并打印到控制台。

json
[
  {
    "name": "Austin Plumbing Co",
    "address": "1200 W 5th St",
    "locality": "Austin, TX 78703",
    "phone": "(512) 555-0142",
    "category": "Plumbers, Water Heaters",
    "rating": 4.5,
    "reviews": "(38)",
    "website": "https://www.austinplumbingco.example"
  },
  {
    "name": "Lone Star Drain & Sewer",
    "address": "904 E Cesar Chavez St",
    "locality": "Austin, TX 78702",
    "phone": "(512) 555-0188",
    "category": "Plumbers",
    "rating": null,
    "reviews": null,
    "website": null
  }
]

第二条记录展示了容错性:该商家没有评分、评论数和网站,这些字段返回 null 而不是导致运行崩溃。CSV 版本以相同列顺序呈现,可直接在电子表格中打开或加载到数据库。如果你想了解如何将嵌套记录展平为行,从网站抓取表格的指南涵盖了相同的导出形式。

跨城市和多页面扩展

单城市单次查询只是演示。商家数据的真正价值在于跨多个市场运行同一类别查询,而一致性在这里最为重要。遍历城市列表,用 &page= 参数对每个城市分页,直到某页不再返回商家卡片,并将所有结果汇总到一个数据集。

python
import time

def scrape_city(category, city, max_pages=5):
    base = build_url(category, city)
    collected = []
    for page in range(1, max_pages + 1):
        html = crawl(f"{base}&page={page}")
        if not html:
            break
        rows = scrape_results(html)
        if not rows:
            break
        for row in rows:
            row["city"] = city
        collected.extend(rows)
        print(f"{city} page {page}: {len(rows)} listings")
        time.sleep(2)
    return collected

def scrape_cities(category, cities):
    all_rows = []
    for city in cities:
        all_rows.extend(scrape_city(category, city))
    return all_rows

data = scrape_cities("restaurants", ["Austin, TX", "Denver, CO", "Phoenix, AZ"])
save(data, "multi_city")

max_pages 上限使每个城市的查询有边界,避免宽泛查询无限运行,而空结果时的 break 可在目录用完页面时提前结束。为每行记录打上 city 标签,确保所有数据汇总到一个文件后各市场仍可区分。页面之间的 time.sleep(2) 控制请求节奏,避免频繁请求导致被限速。这种多城市循环正是价格比较工具背后的模式:一个查询,多个来源,规整为一个数据集。

保持不被封锁

即便数据获取问题已解决,目录网站仍会监控形似爬虫的流量。以下几个习惯有助于保持任务顺畅运行,适用于任何商业目标。

  • 控制请求频率。在页面之间加入延迟,对查询进行变化,而不是以全速抓取单一关键词。循环中的 time.sleep 是下限,不是上限。
  • 善用轮换。住宅 IP 池将请求分散到众多真实用户地址,使任何单一地址都不会触发速率限制。Crawling API 为你处理这一切;如果你自行搭建,这一环节最为关键。
  • 匹配地理位置。将请求国家固定在你查询的市场,使结果保持一致,流量看起来也更像本地请求而非异地请求。
  • 关注状态码。当运行过程中开始返回挑战或错误时,说明当前速率或 IP 层级已不够用。将其视为需要退让的信号,而非可以忽略的噪音。

关于保持爬虫健康运行的完整策略,请参阅如何在不被封锁的情况下抓取网站。当你超出按需请求的范围、需要一次推送数千个城市和类别 URL 时,异步 Crawler 可在后台批量处理,并将结果推送到 webhook 或 Cloud Storage,让你无需自行管理请求队列即可复用本指南中的解析器。

抓取商家信息是否合法?

抓取商家目录是否被允许,取决于平台的服务条款、你所在的司法管辖区以及你对数据的用途。大多数目录在其条款中限制自动化访问,因此无论你的技术手段多么谨慎,抓取行为都可能与这些条款相抵触。本文中的代码无法改变这一点,它只是让技术层面的工作得以实现。请阅读平台的使用条款和 robots.txt,并将两者视为你能收集内容及收集速度的边界。

以下几条底线值得坚守。仅收集公开的商业信息:任何人无需账号即可在结果页面看到的名称、地址、电话、类别、评分、评论数和网站链接。商家公开列出的联系方式是公开商业数据,但不要收集个人数据,包括私人联系方式、评论者身份,或任何与具名人士相关的、超出商家自身发布范围的信息。将你的请求量控制在不给平台服务器造成压力的水平,并遵守其明确的速率预期。如果你计划将数据用于商业用途,请获取许可或签订官方协议,而非假定沉默等于同意。

本指南有意将范围限定为公开的商家列表页面,因为这条线使工作保持在可辩护的范围内。它不涵盖登录后的内容、账号数据,或对真实个人个人信息的抓取。当平台提供官方渠道时,优先使用:地图和地点服务提供商发布的官方 API 可在明确条款下返回相同的商家字段,当你需要大批量数据、有保证的结构或商业权限时,这才是正确的工具。如果你的项目需要超出公开商家列表的数据,官方 API 或数据协议才是正确路径,而非更聪明的爬虫。

回顾

核心要点

  • 商家信息依赖地理位置。在查询中包含城市,并固定请求国家,否则相同类别在每次运行中返回不一致的商家。
  • 数据获取才是难点所在。Crawling API 在可信住宅 IP 后获取页面并在需要时渲染,让你拿到真实卡片而非封锁页面或空壳。
  • BeautifulSoup 负责提取。遍历 div.result 卡片,将名称、地址、电话、类别、评分、评论数和网站映射到当前选择器,并预期这些选择器会漂移。
  • 跨城市和多页面扩展。遍历城市列表,用 &page= 分页直到某页为空,为每行记录打上城市标签,并通过延迟控制请求节奏。
  • 坚守公开数据。遵守平台的服务条款和 robots.txt,批量数据或授权数据请优先使用官方地图或地点 API,且绝不收集个人信息。

常见问题

抓取商家信息需要普通令牌还是 JS 令牌?

取决于具体目录。像 Yellow Pages 这样的静态结果页面用普通令牌即可正常解析。在客户端注入商家列表的平台(如 Google Maps 和 Yelp)则需要 JS 令牌,以便在返回 HTML 之前在真实浏览器中渲染页面。根据页面选择令牌:先用普通令牌,如果卡片在 body 中缺失再换 JS 令牌。

如何获取特定城市的准确结果?

需要同时做到两点。通过 geo_location_terms 参数在查询本身中包含城市,并通过 Crawling API 的 country 选项将请求固定在正确市场。本地搜索与位置密切相关,缺少任何一项,结果都会因请求看起来来自哪里而不可预期地变化。

我可以在一次运行中抓取多个城市吗?

可以。传入城市列表,对同一类别循环遍历每个城市,将结果合并到一个数据集。在合并前为每条记录打上城市标签,使各市场保持可区分,并在请求之间添加短暂延迟以控制节奏。

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

几乎可以肯定是目录的标记发生了变化。a.business-name(名称)或 div.result-rating(评分)等 class 名称会无通知变更。在浏览器开发者工具中重新检查线上结果页,更新匹配的选择器,然后重新运行。定期维护选择器是任何生产级爬虫的正常工作。

我可以抓取商家列表中的个人联系方式吗?

不可以,本指南也不涉及这一内容。请坚守公开的商业信息:商家名称、地址、公开电话、类别、评分、评论数和网站。个人数据、私人联系方式或评论者身份超出范围,且与大多数平台的条款相抵触。如需更丰富或经授权的数据,正确途径是官方地图或地点 API。

如何处理跨数百个城市的超大规模任务?

对于按需工作,Crawling API 就够了;但当你一次性推送数千个城市和类别 URL 时,请切换到异步 Crawler。你推送 URL,通过 webhook 或 Cloud Storage 接收结果,无需等待每个请求,从而提升吞吐量并避免瓶颈。本指南中的解析器可直接处理返回的 HTML,无需修改。

开始构建

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

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

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