先说结论:如果你有一堆腾讯文档需要定期备份,或者想把在线表格里的数据批量同步到本地做分析,直接在浏览器里点“导出”逐个下载,是最笨的办法。用 Selenium 模拟人去点,短期跑几个还行,一旦文档数量上来了,各种等待超时、元素定位失败、验证码弹窗就能把你折磨到怀疑人生。
我这次做的东西,说直白点就是:不去碰浏览器 UI,直接分析网页版腾讯文档自己调用的后端接口,用 Python 模拟请求把文档内容拉下来。整个过程不涉及任何“破解”或“越权”,只是把浏览器开发者工具里能看到的东西,换成代码去执行而已。这篇文章会把完整的分析思路、抓包方法、关键接口长什么样、以及批量导出时的性能与稳定性问题都讲清楚,适合被重复性导出工作折磨过、想彻底解放双手的开发者参考。
1. 项目整体思路:为什么必须绕过 Selenium
1.1 需求场景到底长什么样
先描述一下我遇到的真实场景。我在团队里负责维护一批运营文档,分布在好几个腾讯文档文件夹里,包含项目计划表、排期表、复盘记录、周报汇总,加起来大概有两百多篇。每周都要做一次全量备份,把内容同步到公司的文件服务器里做归档。
一开始的做法很朴素:登录腾讯文档网页版,挨个点开文档,点右上角菜单,找导出按钮,选择格式,下载到本地。两百篇文档,就算每篇只花 30 秒,也得将近两个小时。更别提鼠标手滑点错格式、下载到一半断网、某个文档权限异常导致页面打不开这类随机事件,每周光做备份就得占掉我小半天。
后来我想过用 Selenium 做自动化,写一个脚本驱动浏览器去逐个点按钮。实验结果证明,这条路能跑通,但维护成本和运行效率都极其感人。
1.2 Selenium 作为备选方案的致命伤
先说速度。Selenium 本质上是“遥控”一个真实的浏览器,每一步操作都有完整的浏览器渲染开销。打开一个文档页面,从发起请求到 DOM 渲染完毕,再到工具栏按钮真正可点击,通常要等 3 到 5 秒,这还是网络状况好的时候。两百个文档,光是等待页面加载的纯时间就超过 15 分钟。
再说稳定性。浏览器自动化最怕的就是页面结构变化。腾讯文档是典型的前后端分离应用,前端会不停迭代,按钮的class属性、弹窗的 DOM 层级、导出选项的排列顺序,随时可能调整。我经历过一次前端改版,导出菜单从鼠标悬浮弹出改成了点击弹出,所有基于hover的定位代码全部失效,脚本直接罢工。
最要命的还是风控问题。短时间内在同一个浏览器会话里高频打开、操作大量文档,很容易触发登录态异常校验。一旦弹出滑块验证或者要求重新登录,整个脚本就卡死了。Selenium 能做的事情,本质上和你手动操作一样,只是把点击换成了代码,它并没有降低操作频率,反而因为执行速度快,更容易触发防护机制。
1.3 换个思路:网页前端也是一个“客户端”
想通了 Selenium 的局限之后,我开始重新观察腾讯文档网页版的工作原理。打开浏览器开发者工具的网络面板,清空记录,然后随便点开一篇文档,你会发现页面在加载文档内容之前,会先发出若干条XHR或Fetch请求,返回的是 JSON 数据,页面靠这些 JSON 渲染出文字和表格。
这里有一个被很多人忽略的事实:网页版腾讯文档本质上就是一个“HTML 壳子 + JavaScript 客户端”,真正的内容数据全部来自后端接口。前端通过什么接口拿列表、通过什么接口拿文档内容、通过什么接口导出文件,全部可以通过开发者工具看得一清二楚。
那就好办了。既然前端能用这些接口拿到数据,那说明这些接口就是设计给“登录用户”用的。我只要保持登录状态,用代码直接调用这些接口,拿到 JSON 响应,再解析出内容,整个过程绕开了浏览器渲染,速度快到飞起,也不会因为页面结构变化而挂掉。
这就是我说“不用 Selenium 也能批量导出”的核心逻辑:UI 自动化是模拟人点击,接口分析是模拟数据请求。数据源头本来就摆在那里,直接从源头取数据,永远比模拟人去搬砖靠谱。
1.4 先明确技术边界,避免跑偏
这里必须先说清楚“逆向分析”的范畴。我做的是分析浏览器端正常发出的网络请求,属于前端开发者和爬虫工程师都熟悉的常规操作,目的仅仅是提高自己账号下文档的导出效率。整个过程不涉及对腾讯文档客户端代码的篡改、不涉及破解加密协议、不涉及绕过登录授权,也不涉及获取任何未授权数据。
有些人会把“逆向”理解为“破解”,然后开始琢磨怎么绕过滑块验证、怎么模拟登录态、怎么扫别人文档。这类内容我不想碰,也不会在这篇文章里出现。你拿代码去调自己的账号、自己的文档,这叫效率工具;你去调别人的数据,那就是另外一回事了。技术分析本身是安全的,用法才决定风险。
2. 抓包定位核心接口的完整过程
2.1 准备工作:一个浏览器和它的开发者工具
要用接口方式操作腾讯文档,第一步是搞清楚网页版到底调用了哪些接口。不需要什么特殊工具,Chrome 的开发者工具就够用了,我自己用的是 Chrome,Firefox 和 Edge 的开发者工具功能也完全足够。
我建议准备工作做两步:
- 浏览器里确保已经登录了腾讯文档,并且测试账号里有至少几篇不同类型的文档(表格、文字、幻灯片都来一个,方便对比接口差异)。
- 打开开发者工具(F12),切到 Network 面板。Network 面板里会持续记录浏览器发出的所有请求,包括图片、CSS、JS 文件、XHR 请求等,我们需要的是其中的
XHR/Fetch类型。
这里有一个小技巧:操作前先点击 Network 面板左上角的清除按钮(🚫图标,即“Clear”),把之前的请求记录全部清掉。这样待会儿产生的网络请求就干净多了,不会混入大量无关内容。
2.2 动手抓包:从文档列表到打开文档
准备工作完成之后,开始操作并观察请求:
- 在腾讯文档首页,停留在“最近查看”或者某个文件夹列表页面。
- 注意看 Network 面板,有没有新的
XHR请求出现?如果有,逐个点开看看它们的 URL 和响应内容。 - 你会发现某些请求的响应里包含了文档的标题、更新时间、文档类型、访问链接等信息。这一步定位的就是“文档列表接口”。
- 接着,随便点开一篇文档,观察页面加载过程中新增的 XHR 请求。响应里包含了这篇文档的正文内容、表格数据、样式信息等。
- 再操作一次“导出为 Word/Excel”,观察导出时是不是也有对应请求发出。
这一步的核心目标是建立直觉:列表页有列表接口,文档页有内容接口,导出工具有导出接口。大部分在线办公类产品都是这个套路,只是接口的路径、参数、返回格式各有不同。
2.3 如何从一堆请求里认出关键接口
一个正常的文档页面打开,网络面板里可能会有十几到几十个请求,不可能每个都去仔细看,要有针对性地筛选。
我习惯的做法是:
- 点击 Network 面板上的
Fetch/XHR筛选按钮,直接把图片、脚本、样式全部过滤掉。 - 按请求响应体积排序,重点看响应体积大的请求。文档内容通常都在大体积的 JSON 响应里。
- 逐个点击请求,切换到
Preview标签页,看 JSON 的层级结构。如果看到title、content、sheet、cell这类关键词,基本就锁定了。
腾讯文档的接口域名主要是docs.qq.com子域名下的一系列/cgi-bin/路径,具体是哪个路径,不同功能模块可能不一样。你在自己的抓包结果里能看到,我在这篇文章里不会把具体路径写死,因为前端会有版本更迭,接口路径和参数也可能调整。以你自己抓包拿到的实际请求为准,是这套方法能长期生效的关键。
2.4 登录态的获取与保持
在线接口的共性要求是:必须带着登录凭证才能返回数据。腾讯文档也不例外。浏览器里登录之后,所有请求的请求头里都会自动带上Cookie字段。Cookie 里包含了会话凭证,后端通过它识别当前用户是谁、有没有权限访问某篇文档。
所以,要在代码里调用接口,第一步就是把 Cookie 保存下来。
方法很简单:
- 在 Network 面板里,随便点开一个请求。
- 在
Headers标签页里找到Request Headers下的Cookie字段。 - 把整个 Cookie 字符串复制出来,保存到本地配置文件里,后续代码要用。
注意:Cookie 其实就是你的登录凭证,泄露 Cookie 等于泄露账号。写代码的时候,不要把 Cookie 硬编码到脚本里再传到公开仓库,建议用环境变量或者本地配置文件保存。
Cookie 的时效性也需要注意。正常登录状态下,腾讯文档的 Cookie 可能可以持续比较长的时间,但如果你退出了登录、换了网络环境、或者触发了风控,Cookie 就可能失效。那时候脚本会报 401 或者返回登录跳转的 HTML,而不是 JSON 数据。遇到这种情况,重新去浏览器里登录,再复制一份新 Cookie 替换即可。
3. 腾讯文档 API 调用实战
3.1 请求协议长什么样
把抓包结果整理一下,腾讯文档网页版的接口调用方式可以归纳为:
- 大部分核心接口是
POST请求。 - 请求地址是
https://docs.qq.com/...下的某个/cgi-bin/路径。 - 请求头需要带
Cookie,有的接口还会校验Referer或User-Agent,建议把抓包里看到的请求头信息尽量带全。 - 请求体一般是
application/json或表单格式,里面包含文档 ID、分页信息、操作类型等参数。 - 响应体通常是一个 JSON 对象,外层有
ret/code/msg之类的状态字段,内层是实际数据。
这里我不贴死某个接口的完整参数表,因为腾讯文档前端更新后接口参数极有可能调整。我提供的是一个经过验证的“请求模板”,你用自己的抓包结果去替换参数名和路径,就能在几分钟内跑通。
用 Python 的requests库做一个示例,先设置好会话:
import requests SESSION = requests.Session() COOKIE = "粘贴你的Cookie" BASE_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Referer": "https://docs.qq.com/", "Cookie": COOKIE, "Content-Type": "application/json", }把SESSION.headers设置成BASE_HEADERS,后续所有请求都会自动带上这些请求头。
3.2 文档列表接口:拿到全部文档的索引
批量导出的前提是知道要导出哪些文档。我抓包时发现,在文档列表页面滚动加载的过程中,会不断有请求发出,请求的响应里包含了文档的title、url、doc_type、edit_time等字段。这个接口就是列表接口。
列表接口一般支持分页,请求体里有类似offset、page_size、folder_id或doc_list之类的参数。我的建议是先用小参数(比如page_size=20)试探,拿到响应后,观察返回的 JSON 结构,再调整参数。
一次接口返回的数据结构大致长这样(这是我抓包后整理出来的通用形态,具体字段名以你实际抓包为准):
{ "ret": 0, "data": { "list": [ { "docId": "xxxx", "title": "2024年度运营计划", "url": "https://docs.qq.com/doc/xxxx", "type": "doc", "editTime": 1710000000 } ], "total": 234 } }解析逻辑可以写成一个函数,不断按分页参数往下翻,直到取完total数量为止。这个列表就是后续批量导出的“任务队列”。
3.3 文档内容接口:拉取正文字符串
拿到文档 ID 之后,下一步是获取文档内容。在浏览器里点开一篇文档,网络面板里会有一个请求的响应里包含大段的正文内容,这就是文档内容接口。
不同文档类型(在线文档、表格、幻灯片)的内容接口返回格式差别比较大:
- 在线文档返回的一般是结构化 JSON,包含段落、标题、文本样式、图片资源引用。
- 在线表格返回的多是单元格的字典结构,键是行列坐标,值是文本内容。
- 幻灯片返回的则是每一页的对象列表,包含文本框、图形、图片信息。
解析时先别急着写完美方案,我建议先打印出原始 JSON 的 key 层级,再用jsonpath或直接遍历的方式把文本内容提取出来。对于在线文档,最常见的方式是遍历结构化块里的text字段并拼接。示例代码:
def extract_doc_text(content_json): # 伪代码:根据实际返回结构调整 blocks = content_json.get("data", {}).get("blocks", []) lines = [] for block in blocks: text_parts = block.get("text", {}).get("elements", []) line = "".join(el.get("text", "") for el in text_parts) if line: lines.append(line) return "\n".join(lines)这一步是整个项目最“脏活累活”的部分,因为不同模板、不同编辑历史产生的 JSON 结构可能略有不同。我的建议是:先做一个“调试模式”,把原始 JSON 输出到本地文件,人工观察结构,再针对性写解析代码。完全靠猜是猜不出来的。
3.4 保持会话:Cookie 过期与自动重试
接口调用过程中,最常见的问题就是请求返回登录跳转。这里的表现很有意思:用requests直接请求接口,如果 Cookie 失效,接口可能不会返回 401 错误码,而是返回一段 HTML(登录页)或者一个要求重定向的 JSON。写代码时一定要做一层判断,检查响应的Content-Type和首字节,如果发现不是 JSON,就立刻中止并提示重新登录。
我用的统一封装函数大致是这样:
import requests from requests.exceptions import RequestException def api_post(url, payload): try: resp = SESSION.post(url, json=payload, timeout=15) content_type = resp.headers.get("Content-Type", "") if "json" not in content_type: raise RuntimeError("接口未返回JSON,可能登录态失效,请刷新Cookie后重试") data = resp.json() if data.get("ret") != 0: raise RuntimeError(f"接口业务错误:{data.get('msg')}") return data except RequestException as e: raise RuntimeError(f"网络请求失败:{e}")这个函数把“网络异常”和“业务异常”分开处理,外层调用时统一捕获RuntimeError并做重试或记录日志,稳定性会好很多。
3.5 参数中的签名与加密字段怎么处理
抓包时你可能会发现,某些请求的请求体里带了一两个看起来像加密串的参数,比如sign、token、nonce、biz_id。很多初学者一看到这种字段就头大,觉得必须去逆向 JS 才能做出来。
其实不用慌,先看清楚这些参数到底是怎么生成的。我把参数分成三类:
- 固定参数:抓包里是多少就是多少,直接写死。
- 跟登录态绑定:Cookie 变了它才变,代码里直接沿用 Cookie。
- 前端计算生成:比如把当前时间戳、文档 ID 做某种组合再哈希,需要读 JS 才能复现。
我的经验是,腾讯文档网页端的核心接口大部分用的是前两类,只要带着合法 Cookie 和正确的文档 ID,请求就能成功。只有极少数特定场景(比如某些导出操作用到前端预计算内容),才会需要去看第三类参数。即便遇到第三种,也建议优先考虑“这个接口是不是有替代方案”,而不是一头扎进 JS 逆向里。
能通过抓包复制参数解决的问题,就别去写 JS 逆向代码,这是时间性价比最高的工作方式。
4. 批量导出落地的工程细节
4.1 导出为本地文件:从 Markdown 到 PDF
接口拿到的原始内容是 JSON 结构,不是最终的 Word 或 PDF 文件。所以“批量导出”本质上要分成两步:先从接口拿内容,再把内容转换成目标格式。我选择了两条技术路线:
- 文本类文档统一转成 Markdown,转成一个带格式的
.md文件。好处是纯文本体积小、易归档、后续要转成 PDF 或 Word 也方便,用pandoc一条命令就能搞定。 - 表格类文档保留原始单元格数据,导出为 CSV 或 Excel(用
openpyxl写入.xlsx)。
如果你确实需要导出成官方 Word 或 PDF 格式,理论上可以继续分析网页版的“导出”按钮对应的接口。但我试下来,这个接口对参数要求更繁琐,而且返回的文件可能需要请求另一个下载 URL,链路更长。对于备份场景,Markdown 和 Excel 已经足够,没必要为了格式一致性去硬啃导出接口。
Markdown 转换的逻辑其实很直白:拿到结构化文本块之后,根据块的类型判断标题层级(h1、h2、正文、列表),拼成 Markdown 字符串。
4.2 并发控制:快,但不能过于快
批量导出的话题绕不开并发。文档数量多的时候,串行请求会比较慢,但无脑开多线程也不是好主意。我测试过几种策略:
| 策略 | 速度 | 稳定性 | 适用场景 |
|---|---|---|---|
| 全串行 | 最慢 | 最稳 | 文档量少,不追求速度 |
| 固定线程池(3~5线程) | 较快 | 较稳 | 文档量中等 |
| 无限制异步 | 最快 | 极易触发风控 | 不推荐 |
我最终选了线程池(ThreadPoolExecutor)设置 4 个工作线程的方案。4 个线程同时跑,每个文档的平均耗时大约从串行的 0.8 秒降到 0.25 秒左右,两百个文档一分钟内能跑完。再往上加线程,速度提升有限,反而容易因为请求频率过高触发接口限流。
代码结构和伪代码如下:
from concurrent.futures import ThreadPoolExecutor, as_completed def export_one_doc(doc): doc_id = doc["docId"] title = sanitize_filename(doc["title"]) try: content = fetch_document_content(doc_id) save_to_markdown(title, content) return (title, "ok", "") except Exception as e: return (title, "failed", str(e)) with ThreadPoolExecutor(max_workers=4) as executor: futures = {executor.submit(export_one_doc, doc): doc for doc in doc_list} for future in as_completed(futures): title, status, err = future.result() print(f"{title} => {status}")4.3 增量备份与断点续传:处理重复导出的问题时
批量导出的一个经典问题是:我每周都要跑一次,如果只是重新拉取所有文档,不仅慢,还可能把之前已经改好的本地文件覆盖。所以要做一个增量判断。
我的做法是,在本地为每篇文档保存一个元数据文件(或直接写到 SQLite 里),记录文档 ID 对应的edit_time和本地文件路径。每次批量导出前,先调列表接口把所有文档的edit_time拉下来,与本地记录对比,只有更新时间比本地新时才重新拉取内容。
这样实现了“断点续传”和“增量更新”两种能力。即使某一次运行到一半挂了,下次跑的时候,已经成功的文档会直接跳过,只有没跑过的、以及更新过的文档才会重新拉取。这个设计带来的体验提升极其明显——从每周全量跑 2 分钟,变成了通常只需要跑十几秒。
4.4 日志与告警:跑挂了能立刻知道
脚本跑到一半失败,最怕的是默默失败,没有任何提示,等发现的时候数据已经缺了一大片。所以我专门加了一个轻量日志模块,把每次导出的结果、失败原因、耗时统一登记到日志文件和 CSV 表格里。
日志记录的核心字段:
- 文档 ID、标题、文档类型
- 开始时间和结束时间
- 状态(成功/失败)
- 失败原因(超时/解析异常/登录失效/业务错误)
- 重试次数
有了日志,排查问题会变得非常高效。比如某个文档一直失败,你能看到失败原因是“权限异常”,那大概率是这篇文档被移除了分享权限或者已经删除,人工确认一下就好。如果全是“登录失效”,那就是 Cookie 过期,重新登录即可。如果全是“请求超时”,那就是网络环境的问题。批量任务最怕的就是黑盒运行,日志是维护这类脚本的必修课。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 问题表现 | 可能原因 | 解决方案 |
|---|---|---|
| 接口返回 HTML 而非 JSON | Cookie 过期或登录态失效 | 重新登录,刷新 Cookie |
| 请求返回 403 | 缺少 Referer 或请求头不全 | 补全从抓包中复制的完整请求头 |
| 返回 JSON 里没有内容字段 | 该文档类型与预期解析结构不匹配 | 打印原始 JSON,调整解析逻辑 |
| 响应很慢或超时 | 单线程串行+网络波动 | 增加超时时间,用线程池并发 |
| 拉取数量有限,翻页不全 | 分页参数没带对 | 检查列表接口的分页字段,逐步调试 |
| 某些文档导不出来 | 文档权限异常、文档被删除、或被移入回收站 | 日志标记,人工复核 |
5.2 最容易被忽视的小坑
说几个我踩过的、不太会遇到但也可能栽跟头的细节:
第一个是文件名非法字符。Windows 文件名里不能有\ / : * ? " < > |,而文档标题里面出现/和:的概率非常高。不处理直接写入本地文件,会直接抛 OSError。我写了一个清洗函数,把这些字符替换成全角字符或者下划线。
第二个是图片资源的处理。文档内容接口返回的文本一般没问题,但图片往往是一个 URL 或者一个资源 ID。如果导出的 Markdown 里直接引用外链图片,本地归档后图片可能因为外链失效而变成死图。比较稳妥的做法是同时下载图片到本地,然后把 Markdown 里的图片路径改为本地相对路径。这一步会让代码复杂一些,但对长期归档很重要。
第三个是接口内部的默认参数调整。列表接口有的会按“最近查看”排序,有的会按“创建时间”排序,如果业务上依赖某个固定顺序(比如按文件夹分组导出),需要在请求参数里显式指定,不能依赖接口默认值,否则导出的文件顺序会和你预期不一致。
5.3 合规边界与长期使用建议
最后认真说一句:接口分析类工具,最大的风险不是“跑不通”,而是“用错地方”。我一直强调,这套方案只能用来操作自己账号下有权限的文档。判断标准很简单:你在浏览器里能正常打开、能正常导出的文档,才属于可以用接口去批量操作的文档。凡是你在浏览器里都看不到、没权限访问的内容,接口也不可能正常返回,强行去绕权限就是个错误方向。
合规层面的另一个注意事项是:个人开发脚本用于自动化操作,属于个人效率工具范畴,但如果要在公司内部大规模推广,建议提前与所在组织的信息安全负责人确认。毕竟批量请求对服务端会有额外负载,也需要确认与平台服务条款的兼容性。
从长期使用角度,有几个建议:
| 建议 | 原因 |
|---|---|
| 不要把接口路径和参数写死在代码里 | 前端更新后很容易失效,集中维护抓包结果更省心 |
| 定期检查 Cookie 有效期 | 被踢下线时及时发现,而不是等到大规模失败后才察觉 |
| 保持中低并发 | 减少对服务端的压力,也降低触发风控的概率 |
| 关键数据先跑存量备份,再搞增量同步 | 保证零丢失的底线下再去追求效率 |
| 平时多观察接口返回的字段变化 | 早发现问题,早调整解析代码,避免精神内耗 |
最后再分享一个小技巧
我在实际使用中发现,真正让“接口分析”比“Selenium 自动化”体验好一大截的,不仅仅是速度,而是排查问题的颗粒度。Selenium 的脚本一旦报错,你要去截图看浏览器到底停在哪个页面、哪个按钮没点着,通常得反复调试好几分钟才能定位。而接口调用的报错,返回结果里直接写着失败原因,要么是参数不对,要么是没权限,要么是登录态失效,一眼就能盯出来。
另外一个花小钱办大事的建议:把“列表接口拉取文档元数据”和“内容接口拉取正文”这两个环节彻底拆开。前者非常快,可以在任何时间高频执行,用于监控文档是否更新;后者比较重,只在检测到更新时执行。这样一来,即使内容接口因为某些原因挂了,你也依然有一份最新的文档索引和更新时间表,可以非常从容地去排障,而不是像以前一样对着一个秃掉的文件列表干瞪眼。
这套方案我已经稳定运行了几个月。最初的动机只是不想再每周花两个小时做机械备份,后来慢慢整理成了结构清晰的脚本,中间也换过几次解析逻辑,但核心思路始终没变:浏览器只是数据入口的后台,直接和后台对话永远是最短路径。如果你手里也有一堆在线文档需要定期处理,不妨按这个思路先把接口抓出来,你会发现,原来让你烦躁的“批量导出”,其实几分钟就能搞定。