手里攒了几百个 markdown 文件,想把它们整批搬进飞书云文档,这个需求听起来特别简单——不就是上传吗。真动手才知道,飞书批量上传 markdown 这件事的难点从来不在"上传"两个字上,而在于图片路径怎么办、目录层级怎么还原、中文文件名会不会出问题、跑到第 187 个文件突然报错之后要不要从头再来。这篇就把我自己做过两轮、累计导入了七百多篇 markdown 的完整链条摊开讲,从选型一直讲到断点续跑和事后维护。适合三类人看:手上有一整个文档仓库要迁移的、想给团队搭"本地写作 + 云端发布"流水线的、以及单纯想摸清飞书云文档接口脾气的开发者。
1. 批量上传之前,先把路线这件事定下来
1.1 三条能走通的路,以及它们各自的天花板
把 markdown 弄进飞书,业界常见的做法就三条,没有第四条。
第一条是纯手动。飞书客户端和网页端都有"导入文档"的入口,支持选本地文件,甚至支持一次选多个。文件少的时候这条最快,十分钟能搞定五十篇。但它的天花板很明显:一次能选的数量有限,导入后的文档会散落在默认位置,你要一个个手动拖进对应文件夹;更麻烦的是文件名即标题,如果你的文件名是01-intro.md这种带序号的,导入进去的文档标题就是"01-intro",还得一篇篇改。文件超过一百个之后,这条路的时间成本会迅速失控。
第二条是浏览器自动化。用脚本驱动浏览器,模拟点击"导入"按钮、选文件、等待完成。这条路的好处是完全不依赖开放平台权限,个人账号就能跑,不需要企业管理员给你开应用。坏处是脆弱到离谱——前端 DOM 一改,脚本全废;上传大文件时页面加载慢,等待时间全靠猜;而且飞书前端对这种高频模拟操作是有风控的,跑着跑着弹个验证就停住了。我第一轮就是用这条路,跑到六十多篇开始随机失败,最后放弃。
第三条是开放平台的云文档接口。核心链路是先把 markdown 当成"素材"上传拿到一个file_token,再用这个 token 创建一个"导入任务",飞书在后台把素材转成云文档,你轮询任务状态,成功后拿到新文档的 token 和 URL。这条路前期配置麻烦——要建应用、开权限、把应用加进文件夹,但一旦跑通,稳定性和可扩展性完全不是一个量级。
| 路线 | 前期成本 | 百篇以上可行性 | 可重复执行 | 图片处理 |
|---|---|---|---|---|
| 手动导入 | 极低 | 差 | 不可 | 自动抓外链 |
| 浏览器自动化 | 中 | 差 | 差 | 自动抓外链 |
| 开放平台接口 | 高 | 好 | 好 | 需自己先处理 |
1.2 为什么最后都收敛到"素材上传 + 导入任务"
有人在第一次看飞书云文档接口文档时会困惑:为什么不能直接传一个 markdown 文件,让它变成一篇文档?为什么非要先上传素材、再创建任务、再轮询?这不是绕远路吗。
关键在于转换是异步的。markdown 到飞书文档不是简单的格式映射,中间要做语法树解析、样式重建、图片抓取、表格重排,复杂文档可能要好几秒。如果接口设计成同步阻塞,一个请求挂十几秒,双方都不好受。所以飞书把它拆成了"接收素材"和"执行转换"两段,中间用 task ticket 串起来。理解了这一点,后面所有的"为什么要轮询""为什么会有 pending 状态"就都顺了。
这个设计还有个隐藏好处:素材和任务分离,意味着你可以在任务失败后只重试任务创建,不用重新上传素材。跑大批量的时候,这一条能省掉大量重复流量。
1.3 三个不解决就没法开工的硬约束
在写第一行代码之前,有三件事必须先确认,否则跑到一半一定卡住。
应用类型。你用的应该是"企业自建应用",因为它可以用tenant_access_token(以应用身份调用),不依赖任何人的登录态。用user_access_token也不是不行,但那个 token 有效期短、需要走授权码流程,不适合跑批处理脚本。
目标文件夹。导入任务必须指定一个挂载点,也就是一个云空间文件夹。这个文件夹可以是根目录,也可以是某个子目录。但注意,它必须是应用有权限写入的文件夹,这一条后面会专门讲。
图片的归宿。这是最容易低估的一项。markdown 里的图片如果是相对路径,导入后一定是断的。你必须提前决定:是先用对象存储把图片传上去拿公网 URL,还是干脆接受图片丢失。这个决定会直接改变你预处理脚本的复杂度。
2. 应用凭证与云空间权限:403 几乎都出在这一层
2.1 tenant_access_token 的获取、缓存与过期
所有接口调用的第一步都是拿 token。请求很直接,POST 到/open-apis/auth/v3/tenant_access_token/internal,body 里带app_id和app_secret,返回里除了 token 还有一个expire字段,单位是秒,一般是 7200。
这里有两个容易犯的错。
第一个是每次请求都重新拿 token。单文件测试的时候无所谓,跑三百个文件就是三百次额外的鉴权请求,直接把你的配额浪费掉一大半,而且鉴权接口本身也有频控。正确做法是在内存里缓存,快到期前再刷新。
第二个是把过期时间当成绝对安全线。expire是 7200 秒,但你不能等到 7199 秒才刷新,中间的网络延迟、时钟漂移都可能让你在边界上被拒。我习惯减掉 300 秒,也就是提前五分钟刷新。
import os, time, requests BASE = "https://open.feishu.cn/open-apis" def fetch_token(): r = requests.post( f"{BASE}/auth/v3/tenant_access_token/internal", json={ "app_id": os.environ["FS_APP_ID"], "app_secret": os.environ["FS_APP_SECRET"], }, timeout=10, ) data = r.json() if data.get("code") != 0: raise RuntimeError(f"取 token 失败: {data}") # 提前 300 秒过期,避开边界 return data["tenant_access_token"], time.time() + data["expire"] - 300注意:
app_secret绝对不要硬编码进仓库。用环境变量或者本地配置文件,并且把配置文件加进.gitignore。这套凭证等于你整个企业租户里这个应用的全部权限。
2.2 权限组怎么勾才算够
飞书开放平台的后台权限是按"权限组"来勾的,不是按接口。云文档相关的至少需要这几项:
- 查看、评论、编辑和管理云空间中的所有文件(对应
drive:drive这一类),这是核心,不勾什么都做不了。 - 上传图片、文件等素材,素材上传接口需要。
- 如果后续还要建文件夹,文件夹创建接口同样归在云空间权限下。
很多人勾完权限就以为完事了,但飞书的权限体系有个特点:权限勾选后需要发布版本才生效。自建应用改权限后,要在后台点"创建版本"并等待审核通过(企业内部应用一般是管理员秒批)。如果你勾完权限立刻跑脚本报 403,八成是版本还没发布。这个坑我踩过,盯着代码看了半小时,最后发现是后台一个按钮没点。
另外一个细节:权限是"能做什么",但能不能操作某个具体文件是另一套逻辑,也就是下一节要说的协作者机制。
2.3 把应用"请进"目标文件夹
这是整个项目里最容易漏、也最让人抓狂的一步。
应用拿了tenant_access_token,有了云空间权限,但这不代表它能往你的文件夹里写东西。飞书的云空间权限是按文件/文件夹粒度授予的,应用默认只对自己创建的资源有权限。你要把目标文件夹共享给这个应用。
具体做法:在飞书里找到你要作为导入挂载点的那个文件夹,点分享,在协作者里搜索你的应用名称(不是你的用户名,是应用本身),把它加为可编辑。应用名称就是你在开放平台注册时起的名字。
加完之后再跑脚本,403 立刻消失。我在第一次做这个项目时,一直以为是权限组没开,反复检查后台配置,其实根因就是文件夹没共享。
提示:如果你要导入的目录树很深,别指望在根目录共享一次就够。飞书云空间的协作者权限不自动继承到你新建的子文件夹——但实际上子文件夹是应用自己创建的,创建者对自建资源天然有权限,所以只需要把最顶层那个"起始文件夹"共享给应用即可,往下都由应用自己建,链路是通的。
2.4 一个判断权限问题的通用排查顺序
遇到权限类报错时,按这个顺序查,基本能定位:
- 报错是什么 code?403 是权限不足,401 是 token 无效,404 通常是资源不存在或者你没权限看不见它(飞书对无权限资源的返回经常伪装成 404)。
- token 是不是过期了?打印一下剩余有效期。
- 权限组勾了吗,版本发布了吗?
- 目标文件夹共享给应用了吗?
- 这个文件夹是不是在别人的个人空间里?个人空间的资源共享规则和企业空间不一样,容易出幺蛾子。
把这份清单存下来,能省下大量瞎猜的时间。
3. 导入前的 markdown 体检:图片、frontmatter 和会变形的语法
3.1 本地图片必须先变成公网可访问的链接
这是整条链路里最脏的活,没有之一。
飞书的导入器在处理这类外链图片时,会主动去抓取并把图片转存到自己的云空间,最终文档里的图片是稳定的。但如果你的 markdown 写的是或者,导入器抓不到,结果就是一张断图,或者干脆什么都没有。
所以预处理的核心任务就一句话:把所有本地图片上传到一个公网可访问的地方,然后把 markdown 里的相对路径替换成对应的 URL。
具体上传到哪儿,看你现有的基础设施:
| 图片去处 | 优点 | 缺点 |
|---|---|---|
| 已有对象存储(S3 兼容等) | 量大不花钱,路径可控 | 需要 bucket 公读或签名 URL |
| 代码托管平台的 raw 链接 | 零成本,和仓库同源 | 有频率限制,私有仓库不可用 |
| 图床服务 | 接入快 | 有额度上限,长期稳定性看运气 |
| 反向代理到自己的域名 | 最可控 | 需要配置和维护 |
对个人项目来说,如果这个 markdown 仓库本来就托管在公开仓库里,直接用它提供的 raw 链接最省事。我常用的一种做法是:先把图片统一 push 到仓库的assets/目录,然后用仓库的 raw URL 作为前缀做字符串替换。全程不涉及额外服务,脚本也就十几行。
如果图片太多或者仓库是私有的,那就老老实实走对象存储。上传脚本用boto3之类的 SDK 批量传,记得控制并发,别一次性开五百个线程。
3.2 用正则批量改写图片路径时踩到的坑
看起来re.sub(r'!\[.*?\]\((.*?)\)', ...)就完事了,实际上有三个坑。
坑一是 URL 里带括号。很多图片 URL 会带(1).png这种,或者 CDN 的链接里有括号参数。非贪婪匹配到第一个)就停了,剩下半截留在正文里。稳妥一点的写法是同时处理带标题的语法:
import re # 匹配  和  两种形式 IMG_RE = re.compile(r'!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)') def rewrite_images(text, mapper): def repl(m): alt, src = m.group(1), m.group(2) if src.startswith(("http://", "https://", "data:")): return m.group(0) # 已经是外链,不动 return f"})" return IMG_RE.sub(repl, text)坑二是引用式图片。markdown 还支持![alt][ref]加底部[ref]: ./a.png的定义式写法。上面的正则完全覆盖不到。如果是自己写的文档,可以要求团队统一用行内式;如果是接手别人的仓库,就得额外写一段处理引用定义的逻辑,或者干脆用 markdown 解析库来做,比如 Python 的markdown-it-py、Node 的unified+remark。我个人建议:批量迁移场景直接用解析库,别跟正则较劲,正则省下的那点时间最后都会还回去。
坑三是 HTML 图片标签。有些文档里写的是<img src="./a.png" />。这个也得单独处理,而且因为它混在正文里,解析库大概率会把它当成原始 HTML 保留下来,需要在解析之后再扫一遍。
3.3 frontmatter 与站点专用标记的剥离
如果你迁移的是静态站点或者笔记软件导出的 markdown,文件开头大概率有 YAML frontmatter:
--- title: 快速开始 date: 2024-03-11 tags: [guide, setup] ---导入飞书之后,这段---包裹的内容不会被识别成元数据,而是当成普通正文渲染出来,一篇原本干净的文档开头就多了几行莫名其妙的键值对。必须在预处理阶段剥掉。
FM_RE = re.compile(r'^---\s*\n.*?\n---\s*\n', re.DOTALL) def strip_frontmatter(text): return FM_RE.sub('', text, count=1)顺带要处理的还有几类站点专用标记:<!-- more -->这种摘要分隔符、VuePress / Docusaurus 的自定义容器语法(::: tip)、{{ }}模板变量、以及{% include %}之类的标签。这些进去之后全是噪声。我的做法是列一份"要删的模式清单",在预处理里统一过一遍,比导入之后再去文档里改省事一百倍。
3.4 表格、公式、mermaid、软换行的兼容性清单
markdown 到飞书文档不是一一对应的,下面这张表是我实测下来比较稳的结论:
| markdown 语法 | 导入后表现 | 建议 |
|---|---|---|
标题#~###### | 映射为文档标题层级,基本正常 | 直接用 |
| 段落间单换行 | 多数情况被合并成一段 | 想断段就空一行 |
| 行尾两空格硬换行 | 表现不稳定,时灵时不灵 | 改用空行或<br> |
| pipe 表格 | 基础表格可用,合并单元格丢失 | 宽表提前拆列 |
| 代码块 + 语言标注 | 保留为代码块,高亮基本在 | 直接用 |
| mermaid 代码块 | 变成普通代码块,不渲染 | 提前导出成图片 |
数学公式$...$/$$...$$ | 支持程度视导入器版本而定 | 复杂公式转图片最保险 |
| 本地图片相对路径 | 断图 | 换成公网 URL |
锚点链接#某标题 | 失效 | 导入后手动补 |
| 内嵌 HTML 标签 | 大多被丢弃或转义 | 不要依赖它排版 |
重点说两个。mermaid是重灾区,很多技术文档里都有流程图,导入飞书后会退化成一堆代码文本,读者体验很差。如果图不多,可以提前用命令行工具把 mermaid 渲染成 png,然后把代码块替换成图片引用。软换行则是所有从 markdown 迁移到富文本的人的共同记忆——你以为写完一行敲个回车就是换行,实际上 markdown 规范里单换行是空格,必须空一行才是新段落。这个认知差异导致的排版事故,我在每个迁移项目里都会遇到一次。
4. 单文件链路跑通:从素材上传到导入任务的四个动作
4.1 素材上传接口里的 parent_type 与 size
先看上传接口。它是一个 multipart 表单,字段包括:
file_name:文件名,带扩展名。parent_type:素材的用途类型。这一步极关键,用于文档导入的场景要传ccm_import_open,传成别的话,后面创建导入任务时会提示 token 不可用。parent_node:父节点 token。导入场景一般留空,因为素材还没有归属。size:文件字节数,必须和实际大小一致,不一致会被拒。file:二进制内容。
返回里最有用的是file_token,形如boxcn开头的一串。它只在有限时间内有效,所以上传完应该尽快创建导入任务,不要攒着。
def upload_material(client, path): name = os.path.basename(path) size = os.path.getsize(path) with open(path, "rb") as f: r = requests.post( f"{BASE}/drive/v1/medias/upload_all", headers=client.headers(), data={ "file_name": name, "parent_type": "ccm_import_open", "parent_node": "", "size": str(size), }, files={"file": (name, f, "text/markdown")}, timeout=60, ) data = r.json() if data.get("code") != 0: raise RuntimeError(f"上传失败 {name}: {data}") return data["data"]["file_token"]注意:
size用的是字节数,不是字符数。中文文件名和中文内容都按 UTF-8 字节算,用os.path.getsize拿的是磁盘上的真实大小,一般没问题,但如果文件被做过换行符转换(CRLF ↔ LF),大小会变。
4.2 创建导入任务:file_extension、type 和 point
拿到file_token之后,POST 到/open-apis/drive/v1/import_tasks:
{ "file_extension": "md", "file_token": "boxcnxxxxxxxx", "type": "docx", "file_name": "快速开始", "point": { "mount_type": 1, "mount_key": "fldcnxxxxxxxx" } }四个参数的语义分别是:
file_extension:源文件的扩展名,markdown 就是md。这个值决定了飞书用哪个解析器处理你的素材。具体支持的枚举以当前接口文档为准,不同时期会有增减。file_token:上一步上传得到的 token。type:目标文档类型。markdown 转成新版的云文档就是docx;如果你想转成表格、多维表格,那是给 csv、xlsx 用的,markdown 走不通。file_name:目标文档的标题,和源文件名可以不一样。这是解决"文件名带序号很难看"问题的关键——你可以在这一步把01-intro.md改写成"第一章 快速开始"。point:挂载点。mount_type为 1 表示挂载到云空间文件夹,mount_key就是那个文件夹的 token。
成功返回一个ticket,这就是你后面轮询用的凭据。
4.3 轮询结果与失败判定的边界
查询接口是 GET/open-apis/drive/v1/import_tasks/{ticket},返回体里的result对象包含status、token、url等字段。
status的语义大致是:一个表示成功,若干个中间态表示还在初始化或处理中,其余表示失败。这里最危险的做法是把"非成功"一律当失败——文档刚创建时一定是中间态,你立刻判失败重试,就会产生一大堆重复文档。正确逻辑是:中间态就 sleep 一下再来,成功就收工,其他值才抛异常。
轮询节奏我一般用 2 秒一次。导入一篇普通 markdown 通常 2 到 6 秒完成,复杂的十几秒。超时阈值给 120 秒足够,超过基本是真出问题了。
def wait_import(client, ticket, timeout=120, interval=2): deadline = time.time() + timeout while time.time() < deadline: r = requests.get( f"{BASE}/drive/v1/import_tasks/{ticket}", headers=client.headers(), timeout=15, ) data = r.json() if data.get("code") != 0: raise RuntimeError(f"查询失败: {data}") result = data["data"]["result"] status = result.get("status") if status == 0: # 成功 return result if status in (1, 2): # 初始化 / 处理中 time.sleep(interval) continue raise RuntimeError(f"导入失败 status={status}: {result}") raise TimeoutError(f"导入超时: {ticket}")提示:
status的枚举值以接口文档为准,不同接口版本可能调整。稳妥起见,把中间态写成一个集合常量,改的时候只改一处。
4.4 单文件跑通意味着什么
把上面三个函数串起来,一个 markdown 文件的完整生命周期就跑通了:
- 读文件、剥 frontmatter、替换图片路径,得到处理后的临时文件。
- 上传素材,拿
file_token。 - 创建导入任务,拿
ticket。 - 轮询直到成功,拿新文档的
token和url。
先别急着批量。拿三个不同类型的文件测:一个纯文字无图的、一个带十几张图的、一个表格特别宽的。这三个跑通,说明你的预处理和接口调用都没问题,再考虑规模化。跳过这一步直接上三百个文件,出错的时候你连是哪一层的问题都不知道。
5. 目录树还原与断点续跑:把几百个文件变成几百篇云文档
5.1 先建文件夹,再往里灌文档
飞书的导入任务只能指定一个已有的文件夹作为挂载点,它不会帮你创建目录。所以要想还原本地目录结构,你得自己先建文件夹。
建文件夹接口是 POST/open-apis/drive/v1/files/create_folder,参数是name和folder_token(父文件夹),返回新文件夹的 token。
于是流程变成两阶段:
第一阶段,遍历本地目录,把每一级目录在云端建出来。用os.walk自上而下遍历,维护一个本地相对路径 -> 云端 folder_token的映射。根目录映射到你一开始共享给应用的那个起始文件夹。每遇到一个新目录,用它的父目录的云端 token 作为folder_token创建,把结果塞进映射。
第二阶段,遍历所有 markdown 文件,找到它所在目录对应的云端 folder_token,走导入链路。
def build_folder_tree(client, local_root, cloud_root): mapping = {"": cloud_root} for dirpath, dirnames, _ in os.walk(local_root): dirnames.sort() # 保证顺序稳定 rel = os.path.relpath(dirpath, local_root) rel = "" if rel == "." else rel parent_rel = os.path.dirname(rel) parent_token = mapping[parent_rel] for d in dirnames: child_rel = os.path.join(rel, d) if rel else d token = client.create_folder(d, parent_token) mapping[child_rel] = token return mappingdirnames.sort()这行看着多余,其实很重要。os.walk的遍历顺序依赖文件系统,不排序的话两次运行顺序可能不一样,做断点续跑对比的时候会很痛苦。
5.2 folder_token 缓存与"重复文件夹"问题
上面的写法有个隐患:如果脚本跑到一半挂了,你重跑,它会对已经建好的目录再建一遍,云端就出现一堆同名文件夹。
解决办法是把mapping持久化到本地,跑之前先加载,跑完之后再写回。更进一步,可以在创建之前先列一下父文件夹下已有子文件夹,按名字匹配复用。列文件夹的接口在云空间文件管理那一组里,可以按folder_token列出子项。
我的选择是前者——本地维护一个 JSON 缓存,简单可靠。因为这套脚本的定位是一次性迁移工具,不是长期运行的同步服务,用不着做实时比对。缓存文件长这样:
{ "folders": { "": "fldcnROOTxxxx", "guide": "fldcnAxxxx", "guide/advanced": "fldcnBxxxx" }, "files": { "guide/intro.md": { "status": "done", "doc_token": "doxcnxxxx", "url": "https://example.feishu.cn/docx/doxcnxxxx" } } }有了这个文件,断点续跑就变成了"跳过 status 已经是 done 的条目"。
5.3 清单文件与失败重跑
大批量运行一定会有一部分失败,原因五花八门:图片 URL 挂了、文件太大、网络抖动、频控被拒。关键不是"不失败",而是失败之后能精准重跑。
我的做法是每次运行都写两份记录:
state.json:完整的处理状态,用于断点续跑。failed.txt:本次运行失败的文件相对路径列表,一行一个。
重跑的时候给脚本加个--only-failed开关,只处理failed.txt里的条目。有了这个,处理三百个文件时哪怕是第 200 个挂了,也不需要从头再来。
另外强烈建议每个文件处理完就立刻落盘一次状态,而不是等全部跑完再统一写。虽然写 JSON 有点 IO 开销,但相对于网络请求的耗时可以忽略不计。崩一次从头再来才是真的浪费时间。
6. 真正跑起来之后才暴露的问题
6.1 频率限制、并发与"越跑越慢"
单线程串行跑,一个文件从上传到导入完成大约 5 到 10 秒,三百个文件就是半小时到一小时。这个速度能接受,但你会忍不住想上并发。
上并发之前先想清楚:飞书对素材上传和导入任务都有频控。官方文档里会标注每个接口的 QPS 上限,实际体感是单个应用每秒两三个请求比较安全,超过就开始零星返回限流错误。如果你把并发开到 10,看着快,实际上大量请求被拒后重试,总耗时反而更长。这就是"越跑越慢"的典型症状。
我的经验值:并发度控制在 3 到 5,并且在客户端做一层简单的令牌桶,把 QPS 压在 3 以内。这个配置下三百个文件大概十几分钟跑完,且几乎没有限流报错。
超时和重试也要设计好。网络类错误(连接超时、5xx)适合指数退避重试,三次为上限;业务类错误(参数不对、权限不足)重试没意义,直接记进失败清单。混在一起无脑重试,只会让日志变成一锅粥。
6.2 幂等:同一次运行跑两遍会发生什么
导入接口不是幂等的。同一个file_token创建两次任务,会生成两篇云文档。同一个 markdown 文件重新上传再导入,更是必然生成新文档。
所以批量脚本必须自己保证幂等,靠的就是上一节的state.json。每次处理前先查状态,已完成的直接跳过。这一点在调试阶段尤其重要——你改了个预处理规则想看看效果,结果把整个仓库重导了一遍,云端瞬间多出一倍文档,清理起来非常痛苦。
调试期我的建议是:先在一个独立的测试文件夹里跑小样本,确认效果满意再动正式的。云端删文档虽然也能通过接口做,但批量删除的麻烦程度不比批量上传低。
6.3 大文件、超时和重试策略
素材上传对文件大小是有限制的,具体上限以文档为准,印象中在几十 MB 这个量级。markdown 文件本身很难超过这个数,但如果你的 markdown 里嵌了 base64 图片,文件会瞬间膨胀——一张 200KB 的图转成 base64 大约 270KB,几十张就上兆了。
所以第一件要检查的事是:正文里有没有data:image/png;base64,这种内联图片。如果有,导入前一定要抽出来变成文件再上传到外链,否则文件体积不可控,飞书的转换也可能出问题。
另一个超时重灾区是单篇文档特别长的情况。一篇两万字的 markdown,飞书转换可能超过 60 秒。轮询的超时阈值要按最长的那篇来设,别用固定值。我一般按文件大小动态算:小于 100KB 给 90 秒,更大的给 180 秒。
6.4 导入完成后图片和附件的去向
这是导入成功后最常见的一个疑问:飞书把外链图片抓走之后,存在哪儿了?
答案是存在云空间里,和你的文档关联,但不占你指定的那个目标文件夹的位置。文档里引用的图片由飞书自己管理,你在文件夹里看不到散落的图片文件,这是好事。但要注意两点:
一是如果外链图片后续失效了,飞书里的图不受影响,因为已经转存了。这也是为什么必须用可访问的链接——抓取是一次性的,抓不到就永远没有。
二是附件(非图片的[下载](./file.zip)链接)不会被自动抓取。飞书只处理图片,其他类型的文件链接会原样保留成文本链接,指向你原来的路径。如果那些路径在公网上不可达,团队同事点开就是 404。附件要么单独上传到云空间再补链接,要么在迁移说明里写清楚。
7. 导入之后:索引表与后续维护
7.1 用多维表格给这批文档建索引
文档进云空间之后是一盘散沙,尤其当你有几百篇的时候,找起来非常痛苦。我的做法是顺手生成一张多维表格索引,一行对应一篇文档,字段包括:标题、原文件路径、云文档链接、导入时间、所属模块、状态。
多维表格的写入接口和云文档是一套凭证体系,可以循环调用,把state.json里的记录灌进去。跑完之后你拿到的不仅是一张清单,还是一个可以按模块筛选、按时间排序的导航页。
如果导入时你在file_name里保留了模块前缀(比如统一格式化成"模块名 - 文档标题"),索引表会更整齐。这一点最好在写预处理脚本时就规划好,事后补很麻烦。
7.2 让脚本可以反复用,而不是一次性消耗品
迁移完成之后,这套脚本大概率不会立刻退休。团队还在用 markdown 写文档,你希望新文档也能一键上云,或者每周同步一次。
要做到这一点,有几件事值得提前做:
把配置抽成文件。根文件夹 token、图片 CDN 前缀、需要剥离的 frontmatter 字段,全部放到一个config.yaml里,别散在代码里。
把"转换"和"上传"分开。预处理产出一份处理后的临时文件目录,上传阶段只负责传。这样调整预处理规则时不用重跑上传,调试效率高得多。
给文件名做规范化。用正则把所有非法字符(/ \ : * ? " < > |)替换成中划线或下划线。云文档标题对字符的限制比本地文件系统宽松,但保留这些字符仍可能在 URL 拼接时出问题。
记录每个文件的源文件哈希。有了哈希,第二次运行就能判断哪些文件真的改了,只同步变化的部分。这就是从"一次性迁移工具"变成"持续同步工具"的关键一跳。
7.3 我个人的一点体会
整套流程拆开看,没有一个环节是特别难的。建应用、拿 token、传素材、建任务、轮询——每一步都是标准的 API 调用。真正消耗时间的是那些不在文档正文里的细节:权限发布会生效、文件夹要单独共享、图片必须先变成外链、frontmatter 会污染正文、行尾两个空格的换行不生效。
这些细节没法靠读文档提前知道,只能靠跑一遍、看到不对劲、再回头查。所以如果你正准备做这件事,我的建议是:先拿十个各具特色的文件(有图、有表、有公式、有 frontmatter、有长文档)跑一轮完整流程,把问题全暴露出来,再动手写批处理框架。用小样本试错,成本是可控的;用三百个文件试错,成本是几个小时的等待加一轮清理。
另外,把这套东西的中间状态设计好,比把流程写得多优雅重要得多。一个能断点续跑、能只看失败清单、能重复执行不产生脏数据的土脚本,价值远高于一个跑一次就报废的漂亮脚本。