资讯动态

语雀文档批量导出工具:API自动化备份Markdown与图片

发布时间:2026/10/13 10:36:10 来源:尧图企业网站定制
1. 为什么要从语雀导出一份本地副本经常泡语雀写文档的人应该都有过这种瞬间想在本地做一次全量备份结果发现官方提供的导出能力非常有限要么只能一篇一篇手动来要么导出的格式丢三落四。我自己是重度知识库用户前后往语雀里塞了上千篇文档包括各种带图的技术笔记、会议纪要、产品方案。某个周末我突然意识到这些内容虽然一直放在云上但万一平台策略调整、账号异常或者我想迁移到别的笔记系统手里居然没有一份完整的本地资产。于是就有了这个文档导出工具的想法。这件事的核心不是“把几篇文章下载下来”而是解决三个痛点批量拉取全量文档、保留 Markdown 原格式、把图片和附件一并落盘。很多第三方工具只能做到前半段导出之后图片还是挂在线上地址一旦源站变动就全部失效等于白干。我做的这个工具就是要把知识库完整地“搬”到本地让它成为一份真正自包含、可离线阅读、可迁移的数据资产。这篇内容适合三类人一是语雀重度用户想在本地做备份或迁移二是想了解怎么基于网页端接口写自动化导出脚本的开发者三是对批量文件处理、并发下载、格式转换感兴趣、想抄一套现成方案的人。我会把方案选型、核心实现、踩坑记录都摊开讲代码和思路都能直接复用。2. 方案选型三种路线的比较动手之前我先梳理了一遍市面上已有的做法避免重复造轮子。实际调研下来大致可以分成三条路线各有各的适用场景。2.1 路线一平台自带的导出能力语雀本身提供了文档导出功能可以在一篇文档的菜单里选择导出 Markdown 或 PDF。对于只有几篇文档的用户这种方式完全够用点几下就能拿到文件。但问题是它没有提供“全量批量导出”的入口知识库有几百篇文档时手动一篇篇导出是不现实的。而且我实际用下来发现个别文档包含复杂表格、画板、思维导图时导出的 Markdown 会丢失部分结构图片虽然能下载但命名规则不统一附件也经常漏掉。所以这条路只适合低频、少量、简单内容的场景。2.2 路线二基于网页端接口写自动化脚本这是我最终采用的方案。思路很简单人在浏览器里操作语雀时页面本身就在调用一组后端接口我只需要分析出这些接口的请求规律带上自己的登录凭证去访问就能拿到目录树、文档正文、图片地址等数据再批量写入本地。它的优点很突出接口返回的数据结构是原始的、完整的Markdown 正文能原样拿到可以做全量批量处理可以完全自动化。缺点是需要一点抓包分析和代码能力而且接口字段不是公开文档化的平台调整时需要跟着改。这个方案不涉及伪装登录、破解之类的灰色操作本质就是把自己账号能看到的内容下载到本地和手动逐篇导出的区别只是“自动化”而已。2.3 路线三浏览器 UI 自动化用自动化工具模拟人的点击操作打开每一篇文档再触发浏览器的“另存为”。这条路线理论上能做到全量导出但实际体验非常差要等页面渲染、要处理弹窗、要控制下载路径速度慢不说稳定性也很差。我试过用自动化脚本连续跑一百多篇文档中途频繁出现元素定位失败、页面加载超时的问题。除非实在拿不到接口数据否则我不推荐这条路线。它只适合对接口完全不了解、又没有代码能力硬要用现成自动化工具凑合的场景。2.4 我最终的选择最终我选了路线二用 Python 写脚本直接对接网页端接口。原因有三个第一数据完整度高。接口返回的文档正文是结构化数据能拿到原始的 Markdown 或编辑器内部格式比页面渲染后的结果更干净。第二可控性强。下载图片、重建目录、处理重名文件这些逻辑完全握在自己手里想怎么处理都行。第三维护成本相对可控。语雀的网页端接口结构比较稳定只要不频繁改版脚本可以一直用。退一步说就算接口字段变了重新抓一次包分析的成本也不高。3. 核心实现细节这一节是整个工具的重头戏。我会把实现过程拆成几个环节逐个说明思路、关键代码和注意事项。整个过程概括起来就是四步获取登录态、拉取目录结构、下载文档正文、批量保存图片附件。3.1 登录态获取与请求头构造所有接口请求都必须带上登录凭证否则拿不到任何私有数据。我采用的方案是用浏览器登录语雀打开开发者工具找到任意一个接口请求从请求头里复制 Cookie把它写进脚本的配置里。这里有一个细节不同浏览器的 Cookie 格式可能略有差异但本质上就是一堆键值对直接用浏览器复制完整 Cookie 字符串是最稳的。有人喜欢改成字典格式再传但我实测下来直接复制原始字符串效果更好不容易漏字段。请求头里还需要带上User-Agent和Referer。Referer尤其重要部分接口会做来源校验不带或带错会直接返回 403。建议直接把浏览器里复制的完整请求头贴过来只改动态变化的 Cookie 部分就行。注意Cookie 本质就是一个会话凭证不要把它提交到公开仓库里也不要发给别人。脚本写完以后建议用环境变量或单独不提交的配置文件存储。3.2 目录树与文档列表的获取语雀的文档组织方式是“知识库仓库 - 目录层级 - 文档”所以第一步要先枚举出账号下有哪些知识库再递归拿到每个知识库的完整目录树。这一步有一个很关键的思路目录树接口通常一次就能返回整棵嵌套结构不需要一篇一篇地猜 URL。数据结构大体是递归嵌套的节点列表每个节点包含type字段用来区分是文档还是分组。拿到之后我会把它扁平化成“路径 - 文档ID”的映射表方便后面下载正文时直接定位。伪代码如下def flatten_tree(nodes, parent_path): result {} for node in nodes: node_type node.get(type) title sanitize_filename(node.get(title)) current_path f{parent_path}/{title} if node_type doc: result[current_path] node.get(id) elif node_type group: result.update(flatten_tree(node.get(children, []), current_path)) elif node_type file: # 附件节点 result[f{current_path}/{filename}] node.get(url) return result这段代码做了两件事把嵌套结构变成扁平映射同时对标题做了文件名清洗。标题清洗很重要Windows 和 macOS 不允许文件名里出现/ \ : * ? |这些字符而文档标题里偏偏很容易出现斜杠或冒号不处理的话保存时会直接报错。3.3 正文抓取与 Markdown 还原拿到文档 ID 之后下一步就是请求文档详情接口。这个接口的返回值里通常有两个关键字段一个是标题一个是正文内容。正文的格式有两种可能直接返回 Markdown 字符串或者返回编辑器内部的结构化数据。我在实际使用中遇到的是直接返回 Markdown 字符串的情况所以处理起来最简单——拿到后直接保存成.md文件即可。如果遇到结构化数据需要自己遍历节点树重新拼接 Markdown工作量会大一些但逻辑上也不复杂就是几个常见节点的转换规则。import requests def fetch_doc_content(doc_id, headers): url fhttps://api.example.com/doc/{doc_id} resp requests.get(url, headersheaders) data resp.json()[data] return data[title], data[body]请求频率要注意控制。我一开始是用 for 循环顺序下载每一篇之间间隔零点几秒速度虽然不快但胜在稳定。后来改成有限并发之后仍然在每批请求之间加了小延时防止触发限流。3.4 图片与附件批量落地文档正文里的图片默认是 Markdown 格式的图片链接![](url)这些 URL 指向的是线上 CDN 地址。如果只是把 Markdown 存本地图片并不会跟着下来所以必须做两步处理第一步解析 Markdown 正文用正则把![](...)里的地址全部提取出来第二步逐个下载图片并把正文里的线上地址替换成本地相对路径。一个很隐蔽的坑是图片 URL 可能是相对路径也可能带了很长的签名参数。在替换时要注意拼接规范否则本地打开会显示裂图。举一个简单例子假设图片下载到assets/目录正文所在文件是notes/xxx.md那么相对路径应该写成../assets/xxx.png而不是assets/xxx.png差一个../就会让图片失效。附件也是一样的逻辑。语雀知识库里有附件节点下载后一般放到独立的attachments/目录并在正文里保留或添加对应的相对链接。这样整个知识库就是一个自包含的文件夹拷到哪都能看。3.5 并发控制与目录重建串行下载几百篇文档确实太慢我实测每篇平均要一到两秒全部跑完可能要半个小时以上。所以我把下载逻辑改成并发执行但控制最大并发数在 5 左右避免把接口打得太快触发限流。Python 里控制并发最顺手的方式是ThreadPoolExecutor配合Semaphore限制最大并发数或者直接用concurrent.futures的max_workers参数。from concurrent.futures import ThreadPoolExecutor, as_completed def download_all(doc_map, headers, max_workers5): with ThreadPoolExecutor(max_workersmax_workers) as executor: futures { executor.submit(download_one_doc, path, doc_id, headers): path for path, doc_id in doc_map.items() } for future in as_completed(futures): path futures[future] try: future.result() print(fcompleted: {path}) except Exception as exc: print(ffailed: {path}, {exc})目录重建的逻辑则是配合扁平化映射表先os.makedirs逐级创建目录再写文件。注意要处理“同一级目录下存在重名文档”的情况我的默认策略是追加_1、_2这样的后缀而不是相互覆盖。4. 实操过程实录写完了理论分析下面把实际跑通这个工具的过程完整走一遍包括怎么准备环境、怎么写核心函数、最终拿到什么样的结果。照着这个步骤走基本可以复现一版能用的导出工具。4.1 环境准备我用的 Python 版本是 3.10只依赖requests这个第三方库其他全部用标准库实现。安装依赖只需要一条命令pip install requests然后创建一个项目目录结构如下doc_exporter/ ├── config.py # 存放 Cookie、请求头、保存路径 ├── exporter.py # 主脚本 ├── output/ # 导出结果目录config.py里需要填写三样东西你的登录 Cookie、请求头、要导出的知识库路径。我建议 Cookie 放在环境变量YUQUE_COOKIE里脚本启动时读取避免硬编码进文件。4.2 核心代码实现主脚本exporter.py整体分成四个函数fetch_tree()拉目录树、fetch_doc()下载正文、download_images()处理图片、main()串联流程。import os import re import time import requests from concurrent.futures import ThreadPoolExecutor, as_completed from config import HEADERS, SAVE_ROOT, API_BASE session requests.Session() session.headers.update(HEADERS) def sanitize_filename(name: str) - str: return re.sub(r[\\/:*?|], _, name) def fetch_tree(repo_id: str): url f{API_BASE}/repo/{repo_id}/tree resp session.get(url) resp.raise_for_status() nodes resp.json()[data] doc_map {} def walk(items, parent): for item in items: title sanitize_filename(item.get(title, untitled)) path os.path.join(parent, title) if item.get(type) doc: doc_map[path] item[id] elif item.get(type) group: walk(item.get(children, []), path) walk(nodes) return doc_map def fetch_doc(doc_id: str): url f{API_BASE}/doc/{doc_id} resp session.get(url) resp.raise_for_status() return resp.json()[data] def download_one_doc(path: str, doc_id: str): data fetch_doc(doc_id) body data[body] doc_dir os.path.join(SAVE_ROOT, os.path.dirname(path)) os.makedirs(doc_dir, exist_okTrue) # 下载图片并替换为本地相对路径 body download_and_replace_images(body, doc_dir) md_path os.path.join(SAVE_ROOT, path .md) with open(md_path, w, encodingutf-8) as f: f.write(data.get(title, ) \n\n body) time.sleep(0.2) def download_and_replace_images(body: str, doc_dir: str): img_dir os.path.join(doc_dir, assets) os.makedirs(img_dir, exist_okTrue) def replace(match): alt, url match.group(1), match.group(2) if url.startswith(http) or url.startswith(//): filename url.split(/)[-1].split(?)[0] try: img_resp session.get(url, timeout15) if img_resp.status_code 200: target os.path.join(img_dir, filename) with open(target, wb) as f: f.write(img_resp.content) return f![{alt}](assets/{filename}) except Exception: pass return match.group(0) return re.sub(r!\[([^\]]*)\]\(([^)])\), replace, body) def main(): repo_id your_repo_id doc_map fetch_tree(repo_id) with ThreadPoolExecutor(max_workers5) as executor: futures [executor.submit(download_one_doc, path, doc_id) for path, doc_id in doc_map.items()] for fut in as_completed(futures): try: fut.result() except Exception as exc: print(ffailed: {exc}) if __name__ __main__: main()这段代码里有一个容易忽略的细节download_and_replace_images里的正则只匹配表单式图片链接不会处理 HTML 标签里的img。假如某些文档正文用了富文本编辑器生成的 HTML图片会以标签形式存在那就需要额外的正则规则。实际使用中大部分文档是 Markdown 语法就够用了但如果你的知识库里混了大量富文本内容记得补充处理。4.3 运行结果与出参说明按上面的代码跑完后输出目录结构大致长这样output/ ├── 项目A/ │ ├── 需求文档.md │ ├── 技术方案.md │ └── assets/ │ ├── 架构图.png │ └── 原型截图.png ├── 项目B/ │ ├── 周报.md │ └── assets/ │ └── 数据报表.png └── 随笔/ ├── 2024年总结.md └── assets/每篇文档都对应一个.md文件用支持 Markdown 的编辑器打开后图片正常显示目录结构与语雀里的层级一一对应。整个知识库可以直接被 Git 管理也可以导入到其他支持 Markdown 导入的笔记工具里。我实际跑了一个三百多篇文档的知识库耗时大约四分钟中途没有出现断档。相比手动一篇篇导出这个效率提升是非常可观的。5. 常见问题与排查技巧实录这个工具写出来之后我又跑了几轮中间踩了不少坑。下面把这些典型问题按场景整理成速查表并给出排查思路和最终的处理方式。现象可能原因排查方式解决方案部分接口返回 403缺少 Referer 或 Cookie 过期对比浏览器请求头补全请求头、重新复制 Cookie文档下载到一半所有请求超时并发过高触发限流观察失败时间点降低并发数、增加请求间隔图片在本地打开是裂图相对路径不正确查看 Markdown 源码按文档所在目录重新计算路径下载的 Markdown 里仍有线上图片链接正则未匹配到搜索http链接补充匹配 HTML img 标签的处理文件名包含/导致目录层级错乱未做文件名清洗查看报错信息对标题做非法字符替换附件节点没有下载目录树只解析了doc类型抓包看节点类型增加对附件节点的处理分支5.1 Cookie 过期导致中间大面积失败脚本连续运行时间较长时Cookie 有可能过期。尤其在并发下载多个知识库的场景里跑到一半突然大量 401。我的处理方式是给请求包一层重试逻辑遇到 401 就立即停止并提示重新登录不盲目重试遇到 5xx 或超时则最多重试三次每次都退避等待。另外下载脚本最好拆成两步——先拉目录树再下载正文。即使下载中途失败了重新跑下载这一半就行不需要重新解析目录。5.2 图片防盗链与域名限制语雀的图片 CDN 有时候会校验Referer如果脚本里没带或者带错了图片请求会返回 403。这个问题在一开始就规避掉的话后面可以省很多事。我还遇到过一种情况图片本身能下载但下载下来的文件不是图片而是一段提示文本或一串 JSON。这是 CDN 对请求做了鉴权被拦截时返回的“假 200”。排查方法很简单下载后检查文件内容的前几个字节如果是图片格式头就保留否则丢弃并记录 URL。5.3 Markdown 中图片链接替换不全正则处理图片的时候我发现有些文档的图片链接不在![](...)里而是出现在 HTMLimg src...标签中这是因为部分旧文档是用老编辑器写的或者文档里嵌入了 HTML 块。只做 Markdown 正则替换的话这些图片不会下载也不会被替换。补一个简单的 HTML 图片正则即可HTML_IMG_RE re.compile(rimg[^]*src([^])[^]*)匹配到之后同样走下载逻辑并替换src属性。不过要注意HTML 里的img标签可能带很多其他属性替换时不要整个标签删除只动src部分最稳妥。5.4 重名文档互相覆盖知识库里经常出现“同名文档”放在不同目录下的情况扁平化映射表保存为本地路径时不会冲突因为完整路径不同。但同一目录下存在重名文件时就会出问题比如“周报”这篇文档在同一个分组里出现了两次。我后来增加了重名检测每写入一个文件前检查目标路径是否已存在如果存在就在文件名后追加数字序号。别小看这个处理几十篇还无所谓几百篇文档时一定会遇到。5.5 接口字段变更带来的连锁反应平台前端升级时接口的返回结构偶尔会变化。之前字段叫body可能某天就变成了content。遇到这种情况脚本会直接报KeyError。我的建议是代码里不要到处直接访问data[body]这种魔法字段而是封装一个提取函数在里面做多种兼容判断。另外脚本运行前打印返回 JSON 的键名排查时非常直观。工具毕竟是跟着接口走的平台调整后需要一个快速定位问题的渠道否则光看报错会很懵。6. 一些改进方向和最后的小技巧整个工具跑通之后我又陆陆续续加了一些细节优化比如把导出结果同时生成一份index.html的索引页方便不熟悉 Markdown 编辑器的人直接用浏览器浏览。另外也在本地做了一个简单的全文检索目录把所有 Markdown 正文拼接后建立索引找文档时不用一层层翻文件夹。最后分享一个小技巧下载图片命名时不要只保留 URL 末尾的文件名我吃过亏。不同图片可能因为 CDN 参数不同而重名导致相互覆盖。建议用“文档ID 图片序号”的方式重命名比如doc_123_001.png。虽然文件名不好看但能保证唯一性。如果你更在意可读性可以在保存时加一个映射表本地名传给正文引用同时把原名记录下来备用。个人体会是做这类导出工具技术和代码其实占一半另一半是对数据结构的理解和对边界情况的耐心。只要把目录树、正文、图片、附件这几个关键点拆开处理整个流程就不会乱。希望这篇内容能帮你把自己的知识资产稳稳握在手里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑