本地商家信息是网络上最实用的公开数据集之一。像"奥斯汀水管工"或"丹佛餐厅"这样的目录搜索,会返回一个结构化的商家网格,每条记录包含名称、地址、电话号码、类别,以及带评论数量的星级评分。销售、营销和研究团队会获取这些数据,用于按城市构建潜在客户列表、用经过核实的联系方式丰富 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 返回给你解析。
Crawlbase 提供两种令牌类型。普通令牌获取静态 HTML;JavaScript(JS)令牌先在真实浏览器中渲染页面。静态目录页面用普通令牌即可正常解析,但在客户端注入商家列表的平台(如 Google Maps、Yelp)则需要 JS 令牌。根据页面选择令牌:简单页面用普通令牌,动态页面用 JS 令牌。
前提条件
在编写代码之前,你需要准备好以下几样东西。每一项都不费时。
基础 Python 知识。你应该能够编写和运行 Python 脚本,并使用 pip 安装包。如果你是 BeautifulSoup 新手,如何在 Python 中使用 BeautifulSoup 这篇入门指南涵盖了本教程所依赖的选择器基础。
Python 3.8 或更高版本。使用 python --version 确认你的版本。如果尚未安装,可从 python.org 下载,或通过 Anaconda 等发行版安装。
Crawlbase 账号和令牌。注册账号,打开控制台,从账号文档页面复制你的令牌。请像保管密码一样保管该令牌:它用于验证你的请求身份,不要将其提交到版本控制系统。
搭建项目
创建虚拟环境以隔离项目依赖,然后安装爬虫所需的两个库。
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-address 和 div.locality,电话在 div.phones,主要类别在 div.categories,评分通过 div.result-rating 上的 class 暴露,评论数在 span.count,网站在 a.track-visit-website。这些就是你的目标选择器。
第一步:获取渲染后的商家列表页面
首先获取完整的页面。导入 CrawlingAPI 类,用你的令牌初始化,由类别和城市构建搜索 URL,然后发起请求。在解析之前检查状态码,可以让失败尽早暴露而非静默发生。
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 解码可能会报错的字符。运行脚本,你应该能看到真实的商家列表标记,而不是空壳或封锁页面。这证明在你编写任何选择器之前,数据获取已正常工作。
那一次 api.get 调用完成了通常需要一周才能搞定的工作:它在固定到正确国家的可信住宅 IP 后获取了商家列表页面,使目录返回了真实卡片而非封锁页面。Crawling API 为你处理渲染、IP 轮换和地理定向,让你跳过自行运行无头浏览器集群和代理池的麻烦。先在免费套餐中指向一个城市进行测试。
第二步:用 BeautifulSoup 解析商家卡片
拿到 HTML 后,将其加载到 BeautifulSoup 中,找到所有商家卡片,并通过选择器提取每个字段。每家商家包裹在 div.result 容器中,名称、地址、电话、类别、评分、评论数和网站各自通过对应的 class 暴露。将每张卡片包裹在 try/except 中,确保一条格式错误的记录不会导致整个运行崩溃。
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 列表中读取星级评分,目录以 four 或 four half 等单词形式写入分数,并将其转换为数字。评论数来自 span.count,网站从锚点的 href 读取而非其文本。名称、地址、电话和类别分别映射到各自的选择器。
目录的 class 名称会无通知变更。将上述选择器视为起始模板,而非固定合同。当某个字段对每张卡片都返回 None 时,在浏览器开发者工具中重新检查线上结果页并更新选择器。定期维护选择器是任何生产级爬虫的正常工作,不代表出了什么问题。
第三步:整合并导出
现在将获取和解析逻辑串联成一个可运行的脚本,并将记录同时写入 JSON 和 CSV,使其可以直接导入电子表格或数据库。获取渲染后的页面,传给解析器,然后输出结构化记录。
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.json 和 listings.csv 并打印到控制台。
[ { "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= 参数对每个城市分页,直到某页不再返回商家卡片,并将所有结果汇总到一个数据集。
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 次请求免费,无需信用卡。

