一次飞书剪藏图片丢失排查:为什么只有第一张图成功,其他全挂了?
前两天我遇到一个很典型,但也很容易被忽略的问题。
我用飞书的 Chrome 剪藏插件剪一篇自己博客里的文章,结果很诡异:
只有第一张图片成功了,后面的图片全没了。
文章正文还在,结构也正常,但大量配图直接消失。
一开始我以为是飞书剪藏插件对 localhost 支持不好,或者是 Astro 渲染的问题。结果一路查下来,发现问题根本不在飞书,也不在博客页面本身,而是在我自己的图片下载链路里。
准确地说,是我一个给飞书图片做本地化的 skill,把图片扩展名写死成了 .webp。
这篇文章就把整个排查和修复过程完整记录一下。
先看现象:为什么“只有第一张图成功”?
当时剪藏的是我博客里一篇关于飞书 CLI 的文章。
现象很简单:
- 第一张图能正常进入飞书文档
- 后面的正文图片大面积丢失
- 文章正文文本、标题、结构都没问题
- 页面本地访问正常,浏览器里图片也都能显示
这种问题最烦的地方在于:
浏览器里一切正常,不代表下游系统也能正常处理。
网页能显示,只说明浏览器足够宽容;但剪藏插件和飞书后端处理图片时,链路会严格得多。
第一步:先怀疑剪藏插件
我先看了飞书 Chrome 剪藏插件当前目录下的实现。
重点查的是三段逻辑:
- 页面内容提取
- 图片抓取和转 base64
- 上传 HTML 给飞书
最后定位到它的核心流程大概是这样的:
fetch(imgSrc, { mode: "cors" })
-> blob()
-> FileReader.readAsDataURL()
-> 把 data URL 塞回 HTML
-> 上传给飞书
也就是说,插件会主动把页面里的每一张图抓下来,转成 base64,再提交给飞书。
这时候我就意识到一个关键点:
如果图片本身的 MIME 和扩展名不一致,这条链路就有可能出问题。
因为浏览器直接显示图片,很多时候靠的是“内容嗅探”;但插件转 blob、转 data URL、再交给飞书时,系统更依赖 MIME。
第二步:排除博客页面结构问题
接下来我去看这篇文章的实际渲染结果。
重点检查了几件事:
- 图片是不是普通
<img src="..."> - 有没有
srcset - 有没有懒加载占位图
- 有没有 Astro 的图片组件额外改写
- 有没有
picture/source这种多层结构
结果很明确:
文章正文里的图片输出非常干净,就是普通的 <img src="/images/blog/...">。
没有 srcset,没有奇怪的懒加载替换,也不是灯箱脚本改坏了 DOM。
这基本可以排除“页面结构导致剪藏失败”。
第三步:检查图片文件本身
然后我直接去检查文章引用的图片源文件。
结果一看就破案了。
我这篇文章里的正文图片在 Markdown 里全写的是:


...
但我用 file 去看真实格式,发现它们根本不是 WebP。
实际情况是:
*.webp文件,真实内容全是 PNG- 封面
*.png文件,真实内容其实是 JPEG
也就是说,当时我的图片资源处于这种状态:
- 文件名:
xxx.webp - 实际内容:PNG
或者:
- 文件名:
cover.png - 实际内容:JPEG
浏览器能显示,是因为浏览器很宽容。
但飞书剪藏插件走的是:
URL -> fetch -> blob -> data URL -> 上传
这时候如果拿到的 MIME 是 image/webp,但里面其实是 PNG 字节流,下游处理就很可能挂。
于是就出现了表面上看很迷惑的现象:
第一张图侥幸成功,后面很多图全部失败。
根因不在飞书,而在我自己的图片下载 skill
继续往回追,我发现这些图片不是手工处理的,而是我之前通过一个 skill 下载飞书图片时生成的。
这个 skill 叫:
feishu-image-downloader(飞书图片下载 Skill)
它的职责很简单:
- 从 Markdown 里提取飞书图片链接
- 下载到本地
- 替换文章里的图片引用
问题就出在这里。
我看了它当时的实现,发现有两个致命点。
问题 1:文件扩展名被写死成 .webp
下载脚本里,文件名是这样生成的:
filename=$(printf "%s-%02d.webp" "$post_name" "$index")
不管飞书返回的是 PNG、JPEG 还是 WebP,统统保存成 .webp。
这就埋下了雷。
问题 2:Markdown 替换逻辑也写死成 .webp
Python 脚本在替换 Markdown 引用时,也是这样写的:
new_link = f''
也就是说,不仅磁盘文件名固定成 .webp,连文章里的引用也固定成 .webp。
这意味着:
只要下载回来的真实内容不是 WebP,整个文章就会进入“扩展名和真实格式不一致”的状态。
真正的修复方式:从源头修,而不是事后补救
我这次没有只修文章本身,而是分成两层去修。
第一层:修 skill 的源头逻辑
我改了两个地方。
1. 下载脚本不再写死 .webp
新的逻辑是:
- 先下载到临时文件
- 用
file --mime-type判断真实格式 - 再映射成正确扩展名:
image/png -> .pngimage/jpeg -> .jpgimage/webp -> .webpimage/gif -> .gifimage/svg+xml -> .svg
也就是说,现在下载链路变成了:
下载临时文件 -> 识别 MIME -> 决定扩展名 -> 正确落盘
2. Markdown 替换时读取真实下载结果
以前替换逻辑是假设:
第 1 张图一定叫 post-01.webp
第 2 张图一定叫 post-02.webp
现在不是了。
现在会先扫描输出目录,找到实际生成的文件名,再回写 Markdown:
post-01.pngpost-02.jpgpost-03.webp
这样下载结果和文章引用天然一致。
第二层:修这篇已经出问题的文章
源头修好以后,我又把这篇已经出问题的文章实际修了一遍。
具体做法是:
- 检查
src/content/blog/2026-03/imgs下所有图片 - 找出“扩展名与真实格式不一致”的文件
- 把文件名改成正确扩展名
- 同步修改 Markdown 里的所有引用
最终修正结果是:
- 封面:
cover.png -> cover.jpg - 正文:
01.webp ~ 23.webp -> 01.png ~ 23.png
也就是说,之前是“假 WebP”,现在变成“真 PNG / 真 JPEG”。
为什么这个问题特别容易被忽视?
因为它具备三个很容易误导人的特征。
1. 浏览器里看起来一切正常
浏览器对图片格式兼容非常宽。
很多时候你文件名写错了,浏览器照样能渲染。
这会让人误以为资源完全没问题。
2. 静态站点构建也不一定报错
构建工具通常只关心文件能不能复制、引用能不能解析。
它不会帮你验证:
这个 .webp 文件里到底是不是真 WebP。
3. 真正炸的是下游链路
像飞书剪藏、内容转存、图片代理、CDN 重写、格式转换这种链路,对 MIME 更敏感。
所以问题不会在你自己页面爆炸,而是在“被别人消费时”爆炸。
这类问题本质上属于:
资源在上游处于“脏数据”状态,但前端浏览器帮你把错误掩盖掉了。
这次我得到的几个结论
1. 图片文件名不是装饰,它是契约
扩展名写成什么,不能靠猜。
如果你保存成 .webp,那它最好就真的是 WebP。
否则一旦进入转码、代理、剪藏、上传链路,迟早出事。
2. 下载工具不要假设格式
凡是做“远程资源下载到本地”这类工作,都应该以后端真实返回的 MIME 为准,而不是预设扩展名。
尤其是自动化工具、CLI、skill、批处理脚本,最怕这种“先写死,后来忘了”的小设计。
3. 浏览器的宽容会掩盖数据问题
网页显示正常,不等于资源规范。
真正靠谱的做法是:
- 抽样跑
file - 检查 MIME
- 检查构建后的静态资源
- 检查会被下游系统消费的链路
4. 修 bug 最好的方式,是顺手修掉 bug 的生产机制
如果我只是把这一篇文章的图片手工改对,下次再跑 skill,问题还会复现。
所以这次真正有价值的,不是“修好了一篇文章”,而是:
把制造错误资源的那条链路一起修了。
最后
这次问题表面上看,是“飞书剪藏为什么只成功了一张图”。
但真正的答案其实是:
不是飞书只成功了一张图,而是我之前一直在生产格式不规范的图片资源,只是以前没有暴露出来。
很多 bug 都是这样。
你以为你在修一个显示问题,最后发现你修的是一个数据契约问题; 你以为问题出在插件,最后发现问题出在自己的工具链; 你以为是兼容性,最后其实是输入不干净。
说到底,工程里的很多诡异现象,不是系统太复杂,而是某个地方偷偷“糊弄了一下”,然后一路传染到了后面。
这次是图片扩展名。
下次可能就是 MIME、编码、时区、换行符、富文本结构、甚至一个多余的空格。
但套路都一样:
别只修结果。顺着链路,把问题生产出来的地方一起修掉。
评论互动