用 Typora 写 Markdown最让我崩溃的从来不是排版而是图片管理。我写过一篇两万字的实践笔记光截图就插了 80 多张传到图床之后文件名全变成20250413_103201.png这种毫无信息量的时间戳三个月后再想找其中一张流程示意图只能一张张点开预览整个人直接血压拉高。后来我认真研究了一下 PicGo 的插件机制自己写了一个小插件让 Typora 在把图片交给 PicGo 上传之前自动按“日期 当前 Markdown 文件名 原文件名”重新归类。图片落到图床的目录从一锅粥变成了整齐的仓库先按日期归档再按文章名分文件夹每个文件还保留可读的原始名称。这篇文章就是完整的记录从为什么这么做、插件怎么写到 Typora 怎么配、实际会遇到哪些坑一次讲清楚。适合正在用 Typora PicGo 管博客素材、公众号配图或者想学 PicGo 插件开发的朋友参考。1. 问题根源图片管理失控的三种典型场景1.1 默认工作流下图片会变成什么样大多数人的工作流是这样的Typora 里打开一篇笔记截图后直接 CtrlV 粘贴Typora 默认把图片复制到当前文档同级的assets目录文件名要么是截图工具生成的Snipaste_2025-04-13_10-30-00.png要么是 Typora 自动处理后的image-20250413103000.png。如果扔在本地其实还没那么糟毕竟文件还在文档旁边。但只要接入了 PicGo 图床图片传到云端之后问题就暴露了同一张功能截图可能在《手动部署服务》和《自动化部署改造》两篇文章里各出现一次上传后它们的名字分别是20250112-deploy.png和20250413-deploy.png从图床上根本看不出谁是谁。我见过更夸张的情况有人把 GitHub 仓库当图床半年之后仓库根目录下堆了两千多个文件分不清哪张图属于哪篇文章只能靠打开 Typora 里的旧文章重新拷贝链接维护成本极高。还有一个隐藏问题Markdown 文件是可以移动、重命名、甚至导出的。本地图片和.md文件之间的关联靠相对路径维持一旦移动了图片目录或者用其他工具打开笔记路径立刻断裂。所以我一直觉得图片管理不能只依赖“文件躺在那里”而是应该把归属信息写进图片的存储路径里。1.2 把图片归属当成元数据来设计当我开始设计整理规则时先问了自己一个问题一张图片放到图床之后我拿到它的链接最需要知道什么答案是三件事它属于哪篇 Markdown 文章它是什么时候产生的它原本的文件名叫什么。这三件事就是图片的元数据。如果把这些元数据直接编码进远程目录路径那么以后在 OSS 控制台、GitHub 仓库或者 PicGo 相册里看到一张图不需要打开任何文档就能判断用途。这也是这篇文章核心思路的起点路径就是索引文件名就是注释。比如下面这个路径2025-04-13/手动部署服务/20250413-103201-Snipaste_2025-04-13_10-30-00.png一眼就能读出这是 2025 年 4 月 13 日写的《手动部署服务》文章里的图原文件是 Snipaste 截图时间 10:30:00。三个信息全部自解释。1.3 为什么一定选 PicGo 插件层而不是别的方式实现同样效果有几个备选思路写一个脚本常驻监听本地目录发现新图片就自动改名移动或者在 Typora 里改图片保存规则再或者干脆每次手动整理。我都试过或评估过最后选了 PicGo 插件原因只有一个字稳。脚本监听方案的问题在于监听进程要一直开着且必须保证 Typora 复制图片和监听器之间没有时序竞争。图片刚复制完就被脚本搬走Typora 里生成的相对路径可能已经失效。手动整理方案的问题在于人一定会偷懒坚持不了两周。Typora 自己的图片路径设置只能决定“本地图片存哪”它不会在上传前给 PicGo 传递重命名规则。Typora 只管调用 PicGo 上传传上去怎么命名是 PicGo 的活。PicGo 本身开放了完整的插件生命周期可以在真正上传图片之前拦截请求修改本地图片路径、文件名、上传参数。插件跑在 PicGo 进程里Typora 只需要无脑调用规则统一、可复用、还能分享给别人。所以我最后决定在 PicGo 插件里做这件事。2. 规则设计目录层级和命名策略的选择2.1 三种常见方案对比动手写插件之前我先把图片的目录结构设计定下来。这个环节建议每一个人都认真做因为规则一旦被大量线上文章引用后期改结构成本非常高。我对比了三种主流方案。方案目录结构示例优点缺点纯日期目录2025-04-13/103201.png按时间归档实现最简单同一天写的多篇文章图片完全混在一起纯 MD 文件名目录手动部署服务/103201.png文章维度清晰单篇文章的图集中没有时间线索跨年找图困难日期 MD 文件名目录2025-04-13/手动部署服务/20250413-103201-原图.png两个维度都能追溯兼顾归档与聚合路径层级稍长部分图床不支持多级目录纯日期适合那种“图片只给个人日记用、几乎不关心归属”的场景。纯 MD 文件名适合写系列教程、单篇文章图片特别多的场景。我最终选了第三种理由很简单我既要回答“这篇文章有哪些图”也要回答“这个月我产出了哪些素材”日期和文章名缺一不可。2.2 本文采用的具体规则最终定的规则是YYYY-MM-DD / Markdown文件名 / YYYYMMDD-HHmmss-原始文件名拆开看每一段的用意YYYY-MM-DD图片被上传的日期按本地时间计算。放在最外层是因为绝大多数素材管理场景下“按时间找”是第一诉求。Markdown文件名图片归属的文章。用 Typora 默认的xxx.md.assets目录反推可靠性最高。YYYYMMDD-HHmmss二次时间戳用来防止同一天、同一篇文章里插入两张同名截图时互相覆盖。原始文件名保留人类的可读性比如知道这是 Snipaste 截图还是某个工具导出的图片。举一个真实例子会清楚很多。一篇名为《使用 PicGo 插件管理博客图片》的 Markdown 文档本地图片路径是/posts/picgo-plugin/blog.md.assets/20250413-snapshot.png经过插件改写后上传到 GitHub 图床的路径是2025-04-13/使用PicGo插件管理博客图片/20250413-101530-20250413-snapshot.png整个规则不依赖任何数据库路径本身就是所有信息。2.3 预处理规则与边界兜底写规则的时候要额外处理几个边界情况否则上线第一天就出事故。第一文件名里的非法字符。Windows 路径不支持\ / : * ? |Markdown 标题却经常出现:和?。我的做法是把这些字符统一替换成-替换完再做 URL 编码兼容。第二提取不到 Markdown 文件名的情况。比如你从网页直接拖拽一张图片进 Typora没有经过xxx.md.assets这个目录插件就拿不到文章名。这种情况我会兜底放到unassigned目录里总比随机散落好。第三时区问题。如果 PicGo 跑在一台设置为 UTC 的服务器上凌晨上传的图片日期会差一天。个人桌面端通常没问题但如果你是给团队写插件建议统一用Intl.DateTimeFormat指定timeZone: Asia/Shanghai之类的时区不要直接依赖系统时间。第四保留原始扩展名。某些截图工具生成.jpegTypora 粘贴后可能变成.png插件里不能写死扩展名必须从原路径提取。3. 插件开发自研 PicGo 插件的核心实现3.1 插件的入口与生命周期钩子PicGo 插件本质上是一个导出函数的 npm 包。PicGo 加载插件的时候会执行这个函数并传入一个ctx上下文对象。ctx上挂着on、off、emit等事件方法也暴露了当前上传任务里的输入输出数据。我的插件只依赖一个生命周期事件uploadBefore。这个名字非常直白就是“所有上传器执行之前”。在这个钩子里修改数据后续的 GitHub、OSS、COS 等图床上传逻辑拿到的就已经是改好的内容。典型的插件入口长这样module.exports (ctx) { ctx.on(uploadBefore, (ctx) { // 在这里修改 ctx.input 里的文件名 }) }你可能会问为什么不直接写一个自定义上传器其实也可以但如果自定义上传器要把 GitHub、OSS、COS 全部重写一遍工作量太大。用uploadBefore只做“预处理”上传仍然交给 PicGo 自带的图床插件组合起来最划算。3.2 核心函数从图片路径反推 Markdown 文件名这个插件最关键的技巧是从 PicGo 收到的图片本地路径里反推出 Markdown 文件名。既然 Typora 默认会把图片复制到note.md.assets文件夹PicGo 拿到的本地路径一定包含这段信息。比如C:\Users\me\blog\posts\deploy\手动部署服务.md.assets\xxx.png我们需要匹配手动部署服务.md.assets这一段的手动部署服务。正则表达式如下function getMdNameFromPath(imgPath) { const normalized imgPath.replace(/\\/g, /) const match normalized.match(/([^/]?)\.md\.assets\//i) if (match) return match[1] const fallbackMatch normalized.match(/([^/]?)\.md\//i) if (fallbackMatch) return fallbackMatch[1] return unassigned }第一段正则匹配xxx.md.assets/第二段是兜底匹配那些已经变成xxx.md/images/的路径结构。i标志是为了兼容 Windows 和 macOS 上偶尔出现的大小写差异。replace(/\\/g, /)则是把 Windows 反斜杠统一成斜杠避免正则写两遍。3.3 重写文件名并交给原上传器拿到 Markdown 文件名之后剩下的组装就是字符串拼接。完整插件核心代码如下module.exports (ctx) { ctx.on(uploadBefore, (ctx) { const newInput ctx.input.map((item) { const mdName getMdNameFromPath(item.path) const dateStr getDateStr(new Date()) const originName sanitize(item.fileName || item.name || image) const timeStr formatTime(new Date()) item.fileName ${dateStr}/${mdName}/${timeStr}-${originName} return item }) ctx.input newInput }) } function getDateStr(date) { const pad (n) String(n).padStart(2, 0) return ${date.getFullYear()}-${pad(date.getMonth() 1)}-${pad(date.getDate())} } function formatTime(date) { const pad (n) String(n).padStart(2, 0) return ${date.getFullYear()}${pad(date.getMonth() 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())} } function sanitize(name) { return name.replace(/[\\/:*?|]/g, -).trim() } function getMdNameFromPath(imgPath) { const normalized imgPath.replace(/\\/g, /) const match normalized.match(/([^/]?)\.md\.assets\//i) if (match) return match[1] const fallbackMatch normalized.match(/([^/]?)\.md\//i) if (fallbackMatch) return fallbackMatch[1] return unassigned }为什么要往item.fileName里塞带/的字符串因为 PicGo 后续的图床上传器会把fileName当成远程存储的相对路径。GitHub 图床会在仓库指定路径下按/自动创建目录OSS、COS 的对象 key 本来也支持/所以这一步同时完成了“目录创建”和“文件命名”。这里有个细节值得注意item.fileName和item.name不一定相同。本地文件路径的 basename 可能是Snipaste_xxx.png而 PicGo 读取到的fileName可能已经带上了一些处理逻辑。所以代码里用item.fileName || item.name做降级保证始终有值。3.4 本地调试与日志观察插件开发最怕的是“看着没生效但不知道哪里出了问题”。我的调试方法是三步走。第一步在关键位置打日志ctx.log.info(before rename:, item.path, item.fileName) item.fileName ${dateStr}/${mdName}/${timeStr}-${originName} ctx.log.info(after rename:, item.fileName)ctx.log是 PicGo 提供的日志对象输出会写进 PicGo 的日志目录平台上一般是用户目录下的~/.picgo/logs。打开日志文件能看到每一张图片改写前后的完整路径。第二步关闭 Typora 的自动上传直接用 PicGo 的命令行上传单张图片测试picgo upload /path/to/test.png这样可以排除 Typora 侧的干扰纯验证插件逻辑。第三步把插件目录通过npm link链接到 PicGo 的全局依赖里或者在 PicGo 的插件目录下新建文件夹放进去重启 PicGo。如果设置页没有出现插件优先检查package.json里的main字段是否写对以及包名是否带picgo-plugin-前缀。4. 接入 Typora 的完整配置与端到端验证4.1 Typora 的图片路径设置必须配合插件能在路径中反推出 Markdown 文件名前提是 Typora 插入图片时把图片复制到和文档名强相关的目录里。经过我反复测试最稳的配置是Typora 偏好设置 - 图像 - 插入图片时 - 选择“复制图片到./${filename}.assets文件夹”。这个配置的效果是图片插入后自动放到当前 Markdown 文件同级的“文件名.assets”目录。比如文档叫picgo-guide.md图片就在picgo-guide.md.assets目录下。这样 PicGo 收到的路径天然包含picgo-guide这个 ID插件才有解析依据。如果你选择“复制到 ./assets 这种公共目录”插件仍然会工作但所有图片都会被归类到unassigned等于白设计。所以这一步一定要先做好。4.2 PicGo 端启用插件和服务端口Typora 与 PicGo 的对接有两种常见方式Typora 直接调用 PicGo 应用或者通过自定义命令。我推荐使用 Typora 内置的“PicGo.app”选项它能自动识别系统里已经打开的 PicGo。需要确认 PicGo 开启了 Server 功能端口默认是36677。如果端口被占用在 PicGo 设置里改掉并确保 Typora 里填的端口一致。我实际遇到过一次 36677 被其他程序占用的情况Typora 上传一直超时排查了很久才发现是端口冲突。插件启用方面开发模式下我建议先把插件目录放到一个固定位置然后在 PicGo 的插件管理页面选择“从本地安装”。安装成功后重启 PicGo再上传一张图验证插件是否被加载。可以在 PicGo 的日志窗口里直接看到插件名字和版本信息。4.3 端到端测试一篇带 5 张图的笔记我每次改完规则都会跑一轮端到端测试用最接近真实操作的方式来验证。测试步骤新建一篇 Markdown命名为e2e-test.md文中插入一张截图、一张 JPG 图片、一张长截图确认 Typora 弹出了 PicGo 上传成功的提示打开图床对应的目录查看上传结果回到 Typora检查图片链接是否已经替换为远程 URL。预期结果应该是本地图片上传后的远程路径e2e-test.md.assets/shot.png2025-04-13/e2e-test/20250413-110500-shot.pnge2e-test.md.assets/photo.jpg2025-04-13/e2e-test/20250413-110501-photo.jpg如果上传后路径确实长这样说明 Typora 调用、PicGo 插件、图床目录三层全部打通。如果路径不对优先看 PicGo 日志里改写后的fileName确认是插件没生效还是图床不支持目录。5. 实战中的坑与解决细节5.1 旧文章迁移插件只对“新上传”生效这个问题很多人会忽略插件只处理上传的那一刻已经上传过的历史图片并不会自动重命名。我的博客仓库有上百篇旧文章图床上的图片早就是混乱状态不可能靠插件自动修复。我的处理方案是分两步做。第一步新文章从今天开始严格执行新规则不再制造新的混乱。第二步对于历史文章写一个一次性脚本扫描 Markdown 文件里的图片引用找到还存在本地assets副本的重新通过 PicGo 上传并替换链接。这里有个前提本地旧副本没有被删除否则只能接受旧的混乱链接。如果你不想写脚本也可以选择“从今天开始旧图不动新图守规矩”。维护的最终目标是让增长的部分走向有序存量部分强行重排性价比很低。5.2 文件名解析的边界情况与正则细节我踩过最隐蔽的一个坑是Markdown 文件名本身包含句点比如《1.2 部署架构.md》。这种情况下Typora 生成的 assets 目录名是1.2 部署架构.md.assets如果正则写得不严谨可能贪婪匹配到1.2 部署架构.md而不是1.2 部署架构。所以我的正则里用了非贪婪匹配/([^/]?)\.md\.assets\//i?表示尽可能少匹配这样遇到第一个.md.assets就停下来。配合[^/]也能兼容文件名中间包含空格和中文的情况。还有 Windows 用户容易遇到的反斜杠问题。PicGo 内部拿到的是 Windows 原生路径例如C:\Users\me\a.md.assets\b.png如果直接跑正则斜杠方向全反了[^/]匹配不到东西。代码里的replace(/\\/g, /)必须放在所有正则之前。5.3 不同图床对子目录支持差异如果你在选图床建议先确认目标图床是否允许fileName带/作为目录分隔。我用过的图床里支持情况差别很大。图床类型fileName含/的表现是否适合本方案GitHub / Gitee自动创建子目录非常适合阿里云 OSS对象 key 的/即目录非常适合腾讯云 COS同 OSS非常适合七牛云支持 key 路径适合SMMS / 微博图床普遍不支持目录全部平铺不适合自建图床 API取决于接口实现需要自己验证如果你的图床不支持多级目录插件改成只给文件名加前缀比如20250413-e2e-test-shot.png至少不会丢失归属信息只是目录层次少了。我个人还是建议把多级目录作为硬性条件否则整理效果大打折扣。5.4 同名图片并发与毫秒级冲突写作的时候经常会出现同一篇教程里两张截图间隔不超过一秒原始文件名完全一样。如果插件只靠YYYYMMDD-HHmmss做前缀同一秒内上传两张同名图片大概率有一张会被覆盖。我的解决方案是给时间戳追加随机后缀const suffix Math.random().toString(36).slice(2, 6) item.fileName ${dateStr}/${mdName}/${timeStr}-${suffix}-${originName}这样既保留了可读性又用四位随机字符避免极小概率的冲突。如果文件名里能接受更多信息甚至可以加入width、height之类的维度但普通场景没必要。6. 扩展思路把这套流程变成基础设施6.1 增加文章 ID 或标签层级目前规则是“日期 MD 文件名”两级目录。如果文章量大、团队协作多建议在文件名解析基础上增加一层“文章 ID”。比如从 Markdown 文件头部解析slug字段路径变成2025-04-13/my-blog-post/20250413-110500-1.png不过要注意PicGo 插件拿到的只是图片路径不一定能读到 Markdown 文件内容。要解析slug需要额外做一次拼盘查找由图片路径里的note.md.assets推导出note.md的完整路径再去读文件内容。这样绕了一层但收益是目录命名可以完全脱离中文文件名对 CDN 和中国境外图床更友好。6.2 双图床备份与压缩如果担心 GitHub 图床某天访问缓慢可以在uploadAfter事件里写一个转发逻辑上传成功后再把同一张图推到备用图床。PicGo 的生命周期事件完整支持这种操作核心是拿到uploadAfter的返回结果把云端 URL 作为新图片地址传给备份上传器。压缩方面我个人习惯是把图片压缩交给 Typora 之前的步骤。比如 Snipaste 截图后直接用pngquant压一遍PicGo 插件尽量不要在中间做重压缩容易拖慢上传速度。6.3 批量清理未引用图片有了新目录规则后图床会越来越规整但还有一个新问题一篇文章改了 N 版旧截图可能已经不再被引用却还留在图床上。我自己写了一个 Python 脚本思路很简单扫描本地所有 Markdown 文件里的图片 URL记录 URL 中出现的图片文件名扫描图床目录下的所有远程文件找出未被任何 Markdown 引用的文件。这个脚本本质上是“用引用关系反推孤儿文件”。后面可以加上“移动到一个待删除目录”而不是直接删除避免误伤。回到最初那个让我血压拉满的图片混乱问题。现在我的图床目录长这样2025-04-13/ 使用PicGo插件管理博客图片/ 20250413-101530-20250413-snapshot.png 20250413-101601-architecture.png 手动部署服务/ 20250413-103201-Snipaste_2025-04-13_10-30-00.png每个文件夹打开不用看任何备注就知道里面是什么每篇文章重新迁移到新平台图片目录也能整包带走。如果你正准备搭建 Typora PicGo 的素材管理体系我建议第一版先跑通最简单的规则不要一上来就加文章 ID、标签、备份这些重型功能。把“日期 MD文件名”这个核心跑顺再逐步扩展。最后分享一个我自己的习惯只要改插件代码第一步永远是打日志看before rename和after rename两行输出能救回你至少半天的排查时间。