AI Bot Scraper实战:突破SSE流与浏览器限制,结构化抓取ChatGPT对话
做数据分析的人大概都动过这个念头能不能把 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 产品的对话接口走的是 SSEServer-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 结构化抓取到底指什么一句话概括结构化抓取不是把屏幕上的文字复制下来而是把对话内容变成带字段的数据。比如一条完整的对话记录至少应该包含对话 IDconversation_id角色user / assistant消息 IDmessage_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, headlessFalse, channelchromium, args[--disable-blink-featuresAutomationControlled], ) 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, errorsignore) 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, headlessFalse, ) 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() 等流结束后一次性拿全量比实时捕获更稳定至少不容易丢片段。如果对实时性有要求才需要升级成在 CDPChrome 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, headlessFalse, ) 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, timeout60000) # 抓取所有消息 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 读。业务化场景建议写进 SQLiteimport 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 个问题每个问题分别用方案ADOM解析、方案BSSE捕获、方案C桌面端辅助各跑 4 次总计 600 次抓取动作。统计指标是成功率、单次平均耗时、结构化字段完整度和主观维护难度。需要说明的是这三个方案里方案C我只能做到“尽力而为”因为桌面端的数据加密和版本变动让我没法把这套流程完全自动化最后是用手动导出的方式取了部分数据。所以它的数据样本比其他两个方案少一些更偏向定性判断。4.2 实测结果指标方案ADOM 解析方案BSSE 捕获方案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 可能是一长串随机字符今天长这样明天长那样。我的建议有三个优先用语义化属性>