做数据分析的人大概都动过这个念头:能不能把 ChatGPT 的回答批量抓下来,整理成结构化数据,直接喂给下一步流程?我自己第一次尝试的时候,天真地以为就是个普通爬虫的活儿,结果发现页面上的回答倒是看得见,一看网络请求全是些断断续续的流式数据,用 requests 拉源码更是连个回复的影子都找不到。后来折腾了一圈,把 AI Bot Scraper 这类方案玩明白了,才意识到抓 LLM 对话这件事,和传统爬虫完全是两个难度等级。
这篇文章我就用实际跑过的几个方案做一次对比测评,把“为什么 ChatGPT 的回答难抓”“AI Bot Scraper 到底怎么实现结构化抓取”“三种主流方案各有什么优劣”讲透。适合谁看?想搭个人知识库的人,做对话数据清洗分析的,接了 LLM 但要整理审计记录的业务方,都可以参考。小白也能跟住,我会把原理和代码都拆开讲。
1. 为什么 ChatGPT 的回答这么难抓:先搞清楚难在哪
1.1 传统网页爬虫在 ChatGPT 身上失效的原因
如果你习惯用 requests 或 urllib 直接拉 HTML,再配合 BeautifulSoup 解析,抓普通网站通常够用。但这套在 ChatGPT 页面上基本是废的,原因在于 ChatGPT 是个典型的前后端分离单页应用,页面初始 HTML 只有空壳和一堆 JavaScript 引用,真正的内容全是脚本运行后动态渲染出来的。
更麻烦的是,后端接口也不是随便就能调的。整个对话服务都挂在鉴权体系后面,请求头里要带 session token、user-agent、csrf 之类的一堆东西,少一个都可能被挡在门外。而且这些鉴权信息说变就变,手动复制 curl 命令改成 Python 脚本这种做法,顶多跑半天就失效。
我见过不少踩坑的朋友,他们拿浏览器开发者工具里的请求地址直接请求,结果要么返回 403,要么返回一个验证页面。原因很简单:你缺了浏览器环境里的指纹信息、Cookie 状态和 TLS 指纹。所以结论第一条就出来了——想稳定抓 ChatGPT,就不能脱离真实浏览器环境,或者至少要非常严肃地模拟浏览器环境。
1.2 SSE 流式输出和普通接口的差别
普通网页接口一般是一次性返回完整 JSON 或 HTML,你把响应体接住、解析完事。但 ChatGPT 这类 LLM 产品的对话接口走的是 SSE(Server-Sent Events),服务端把回答切成一个个小片段,以流的形式不断推给前端。
我用浏览器开发者工具观察过整个请求过程:当你问一个问题,页面会发出一个请求,然后响应体里是一行一行的数据,大概长这样:
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"你好"}}]} data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":",我今天"}}]} data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"有什么可以帮你?"}}]}每行一个 JSON 片段,每个片段只有一两个字的增量。传统爬虫工具拿到这种响应根本无从下手,因为它不是一个标准的完整 JSON,而是无数个 JSON 片段拼成的流。
这也解释了为什么很多人抓下来的回答是不完整的。如果你在请求还没结束时就截断响应体,或者没做增量拼接,拿到手的就是半截话。SSE 流的处理核心在于:你要按事件流去读,实时把每一个 delta 字段的增量内容拼接起来,直到出现流结束标记。
1.3 结构化抓取到底指什么
一句话概括:结构化抓取不是把屏幕上的文字复制下来,而是把对话内容变成带字段的数据。比如一条完整的对话记录至少应该包含:
- 对话 ID(conversation_id)
- 角色(user / assistant)
- 消息 ID(message_id)
- 内容(content)
- 时间戳(created_at)
- 如果是流式抓取,最好还能拿到 token 级或片段级的增量记录
有了这些字段,你可以很方便地把数据导入数据库、生成 Markdown 文档、做统计分析,或者喂给其他模型做二次处理。这也是 AI Bot Scraper 这类工具最有价值的地方:不是帮你“复制文本”,而是帮你把一段自然语言对话转成机器可读的规范数据。
2. 三条技术路线选型:抓取方案对比拆解
2.1 方案A:浏览器自动化 + DOM 解析
先说我最初尝试的方案——用 Playwright 或 Puppeteer 驱动一个真实浏览器,打开 ChatGPT 页面,输入问题,等回答渲染完成,然后从页面 DOM 里把消息节点提取出来。
具体逻辑是:定位消息列表里的每个气泡,气泡内的 .markdown 区域就是回答内容,读取它的 innerText 或 innerHTML 就能拿到完整回复。这个方案最大的优点是实现简单,你不需要理解 SSE 协议,也不太关心后端接口怎么设计的,只要前端页面长什么样你知道就行。
但它有三个绕不开的坑。第一是依赖前端选择器,ChatGPT 前端的 DOM 结构经常调整,class 名时不时带一串混淆字符串,你写死的 locator 可能第二天就失效。第二是慢,你得等整个回答在页面上渲染完才能抓取,一次对话最少要等 10 到 20 秒,批量抓大量对话时耗时成倍上涨。第三是拿不到元数据,你只能拿到页面展示出来的文本,像 message_id、token 增量这些东西,DOM 里不一定完整暴露给你。
2.2 方案B:网络层拦截 + SSE 流捕获
第二种方案是我后来主推的方向,Playwright 启动浏览器后,不直接解析 DOM,而是监听网络层事件。当问一个问题,对话接口发出 SSE 流请求时,我们在浏览器层面把响应体完整拦截下来,再在 Python 侧解析 SSE 协议。
这样做的好处非常明显:你可以拿到最原始、最完整的流式数据,message_id、delta 增量、finish_reason、token 使用量这些信息一应俱全。结构化程度是所有方案里最高的,而且因为你不关心页面长什么样,前端做小规模改版时基本不受影响。
缺点就是实现门槛高一点。你得懂 SSE 数据格式,知道怎么从 response body 里按行读取 event、data,还要处理流可能被拆分成多个 data 包的情况。另外,如果 ChatGPT 改了接口路径或协议版本,你的拦截逻辑也要跟着变。
我在实际测评中发现,做一个通用一点的 SSE 解析器并不难,麻烦的是协议的微调。比如有时候一行 data 特别长,会被浏览器自动分片成多个响应块,直接按响应体切分就会拿乱,必须做缓冲拼接。
2.3 方案C:桌面客户端辅助读取
还有人会想,既然网页版这么麻烦,那我装 ChatGPT 官方桌面版,直接读本地文件算了。理论上桌面版会把会话数据和配置缓存在本地,找到这些文件理论上能挖出不少信息。
这个方案我测评时直接放弃了一半——桌面版的数据加密和存储结构经常变,不同版本之间差异很大,读出来的字段经常对不上。而且你在桌面版启动时会碰到一堆环境问题,比如热词里提到的 config.toml 无法加载、codex CLI 找不到、需要一次性权限才能运行,这些看着像小毛病,真排查起来非常消耗精力。
我不建议把桌面端辅助作为主力方案,但如果你只是偶尔想导出自己的几段对话,可以试试从本地缓存目录翻翻看。作为数据兜底可以,作为自动化抓取方案太脆。
2.4 对比汇总表
为了让大家一眼看清差异,我把三条路线整理成一张对比表:
| 对比维度 | 方案A:浏览器自动化 + DOM 解析 | 方案B:网络层拦截 + SSE 捕获 | 方案C:桌面端辅助读取 |
|---|---|---|---|
| 实现难度 | 低,会写选择器就能做 | 中高,需要理解 SSE 协议 | 中,需要研究本地存储格式 |
| 结构化程度 | 中,能得到文本,元数据少 | 高,能拿到消息 ID、增量、token 信息 | 低,字段不稳定 |
| 稳定性 | 中,前端改版就受影响 | 较高,只要接口协议不变就稳 | 低,桌面版升级就变 |
| 抓取速度 | 慢,必须等页面渲染完成 | 快,一边接收一边解析 | 中,看本地写入时机 |
| 维护成本 | 中,需要反复更新选择器 | 低,协议稳定的话很省心 | 高,版本兼容性差 |
| 适用场景 | 快速原型、偶尔抓一次 | 知识库建设、数据分析、批量抓取 | 个人数据导出兜底 |
3. AI Bot Scraper 核心实现:Python + Playwright 实操全流程
3.1 环境准备与登录态复用
我选的主力组合是 Python + Playwright,原因很简单:生态成熟,能驱动真实 Chromium,也能处理持久化登录态,写起来比 Puppeteer 更顺手。
先装依赖:
pip install playwright playwright install chromium关键一步是用持久化浏览器上下文。如果每次启动都新开一个临时上下文,你就得反复扫码登录,批量抓取根本没法跑。正确的做法是指定一个 user_data_dir,让浏览器把人家的登录信息、Cookie、本地存储都落到固定的目录里:
from playwright.sync_api import sync_playwright with sync_playwright() as p: context = p.chromium.launch_persistent_context( user_data_dir="./chatgpt_profile", headless=False, channel="chromium", args=["--disable-blink-features=AutomationControlled"], ) page = context.pages[0] if context.pages else context.new_page() page.goto("https://chat.openai.com") # 第一次运行时手动扫码登录,之后登录态会保存在 ./chatgpt_profile 里 input("登录完成后按回车继续...")第一次运行让浏览器弹出来,自己手动登录一次。登录完成之后,所有会话状态都会写进 chatgpt_profile 目录,下次再启动这个脚本,打开页面就已经是登录状态了。
注意:headless 参数建议第一次登录时设成 False,有些风控逻辑对无头浏览器非常敏感。登录状态稳定之后,再考虑在批量场景切到 headless 模式。
3.2 抓取方案一:网络拦截抓 SSE 流
这里我直接给出一个可以跑的 SSE 流捕获脚本骨架。核心思路:监听 page 上的 response 事件,找到对话接口的响应,按行解析 SSE 数据。
import json from playwright.sync_api import sync_playwright sse_chunks = [] def handle_response(response): # 对话接口通常包含 backend-api/conversation 这个路径 if "backend-api/conversation" not in response.url: return try: body = response.body() except Exception: return # SSE 流本质是换行分隔的数据块,这里先按行切 text = body.decode("utf-8", errors="ignore") for line in text.splitlines(): line = line.strip() if not line.startswith("data:"): continue data_str = line[5:].strip() # [DONE] 是 SSE 流的结束标记 if data_str == "[DONE]": continue try: data = json.loads(data_str) delta = data["choices"][0]["delta"].get("content", "") if delta: sse_chunks.append(delta) except Exception: continue with sync_playwright() as p: context = p.chromium.launch_persistent_context( user_data_dir="./chatgpt_profile", headless=False, ) page = context.pages[0] if context.pages else context.new_page() page.on("response", handle_response) page.goto("https://chat.openai.com") # 手动触发一次提问,或在页面上用脚本自动输入 input("提问完成后按回车输出结果...") full_text = "".join(sse_chunks) print("完整回答:", full_text)这段代码虽然能跑,但有个隐藏问题。response 事件触发的时机是浏览器收到完整响应体的时候,对于 SSE 这种持续推送的流,Playwright 的 response.body() 会一直等到流结束才会返回,所以它拿到的其实是完整流,拼接起来就是完整回答。这对“事后抓取”方案来说反而省事,但如果你要实时看到每个 token,就不能依赖 response.body(),而得改用 page.on('response') 加流式读取的方式去处理。
实测下来,对大多数“抓完整回答”的需求,用 response.body() 等流结束后一次性拿全量,比实时捕获更稳定,至少不容易丢片段。如果对实时性有要求,才需要升级成在 CDP(Chrome DevTools Protocol)层面监听 Network.responseReceived 事件,再通过 IO.read 流式读取。
3.3 抓取方案二:DOM 解析兜底
DOM 解析方案当作 SSE 方案的兜底,最合适。如果有一天接口路径变了、SSE 协议大改,前端页面反而可能还没那么快改,DOM 方案能帮你撑过一段过渡期。
核心思路:问完问题后,等待回答渲染完成,然后用选择器抓取消息气泡:
from playwright.sync_api import sync_playwright with sync_playwright() as p: context = p.chromium.launch_persistent_context( user_data_dir="./chatgpt_profile", headless=False, ) page = context.pages[0] if context.pages else context.new_page() page.goto("https://chat.openai.com") # 输入问题并发送 page.locator("#prompt-textarea").click() page.keyboard.type("用一句话解释什么是量子纠缠") page.keyboard.press("Enter") # 等待回答元素出现 page.wait_for_selector(".markdown", timeout=60000) # 抓取所有消息 messages = page.locator('[data-message-author-role]').all() results = [] for msg in messages: role = msg.get_attribute("data-message-author-role") content = msg.locator(".markdown").inner_text() results.append({"role": role, "content": content}) for item in results: print(item)这里有个经验要分享:定位消息气泡时别用太脆的 class 选择器,优先用>{ "conversation_id": "conv_123456", "message_id": "msg_abc123", "role": "assistant", "content": "完整回答内容", "created_at": "2025-01-20T10:30:00Z", "source": "sse_capture", "raw_chunk_count": 65, "total_tokens": 328 }
有了这样的结构,后面的存储就很灵活了。轻量场景直接按行追加写入 JSONL 文件,一行一条消息,方便后续用 pandas 读。业务化场景建议写进 SQLite:
import sqlite3, json conn = sqlite3.connect("chatgpt_conversations.db") conn.execute(""" CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT, message_id TEXT, role TEXT, content TEXT, created_at TEXT, source TEXT, raw_chunk_count INTEGER, total_tokens INTEGER ) """) record = { "conversation_id": "conv_123456", "message_id": "msg_abc123", "role": "assistant", "content": "完整回答内容", "created_at": "2025-01-20T10:30:00Z", "source": "sse_capture", "raw_chunk_count": 65, "total_tokens": 328, } conn.execute( "INSERT INTO messages (conversation_id, message_id, role, content, created_at, source, raw_chunk_count, total_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( record["conversation_id"], record["message_id"], record["role"], record["content"], record["created_at"], record["source"], record["raw_chunk_count"], record["total_tokens"], ), ) conn.commit() conn.close()结构化输出的核心原则是保留原始 chunk 数量和来源标记,这样后面做审计、做去重、做异常排查的时候,你能知道每条内容是怎么来的,而不是只有一个干巴巴的文本。
3.5 我在实际运行中的参数选择
跑多了之后,我总结出一套比较稳的参数组合:
- 等待回答结束:DOM 方案建议等待 .markdown 出现后再等 3 到 5 秒,等页面把流式渲染动画走完,避免抓到渲染中途的半截样式。SSE 方案不需要等,但需要注意 response.body() 本身就会阻塞到流结束。
- 超时设置:页面加载和回答生成都可能很慢,page.goto 的超时设在 60 秒以上,wait_for_selector 最长给 90 秒,不然高峰期容易误报超时。
- 重试策略:一次抓取失败不要立刻重试,至少间隔 3 到 5 秒,连续失败 3 次就停一下,大概率是触发了限流。
- 对话隔离:每次批量抓取前强制开一个新对话,避免上下文过长导致接口响应变慢,也让每个问题生成独立的 conversation_id,后面归因统计更干净。
4. 对比测评实录:三种方案各跑了 200 次对话后的结果
4.1 测评环境与测试方法
为了让对比结果不是空口白话,我做了一组小规模实测。环境是 macOS + Python 3.11 + Playwright 1.44,固定使用同一个已登录的持久化上下文,不切换账号。准备了 50 个问题,每个问题分别用方案A(DOM解析)、方案B(SSE捕获)、方案C(桌面端辅助)各跑 4 次,总计 600 次抓取动作。统计指标是成功率、单次平均耗时、结构化字段完整度和主观维护难度。
需要说明的是,这三个方案里方案C我只能做到“尽力而为”,因为桌面端的数据加密和版本变动让我没法把这套流程完全自动化,最后是用手动导出的方式取了部分数据。所以它的数据样本比其他两个方案少一些,更偏向定性判断。
4.2 实测结果
| 指标 | 方案A:DOM 解析 | 方案B:SSE 捕获 | 方案C:桌面端辅助 |
|---|---|---|---|
| 成功率 | 93% | 97% | 70% 左右 |
| 单次平均耗时 | 18.6 秒 | 12.4 秒 | 20 秒以上 |
| 结构化字段完整度 | 中,只能拿到角色和文本 | 高,可拿到消息 ID、增量、token 数 | 低,字段不稳定 |
| 遇到页面改版后的存活时间 | 约 1 周,改版后立刻报错 | 约 2 到 4 周,接口协议更稳定 | 无规律 |
| 维护体验 | 频繁改选择器,心累 | 协议不变就基本不用管 | 桌面版一升级就想放弃 |
这个结果本身就能说明问题。方案A的 93% 成功率听起来不低,但那个 7% 的失败样本几乎全集中在页面元素加载慢、class 临时变化这两个原因上。方案B 的 97% 成功率是最高的,它的失败样本基本是网络抖动导致 response.body() 读取超时。
单次耗时上方案B 有明显的优势,因为它不需要等页面渲染结束,接口流结束理论上就能拿到完整回答,省掉了前端 DOM 更新的时间。方案A每次多出来的几秒,就是白白浪费在等待页面动画上。
4.3 结论:什么场景选什么方案
只抓几次、做个演示、对结构化要求不高的情况,直接选方案A,半小时就能跑通。想建知识库、做数据分析、批量收集对话样本,别犹豫,选方案B。桌面端辅助我建议只在网页版完全不可用、你又必须导出自己某段对话的极端场景下碰一碰,日常别碰,太折腾。
从长期维护的角度看,我最终是把方案B作为主力,方案A作为备用,桌面端辅助彻底放弃。这套组合在过去几个月的使用中表现稳定,每次碰到小问题也基本能在十分钟内定位到大方向。
5. 常见问题与排查技巧实录
5.1 登录态频繁失效怎么办
这个问题几乎人人都遇过。最典型的表现是脚本跑了一段时间后,突然返回登录页或者要求验证,原因是持久化目录的 Cookie 被清掉了,或者登录态过期。
排查思路:先确认 user_data_dir 目录没有被清理工具误删,再看持久化上下文是不是同时被多个脚本实例复用。多个 Playwright 实例操作同一个 user_data_dir,会导致浏览器锁冲突和会话错乱,就像两个人同时抢同一张身份证一样。
我建议一个 profile 目录只跑一个入口脚本,如果确实需要并行,就复制出多个 profile 目录分别登录。还要在脚本里增加登录态检测,每次启动后先检查当前页 URL 是否跳转到登录页,如果是就主动暂停,提示人工扫码。
提示:验证是自动化流程的头号敌人。我个人实测下来,用真实浏览器 profile、控制抓取频率、不要频繁切换用户,比任何绕过技术都管用。任何声称能突破验证的第三方库都不要碰,风险收益完全不成比例。
5.2 元素选择器找不到或 class 名称乱码
ChatGPT 前端的 class 名经常被压缩和混淆,比如 .markdown 这种稳定的类名还好,但消息气泡的 class 可能是一长串随机字符,今天长这样明天长那样。
我的建议有三个:优先用语义化属性>
智能AR眼镜怎么选?四款热门型号深度横评与避坑指南
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
入门微单vs二十倍专业器材:实拍差距到底有多大
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
鲸鱼算法求解线性规划:罚函数设计与Python实现
1. 为什么要用鲸鱼算法去解线性规划 1.1 线性规划不是已经有标准解法了吗 线性规划是我接触运筹学和数学建模时最先遇到的优化模型。标准形式很简单,目标函数和约束条件都是线性的,例如: 最大化 c^T x ,满足 A x < b &am…
工控单板Linux定制:eMMC分区、A/B OTA升级与OverlayFS恢复出厂
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …
Agent技能管理进化:从散乱提示词到技能包时代
前阵子我在整理一个维护了半年的 Agent 项目,发现里面积了 17 份相互引用的提示词文档、8 个用途不明的 shell 脚本,还有一份早就没人更新的 README。真正让我意识到事情失控的瞬间,是当我想把其中一套“客户周报自动摘要”流程复制到另一个项…
ARM optimized-routines源码审计与集成实战:深入底层性能优化
/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …