去年年底我接到一个活儿,要把团队在腾讯文档里攒了两百多个表格、文档和幻灯片统一备份到本地。一开始我老老实实用Selenium写自动化脚本,结果越写越痛苦:等待元素加载、处理弹窗、维护选择器、控制浏览器版本,每天都像在修一个永远修不完的补丁。直到有一天我打开了浏览器开发者工具,盯着Network面板看了半小时,突然意识到一个事——前端页面厉害归厉害,但它所有的操作最终都要落到那些HTTP请求上,那我为什么不直接分析这些请求,绕过浏览器,自己构造同样的调用呢?
这个思路后来帮我用一百多行Python替代了六百多行Selenium代码,批量导出的速度从每晚跑两小时变成三分钟跑完。这篇就把完整过程写出来,包括前端API的调用链路、鉴权机制、导出任务的处理逻辑、限流规避,以及我在这个过程里踩过的坑。
1. 被Selenium折磨一个月后,我盯上了浏览器Network面板
1.1 Selenium方案哪里让人崩溃
先交代下背景,我的需求其实很简单:把指定目录下的在线文档、表格、幻灯片全部下载为Office格式到本地。听起来是不是比写爬虫简单多了?但真用Selenium去操作腾讯文档网页版,你会发现处处是坑。
页面组件全是动态渲染,表格内容只有滚动到可视区域才会加载,Selenium的find_element时有超时;点击邮件通知、历史版本这类弹窗经常把界面遮住;更麻烦的是登录态维护——扫码登录后Cookie有时效,挂在服务器上跑定时任务根本没法自动续期。我试过把webdriver.Chrome的user-data-dir指向本地Chrome配置,试图借用浏览器登录态,结果频繁的浏览器升级又把ChromeDriver版本兼容性问题扯了出来。
很多人说Selenium是万金油,什么网页都能自动化,但“能跑”和“能稳定跑”是两码事。尤其面对单页应用,整个页面状态都在JavaScript里维护,Selenium每执行一步都在和前端渲染线程抢时间。我用显式等待解决了80%的超时,但剩下20%的偶发失败依然无法根除,而且每次跑完两百多个文件要将近两小时,完全是不可忍受的。
1.2 一次抓包改变思路
真正让我转变思路的是一个偶然动作。当时为了排查某个文档下载失败的原因,我打开了Chrome开发者工具,切到Network面板,勾选了Fetch/XHR过滤,然后手动在页面上点了一次“导出为xlsx”。我清楚看到浏览器发出了一串请求——先是一个创建导出任务的接口,接着是状态轮询,最后是一个下载地址。全程没有出现过任何一次对后端页面的真实访问,所有数据交换都是标准JSON。
这时候我脑子里冒出来一个想法:如果页面操作的本质就是这十几个HTTP请求,那我只需要模拟这些请求,就能复刻整个导出流程。浏览器只是负责渲染和交互的壳子,真正干活的从来都是API。Selenium最大的问题就在于它把大量的计算资源浪费在了“模拟人眼可见的界面”上,而这些界面交互哪怕全部跳过,对最终结果也没有任何影响。
顺着这个思路,我开始系统抓包分析腾讯文档Web端的API结构。这个过程不算难,但也绝对没有网上传的那么简单。下面把这套API调用逻辑完整拆一遍。
2. 腾讯文档前端API的调用链路拆解
2.1 鉴权设计:Cookie、CSRF与Referer
第一步要解决的问题是鉴权。腾讯文档的网页版登录态依赖两样东西:一个是主Cookie,另一个是请求头里携带的csrfToken。主Cookie在登录之后由服务器下发,包含若干个关键字段,浏览器会把它自动附加到同域请求上。关键是CSRF Token——它通常藏在页面的全局JS变量里,也可能是某个meta标签里,腾讯文档的做法是通过一个专门的初始化接口动态下发。
我抓包时发现,几乎所有写操作(创建导出任务、修改文件属性)的请求头都带了一个x-csrf-token字段,而请求体里也会带上相同的token。这两个值必须一致,否则服务端直接拒绝。这个token本身有过期时间,好在有效期足够长,实测几小时内不会失效,跑批量任务基本够用。
还有一个细节容易被忽略:导出请求的Referer必须设置为腾讯文档的域名页面。服务端会校验Referer来源,如果Referer是空的或者非法域名,会返回403。这个校验在早期版本里没有,后来某次改版加上去的。我在脚本里统一设置了:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://docs.qq.com/", "x-csrf-token": csrf_token, }2.2 文档列表接口与元数据结构
导出的第一步是先拿到你要导出的文档列表。如果你手动在网页上管理文档,分页浏览会反复请求一个列表接口。这个接口的URL路径类似/cgi-proxy/document_list(实际路径会随版本调整),参数是start、limit、type等分页和筛选条件,返回的是JSON结构,里面包含了文档的唯一标识fileId、标题、类型、更新时间、创建时间等字段。
我需要的核心字段是fileId,因为在腾讯文档的API体系里,所有针对单个文件的操作都依赖这个ID,而不是文档标题。标题只是给人看的,程序里必须用ID来定位文件。同一个接口也支持按目录过滤,比如我只想导出某个文件夹下的文档,传对应的folderId即可。
列表接口返回的分页逻辑需要注意:默认limit最大是100,超过了要加分页参数逐页拉取,否则服务端会截断数据。我在脚本里做了个循环,一页一页拉,直到拉到的条数小于limit,就认为到底了。
元数据结构里值得关注的还有fileType字段,它区分了doc、sheet、slide等不同类型。后面调用导出接口时,不同的文件类型对应的导出格式不一样——文档可以导出docx、pdf、md,表格可以导xlsx、csv,幻灯片是pptx、pdf。
2.3 导出引擎:创建任务、轮询结果、下载文件
文档列表拿到之后,真正的核心环节是导出。腾讯文档的导出机制不是简单的“传ID返回文件”,而是三步走:创建任务、异步轮询、下载文件。
创建任务阶段,脚本向/cgi-proxy/export/create发送POST请求,请求体里包含fileId、exportType等参数。exportType决定了你要导出成什么格式,比如表格的xlsx对应某个枚举值,pdf对应另一个值。服务端收到请求后不会立刻生成文件返回,而是返回一个taskId,表示导出任务已经在后台队列里了。
轮询结果阶段,脚本拿着taskId去请求/cgi-proxy/export/progress,这个接口会返回任务当前状态,包括等待中、处理中、成功、失败几种状态。由于超大文件导出需要一定时间,轮询不能只等一次,要隔几秒查一次,直到状态变为成功或失败。轮询间隔建议不要低于2秒,短了容易被限流。
下载文件阶段,当轮询接口返回成功时,响应里会带一个临时的文件下载URL。这个URL通常是某个对象存储域名下的签名地址,带有有效期,一般是10到30分钟。拿到URL后直接用requests下载即可。注意这个URL一旦过期就必须重新发起导出任务,所以下载逻辑里的一个判断要点是及时拿、及时下,不要在拿到URL后拖延。
3. 手写一个批量导出脚本
3.1 代码分层设计
搞清了API的调用链,写代码就成了力气活。我的脚本分了三层:第一层是会话层,负责处理Cookie、CSRF Token的获取与刷新;第二层是接口层,封装了列表、创建任务、轮询、下载四个核心函数;第三层是调度层,遍历文档列表,对每个文件执行完整的导出流程。
这种分层不是为了炫技,而是为了让排错更简单。接口层出了问题,可以直接在REPL里单测某个函数;会话层如果过期,有独立的重试逻辑;调度层负责控制节奏,同时承担日志记录。
3.2 准备登录态与CSRF Token
第一次拿Cookie没有特别巧妙的办法,我是在浏览器里手动登录腾讯文档,然后从开发者工具里把Cookie字符串复制到脚本的配置文件里。这种做法在日常运维中完全可以接受,关键是脚本要支持Cookie的快速替换,以及遇到401/403时能明确提示用户去更新Cookie。
CSRF Token我没用解析HTML的方式去抠,而是直接请求一个初始化接口拿JSON。实测下来这个接口在页面加载时会被调用,返回一个token字段,直接从JSON里取值即可:
def fetch_csrf_token(session: requests.Session) -> str: r = session.get("https://docs.qq.com/cgi-proxy/init_data") r.raise_for_status() return r.json()["token"]3.3 核心代码:文档列表获取
列表接口的封装要点在于分页。我把start和limit作为参数传入,每次最多拉100条,循环拉取直到不满一页:
def fetch_doc_list(session, start=0, limit=100): params = { "start": start, "limit": limit, "type": "all", } r = session.get("https://docs.qq.com/cgi-proxy/document_list", params=params) r.raise_for_status() return r.json()返回的JSON里,docs数组包含每个文档的fileId、title、fileType等字段。我在调度层维护一个待导出的任务队列,把title和fileId打包成tuple存进去。这里有个小坑:文件名里可能包含/、\、:等非法字符,Windows本地保存时必须做清洗,否则open()会直接抛异常。
3.4 核心代码:导出任务创建与轮询下载
创建任务的接口我封装成下面这个函数。export_type参数需要按文件类型映射:文档类通常传1代表docx、传2代表pdf;表格类传1代表xlsx;幻灯片类传1代表pptx。具体枚举值以抓包为准,不同版本可能有变化。稳妥做法是先抓一次包,看你常用的导出格式对应哪个值,写死到配置里。
EXPORT_TYPE_MAP = { "doc": {"word": 1, "pdf": 2}, "sheet": {"excel": 1, "pdf": 2}, "slide": {"ppt": 1, "pdf": 2}, } def create_export_task(session, file_id, export_type): payload = { "fileId": file_id, "exportType": export_type, } r = session.post("https://docs.qq.com/cgi-proxy/export/create", json=payload) r.raise_for_status() return r.json()["taskId"]轮询函数的要点是设置合理的超时上限。我默认最多轮询30次,每次间隔3秒,总共最多等90秒。如果一个文件超过90秒还没导出完成,大概率是文件太大了,我会在日志里记录一条警告,把它归入“慢任务”列表,下一轮再单独处理:
def wait_export_done(session, task_id, max_retry=30, interval=3): for _ in range(max_retry): r = session.get("https://docs.qq.com/cgi-proxy/export/progress", params={"taskId": task_id}) r.raise_for_status() data = r.json() if data["status"] == "success": return data["downloadUrl"] if data["status"] == "failed": raise RuntimeError(data.get("message", "export failed")) time.sleep(interval) raise TimeoutError("export task timeout")下载的逻辑不多说,拿到downloadUrl后直接用session.get流式下载,写入本地文件。注意文件名后缀要和导出的格式一致,否则打开文件时系统会不认识。
4. 请求频率与异常处理的经验边界
4.1 限流规则推测与请求退避
直接拿API刷接口和用Selenium操作页面有一个本质区别——API请求频率的“人味”很重。你用人手点网页,每秒最多点几次,而脚本可以一秒发几十个请求。服务端对异常高频请求有明显识别策略,我在测试阶段用无节制的循环跑过,百来个请求之后就触发了一次临时封锁,表现是连续十几个请求返回429,过十分钟才恢复。
建议的节流策略是:每次创建导出任务之前强制sleep(1)到sleep(2),列表接口拉取控制在每页之间隔0.5秒,轮询接口本身就自带3秒间隔,不用额外加锁。这样跑一百个文件的整体耗时才几分钟,完全够用,没必要冒着被封的风险去压缩时间。
另一个容易忽略的点是请求顺序。正常用户的操作习惯是“看一眼列表,点一个文档,导出,等结果,再返回列表”,请求序列是间隔性重复的。脚本如果一口气连发几十个创建任务请求,再统一轮询,服务器端的反自动化策略很容易告警。我把调度逻辑设计成“逐个导出”而非“并发导出”——每个文件完成下载后,再处理下一个。虽然牺牲了并发效率,但胜在安全稳定。
4.2 常见HTTP错误码与处理
下表是我在实际运行中遇到的HTTP状态码和对应的处理方式:
| 状态码 | 含义 | 处理方案 |
|---|---|---|
| 401 | 登录态过期 | 提示用户重新获取Cookie,退出当前任务 |
| 403 | Referer被校验拦截 | 检查请求头里的Referer是否设置正确 |
| 404 | 请求路径变了或fileId无效 | 更新接口路径,核对fileId |
| 429 | 触发限流 | 退避60秒后重试,最多重试3次 |
| 500 | 服务端异常 | 重试2次,仍失败则跳过该文件记录日志 |
我特意强调一下403的处理经验。早期脚本总是偶发性的返回403,排查了半小时发现是某个请求漏设了Referer,而漏设的请求刚好是创建任务的请求。因为列表接口的Referer校验不严格,但导出相关的接口校验很严格,所以同样的header配置在不同接口上效果不同。统一的做法是在构造requests.Session时就通过session.headers.update()设置全局默认请求头,而不是在每次请求时单独传。
4.3 大文件与特殊格式的处理
腾讯文档对超大文件的导出有独立的限制。我遇到过一个几百MB的表格,创建任务后轮询了五分钟才成功,下载时还要处理网络中断重连。所以下载函数不建议用response.text或一把梭的content,要用流式写入:
def download_file(session, url, dest_path): with session.get(url, stream=True) as r: r.raise_for_status() with open(dest_path, "wb") as f: for chunk in r.iter_content(chunk_size=8192): if chunk: f.write(chunk)流式下载的好处是边下边写盘,不会因为文件过大把内存撑爆。对于超过500MB的文件,建议额外加一个断点续传逻辑,不过实测腾讯文档的下载URL过期时间够长,正常网络下不至于传一半就失效。
特殊格式方面,腾讯文档里有一部分文件可能是“在线表格”之外的特殊类型,比如智能表格、脑图、流程图。这三类文件在列表接口里的fileType字段和普通类型不同,导出接口也不支持转成Office格式,只能导出为对应的私有格式。我在脚本里加了一个格式白名单,遇到不在白名单里的文件类型直接跳过,避免浪费任务额度。
5. API方案与Selenium方案实测数据对比
用API方案替换Selenium之后,我做了一个简单的对比测试。同一个文件夹下123个文档(含表格、文档、幻灯片三种类型),分别用两种方案跑,结果如下:
| 指标 | Selenium方案 | API方案 |
|---|---|---|
| 总耗时 | 约1小时47分钟 | 约4分钟 |
| 代码量 | 682行 | 156行 |
| 失败率 | 约8%(偶发元素未找到) | 约1%(偶发限流) |
| 浏览器依赖 | 依赖Chrome和ChromeDriver版本匹配 | 无 |
| 内存占用 | 单个浏览器进程约500MB | 约150MB |
| 维护成本 | 每次页面改版都要修选择器 | 接口路径偶尔变动,频率较低 |
这个对比能清楚看出来两者的本质区别:Selenium是在和前端渲染层较劲,而API方案直接和后端数据层对话。后者的效率优势不是一点点,而是数量级的差异。
当然,API方案也有它的短板。它依赖对接口路径和参数结构的理解,一旦腾讯文档后端调整接口格式,脚本就要跟着改。我在使用过程中遇到过两次接口调整,一次是列表接口的路径改版,一次是导出任务轮询接口的返回字段从status改成state。这种维护成本还是远低于Selenium那种“页面稍微加个弹窗就要重写选择器”的成本。
6. 踩坑记录与边界意识
6.1 我踩过的三个坑
第一个坑是Cookie过期没有预警机制。脚本跑到第160个文件时突然全部401,当时没做错误中断,后面几十个文件全白跑了。后来我加了元组返回重试逻辑——检测到401立刻停止当前任务循环,并输出醒目的提示日志。
第二个坑是文件名清洗。某个文档标题是“年度/Q1汇报/最终版”,保存到Windows本地时Open报错,排查了半天才发现是标题里的斜杠搞的鬼。后来我写了个sanitize_filename函数,把所有/\:*?"<>|字符统一替换成下划线。
第三个坑是并发误解。我想当然地用ThreadPoolExecutor同时并发导出多个文件,结果触发了一次挺严重的限流。后来改为单线程顺序执行,速度虽然慢了,但整体稳定性大幅提升。对于脚本工具来说,稳比快重要。
6.2 合规意识与使用边界
最后说一点合规的事。这套思路本质上是对腾讯文档公开Web API的分析和调用,它适用于处理你自己拥有权限的文档。如果你拿别人的文档链接去调用,会收到权限错误,因为鉴权阶段就过不去——这也说明服务端对文档权限的控制是严格的。脚本的去向应该是辅助你管理自己的文件,比如批量备份、批量转换格式,而不是破解他人文档的访问控制。
在使用过程中,我建议做好三件事:第一,控制请求频率,不要让脚本的行为特征明显偏离正常用户;第二,只读取和处理你具备权限的文档,不尝试越权访问;第三,把脚本的使用范围限定在个人备份、企业内部数据打通等正当场景。
6.3 后续还能怎么扩展
这套API分析思路其实不止能用来导出文档。文档列表接口拿到的fileId还能用于其他操作,比如批量修改标题、批量移动文件到指定文件夹、批量设置权限等等。如果你熟悉了腾讯文档的接口风格,会发现它整体的设计思路和很多云端协作平台类似:列表接口、任务接口、异步轮询接口、下载接口,这四个环节几乎能套用到所有云文档产品上。以后再遇到其他平台的批量导出需求,用同样的抓包分析方法就可以快速搞定。
我个人的习惯是把这套脚本做成一个命令行工具,支持指定目录导出、格式选择、增量备份(通过比对导出时间戳跳过未修改的文件)。增量备份这个功能特别实用,每周跑一次定时任务,只会导出新增和修改过的文档,大幅减少接口调用次数。如果你有跨平台使用的需求,把脚本里的会话层用环境变量管理,就能很方便地部署在Linux服务器或容器里定期执行。