Groupon 每天在餐饮、旅行、健康和零售领域上线数千条本地优惠,每条信息都带着交易追踪工具、价格监控器或竞争研究仪表板想要的那类结构化数据:标题、折后价、原价、节省金额、优惠适用的地点,以及一个指向该优惠的链接。问题在于,Groupon 用 JavaScript 在浏览器里构建它的分类页面,所以一次普通的 HTTP 请求交给你的是一个近乎空白的外壳,而不是你想要的优惠。
本指南将向你展示如何以可靠的方式用 Python 抓取 Groupon。你会构建一个可运行的小型抓取器,它通过 Crawling API 获取渲染后的分类页面,用 BeautifulSoup 解析每张优惠卡片,并打印出干净的结构化输出。整个演练严格限定在公开优惠数据上,而临近结尾的合规性章节并非套话,所以在你把它指向任何真实流量之前,请先读一读。
你将构建什么
一个 Python 脚本,它接收一个公开的 Groupon 分类 URL,通过 Crawling API 获取渲染后的 HTML,并为页面上每条优惠提取一条结构化记录。我们将用一个城市优惠页作为贯穿全文的示例,并从每张卡片中取出这些字段:
- 标题 优惠的标题,例如 "55-Minute Couples Massage"。
- 当前价格 你实际支付的折后价。
- 原价 被划掉的标价。
- 折扣 节省额,由两个价格推导得出。
- 地点 优惠适用的地址或区域。
- 链接 单条优惠页面的 URL。
为什么对 Groupon 发起普通抓取会失败
如果你用一个裸 HTTP 客户端请求某个 Groupon 分类 URL,你会得到一个状态为 200 的响应,但响应体里几乎没有优惠数据。有两件事在跟你作对。第一,Groupon 用 JavaScript 在浏览器里渲染它的优惠卡片,所以初始 HTML 只是一个框架,只有在页面脚本运行之后才会被填满。第二,Groupon 会很快标记出自动化流量:数据中心 IP 以及看起来不像真实浏览器的请求模式,远在抵达渲染后的卡片之前就会被挑战或封锁。
因此,一个能用的 Groupon 抓取器需要在同一个请求里满足两件事:一个真正渲染页面的浏览器,以及一个被平台读作真实访客的 IP。你可以自己用无头浏览器加一池轮换住宅代理来拼凑这套方案,但把它们缝合起来并保持健康才是大部分工作。Crawling API 把两者折叠进一次调用:你把带 JavaScript token 的 URL 发给它,它在一个受信任的 IP 之后渲染页面,并返回已完成的 HTML 供你解析。
Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML;JavaScript(JS)token 会先在一个真实浏览器里渲染页面。Groupon 是客户端渲染的,所以你在这里需要 JS token。用普通 token 返回的就是普通抓取会得到的同一个空框架,里面没有任何可供解析的东西。你可以从 1,000 次免费请求开始,无需信用卡。
前置条件
在写任何代码之前,你需要准备好几样东西。它们都花不了多久。
基础 Python。你应当能自如地编写和运行一个 Python 脚本,并用 pip 安装软件包。如果 BeautifulSoup 对你来说还很新,我们的在 Python 中使用 BeautifulSoup 指南涵盖了本教程默认你已掌握的解析基础。
Python 3.8 或更高版本。用 python --version 确认你的版本。如果还没有,请从 python.org 安装,或通过 Anaconda 这类发行版安装。
一个 Crawlbase 账户和 JS token。注册、打开你的仪表盘,从账户文档页复制你的 JavaScript(JS)token。把 token 当作密码对待:它用于认证你的请求,所以不要把它放进版本控制。
搭建项目
创建一个虚拟环境,让项目依赖保持隔离,然后安装抓取器需要的两个库。
python --version python -m venv groupon_env source groupon_env/bin/activate pip install crawlbase beautifulsoup4
在 Windows 上,请用 groupon_env\Scripts\activate 替代那行 source 命令来激活环境。两个依赖各司其职:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML,于是你能按 CSS 选择器取出各个字段。
第 1 步:抓取渲染后的分类页面
先从获取已完成的页面开始。导入 CrawlingAPI 类,用你的 JS token 初始化它,并请求分类 URL。在解析之前检查状态,能让失败大声暴露而不是悄无声息。
from crawlbase import CrawlingAPI api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"}) def crawl(page_url): options = {"ajax_wait": "true", "page_wait": 5000} response = api.get(page_url, options) if response["status_code"] == 200: return response["body"].decode("utf-8") print(f"Request failed: {response['status_code']}") return None if __name__ == "__main__": page_url = "https://www.groupon.com/local/washington-dc" html = crawl(page_url) print(html[:500] if html else "No HTML returned")
这两个等待选项对像这样的客户端渲染目标很重要。ajax_wait 告诉 API 等待异步内容加载完成,而 page_wait 在加载后再保持固定毫秒数,好让渲染较晚的卡片在页面被捕获之前出现。五秒是个合理的起点;如果优惠卡片返回为空,就把它调高。用 python scraper.py 运行脚本,你应当看到真实的优惠标记,而不是普通抓取返回的那个空框架。这在你写下第一个选择器之前就确认了渲染正常工作。
Groupon 需要在一个受信任的 IP 之后、在一次调用里得到一个渲染后的页面。Crawling API 接收一个 JS token,在一个真实浏览器里运行页面,在服务端轮换住宅 IP,并把已完成的 HTML 交给你,于是你免去了自己运行无头舰队和代理池的工作。先在免费额度上把它指向一个公开的城市优惠页。
第 2 步:用 BeautifulSoup 解析优惠卡片
拿到渲染后的 HTML 后,把它载入 BeautifulSoup,并按选择器取出每条优惠。Groupon 把它的优惠卡片排布成一个重复的结构,所以你一次性选中所有卡片,然后从每张卡片里读取相同的字段。在你浏览器的开发者工具里检查实时页面,确认当前的类名;下面的选择器与撰写本文时的布局相匹配。
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_deals(html): soup = BeautifulSoup(html, "html.parser") cards = soup.select('div[data-item-type="card"] > article') deals = [] for card in cards: link = card.select_one("a[href]") deals.append({ "title": text_of(card, "h2.text-dealCardTitle"), "current_price": text_of(card, 'div[data-testid="green-price"]'), "original_price": text_of(card, 'div[data-testid="strike-through-price"]'), "location": text_of(card, "h2 + div span"), "link": link["href"] if link else None, }) return deals
text_of 辅助函数一次做了两件有用的事:它在卡片内部查询单个元素,并在该元素缺失时返回 None,而不是对一个空值调用 .get_text() 而抛错。这在某张卡片上某个字段缺失时让提取保持健壮,而这很常见,因为并非每条优惠都列出划掉的原价或地点。链接是从一个锚点的 href 而非其文本中读取的,所以它被单独处理。
Groupon 的类名和 data-testid 属性会不经通知地改变。把上面的选择器当作一个起点模板,而不是一份契约。当某个字段对每张卡片都返回 None 时,在你浏览器的开发者工具里重新检查一条实时优惠并更新选择器。对任何生产环境的抓取器来说,定期维护选择器都很正常,并不是出了什么问题的迹象。
第 3 步:计算折扣
Groupon 有时会打印一个折扣徽章,但最可靠的节省额是你从已经提取的两个价格中推导出来的那个。从每个价格字符串里解析出数值,然后计算百分比。自己做这道数学题意味着无论页面是否显示徽章,这个字段在每张卡片上都是一致的。
import re def to_amount(price): if not price: return None match = re.search(r"[\d,.]+", price) return float(match.group().replace(",", "")) if match else None def discount_percent(original, current): o, c = to_amount(original), to_amount(current) if not o or not c or o <= 0: return None return round((o - c) / o * 100)
to_amount 辅助函数剥掉货币符号和千位分隔符,于是 "$45" 和 "$1,299.00" 都能干净地解析,并且在价格缺失时返回 None 而不是崩溃。discount_percent 在做除法之前防住了原价为零或缺失的情况,所以一张没有划掉原价的卡片只会得到一个 None 折扣,而不会抛错。在循环里用 "discount": discount_percent(original_price, current_price) 把结果并入每条记录。
第 4 步:把它拼起来
现在把抓取、解析和折扣计算串成一个可运行的脚本。抓取渲染后的 HTML,把它交给解析器,用计算出的折扣丰富每条记录,并把结果写入 JSON。
import json import re from crawlbase import CrawlingAPI from bs4 import BeautifulSoup api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"}) def crawl(page_url): options = {"ajax_wait": "true", "page_wait": 5000} response = api.get(page_url, options) if response["status_code"] == 200: return response["body"].decode("utf-8") 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 to_amount(price): if not price: return None match = re.search(r"[\d,.]+", price) return float(match.group().replace(",", "")) if match else None def discount_percent(original, current): o, c = to_amount(original), to_amount(current) if not o or not c or o <= 0: return None return round((o - c) / o * 100) def parse_deals(html): soup = BeautifulSoup(html, "html.parser") cards = soup.select('div[data-item-type="card"] > article') deals = [] for card in cards: link = card.select_one("a[href]") current = text_of(card, 'div[data-testid="green-price"]') original = text_of(card, 'div[data-testid="strike-through-price"]') deals.append({ "title": text_of(card, "h2.text-dealCardTitle"), "current_price": current, "original_price": original, "discount": discount_percent(original, current), "location": text_of(card, "h2 + div span"), "link": link["href"] if link else None, }) return deals def main(): page_url = "https://www.groupon.com/local/washington-dc" html = crawl(page_url) if not html: return deals = parse_deals(html) with open("groupon_deals.json", "w") as f: json.dump(deals, f, indent=2) print(f"Saved {len(deals)} deals") if __name__ == "__main__": main()
输出长什么样
用 python scraper.py 运行完整脚本,你会为每条优惠得到一条干净的结构化记录,可以直接写入 JSON、CSV 或数据库。
[ { "title": "55-Minute Couples Massage or 50-Minute Deep Tissue Massage", "current_price": "$65", "original_price": "$95", "discount": 32, "location": "4238 Wilson Boulevard, Arlington", "link": "https://www.groupon.com/deals/refresh-therapeutic-massage-3" }, { "title": "Spa Package with Glass of Wine at Spa Logic", "current_price": "$219", "original_price": "$330", "discount": 34, "location": "1721 Connecticut Avenue Northwest, Washington", "link": "https://www.groupon.com/deals/spa-logic-12" } ]
循环遍历分类页面并为请求控速
一个分类只是演示;一项真实的工作会跑遍若干城市或垂直领域。形态保持不变:保留一份分类 URL 列表,通过 Crawling API 抓取每一个,用同一个函数解析它,并收集这些行。因为每个分类页面共享相同的卡片结构,你已经写好的解析器无需改动就能跨所有页面工作。让一次长时间运行保持健康的那个习惯就是控速:在请求之间暂停,这样你就不会在一个紧凑的循环里猛攻 Groupon。
import time categories = [ "https://www.groupon.com/local/washington-dc", "https://www.groupon.com/local/new-york-city", "https://www.groupon.com/local/chicago", ] results = [] for url in categories: html = crawl(url) if html: results.extend(parse_deals(html)) time.sleep(3) with open("groupon_deals.json", "w") as f: json.dump(results, f, indent=2)
Groupon 还会把更多优惠藏在一个 "Load more" 按钮之后,而不是放在一个带编号的页面里。如果你想要折叠线以下的卡片,向 Crawling API 传一个指向那个按钮的 css_click_selector 选项,API 会在捕获页面之前点击它。在开发者工具里检查实时按钮以读取它当前的选择器,因为这个属性会像其余标记一样漂移。
保持不被封锁
即便渲染已经被处理好,Groupon 仍会监视带抓取器特征的流量。有几个习惯能让一次运行保持健康,它们适用于任何难啃的商业目标。
- 为你的请求控速。在一个紧凑的循环里猛攻分类页面,是被限流的最快方式。把请求摊开,并变换你的目标,而不是全速爬取一座城市。
- 依靠轮换。一池住宅 IP 把请求分散到许多真实用户的地址上,于是没有任何单个地址会触发速率限制。Crawling API 替你处理这一点;如果你自建技术栈,这就是需要做对的部分。
- 读取状态码。一次开始返回挑战或错误的运行,是在告诉你当前的速率或 IP 层级已经不够了。把它当作退避的信号,而不是可以无视的噪音。
关于更广的套路,参见如何抓取网站而不被封锁以及关于如何在网页抓取时绕过 CAPTCHA的更深入探讨。如果你的目标像 Groupon 那样是客户端渲染的,我们关于爬取 JavaScript 网站的指南解释了为什么渲染很重要。而如果你更愿意把自己的流量路由经过一个轮换池,而不是使用这个托管 API,Smart AI Proxy(也称 AI Proxy)以一个即插即用的代理端点为你提供同样的住宅 IP 轮换。
抓取 Groupon 合法吗?
抓取 Groupon 是否被允许,取决于 Groupon 的服务条款、你所在的司法管辖区,以及你拿这些数据做什么。Groupon 的条款对自动化访问设有限制,所以无论你的工具多么谨慎,抓取都可能与那些条款相抵触。这里的任何代码都不会改变这一点;它只是让技术部分能跑通。请阅读 Groupon 的服务条款及其 robots.txt,并把两者都当作你采集范围的边界。
有几条值得坚守的准则。只采集公开的优惠数据:任何人无需账户就能在分类页面上看到的标题、当前价格、原价、折扣、地点和链接。尊重 Groupon 所声明的速率预期,并把你的请求量保持得足够低,以免给它的服务器造成压力。如果你打算把数据用于商业用途,请取得许可或一份官方协议,而不是把沉默当作同意。
本指南刻意被限定在公开的优惠和分类页面上,因为那正是让这项工作站得住脚的边界。它不涉及任何登录墙之后的内容、账户或个人数据、客户详情、登录受限的页面,或任何绕过认证的尝试。只采集公开的优惠数据。如果你的项目需要的不止于此,那么一份官方数据协议才是正确的路径,而不是一个更聪明的抓取器。
核心要点
- Groupon 是客户端渲染的。一次普通抓取返回一个空框架,所以你必须先渲染页面再解析它。
-
你需要渲染和受信任的 IP 同时具备。带 JS token 的 Crawling API 在一次调用里两者都做到;
ajax_wait和page_wait控制它等待内容的时长。 - BeautifulSoup 负责提取。选中所有优惠卡片,然后从每张卡片读取标题、当前价格、原价、地点和链接,并预期选择器会漂移。
- 自己推导折扣。解析两个价格并计算节省额,比抓取一个并非总是存在的徽章更一致。
- 通过循环分类和控速来扩展规模。同一个解析器跨每个分类页面都能工作,所以一项真实的工作就是一份 URL 列表加上请求之间的一段睡眠。
- 只停留在公开数据上。尊重 Groupon 的服务条款和 robots.txt,绝不触碰账户、个人数据或登录受限的页面。
常见问题
为什么一次普通抓取返回不到 Groupon 的任何优惠?
因为 Groupon 用 JavaScript 在客户端渲染它的优惠卡片。初始 HTML 只是一个框架,只有在页面脚本于浏览器里运行之后才会被填满,所以一次裸 HTTP 请求返回状态 200,但卡片是空的。要拿到真实数据,你必须先渲染页面,而这正是 Crawling API 的 JS token 替你处理的。
对 Groupon 我需要普通 token 还是 JS token?
JS token。普通 token 获取静态 HTML,在 Groupon 上这就是普通抓取会返回的同一个空框架。JS token 先在一个真实浏览器里渲染页面再交回 HTML,于是当 BeautifulSoup 解析时,优惠卡片是在场的。
我如何抓取 "Load more" 按钮以下的所有优惠?
Groupon 把额外的优惠藏在一个按钮之后,而不是放在一个带编号的页面里。向 Crawling API 传一个带按钮 CSS 选择器的 css_click_selector 选项,API 会在捕获页面之前点击它,于是额外的卡片就在你解析的 HTML 里。在你浏览器的开发者工具里确认按钮当前的选择器,因为这个属性会随时间改变。
我的选择器对每张卡片都返回 None。变了什么?
几乎可以肯定是 Groupon 的标记。它的类名和 data-testid 属性会不经通知地改变,所以上个月还能用的选择器可能会失效。在你浏览器的开发者工具里重新检查一条实时优惠并更新选择器。对任何生产环境的抓取器来说,定期维护选择器都很正常。
我能从 Groupon 抓取账户或个人数据吗?
不能,而且本指南不涉及它。账户详情、个人数据,以及任何登录墙之后的东西都位于公开优惠页面之外,所以它们不在这里的范围内,并且与 Groupon 的条款相抵触。抓取登录受限的内容,或绕过认证去触及它,都不是这套方法的一部分。请坚持采集公开的优惠和分类数据。
在抓取 Groupon 时,我如何避免被封锁?
把你每个 IP 的请求速率保持得低,变换你的目标而不是循环爬取一座城市,并通过轮换住宅 IP 路由,这样就没有任何单个地址会触发速率限制。Crawling API 替你管理轮换和一个受信任的 IP 池;如果你自建技术栈,那就是需要投入的部分。盯着状态码,在你开始看到挑战时退避。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
