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 供你解析。

为什么用 JS token

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 当作密码对待:它用于认证你的请求,所以不要把它放进版本控制。

搭建项目

创建一个虚拟环境,让项目依赖保持隔离,然后安装抓取器需要的两个库。

bash
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。在解析之前检查状态,能让失败大声暴露而不是悄无声息。

python
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 运行脚本,你应当看到真实的优惠标记,而不是普通抓取返回的那个空框架。这在你写下第一个选择器之前就确认了渲染正常工作。

Crawlbase Crawling API

Groupon 需要在一个受信任的 IP 之后、在一次调用里得到一个渲染后的页面。Crawling API 接收一个 JS token,在一个真实浏览器里运行页面,在服务端轮换住宅 IP,并把已完成的 HTML 交给你,于是你免去了自己运行无头舰队和代理池的工作。先在免费额度上把它指向一个公开的城市优惠页。

第 2 步:用 BeautifulSoup 解析优惠卡片

拿到渲染后的 HTML 后,把它载入 BeautifulSoup,并按选择器取出每条优惠。Groupon 把它的优惠卡片排布成一个重复的结构,所以你一次性选中所有卡片,然后从每张卡片里读取相同的字段。在你浏览器的开发者工具里检查实时页面,确认当前的类名;下面的选择器与撰写本文时的布局相匹配。

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_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 有时会打印一个折扣徽章,但最可靠的节省额是你从已经提取的两个价格中推导出来的那个。从每个价格字符串里解析出数值,然后计算百分比。自己做这道数学题意味着无论页面是否显示徽章,这个字段在每张卡片上都是一致的。

python
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。

python
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 或数据库。

json
[
  {
    "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。

python
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_waitpage_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 次请求免费,无需信用卡。

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