Python实战:Typora自定义上传接口对接兰空图床
2026/9/8 8:48:14 网站建设 项目流程

我在用 Markdown 做技术写作这几年,最头疼的从来不是语法,而是图片。截图往文档里一贴,Typora 默认把图片存到本地 assets 文件夹,自己看没问题,一旦要把文章发到博客、公众号或者交给同事,图片路径全部失效,整篇文章直接变成“裂图现场”。

后来我把图片管理改成了“本地截图 + 自动上传图床 + 自动替换链接”的工作流,整套动作在 Typora 里一次粘贴就完成,核心就是 Python 写一个自定义上传接口,对接自建的兰空图床(Lsky Pro)。这篇文章就把这个方案从思路到代码、从配置到踩坑完整拆开讲,适合正在被图片管理困扰的 Markdown 重度用户,也适合想自己掌控图片数据、不愿意把图丢给公共图床的朋友。

Python 自动化实践:Typora 自定义上传接口与兰空图床集成

1. 方案整体思路:为什么需要自定义上传接口

1.1 图片管理的真实痛点

先聊聊我为什么折腾这套东西。

早期我也图省事,图片直接丢 GitHub 仓库,用 jsDelivr 当 CDN 加速。后来仓库大了、图片多了,GitHub 访问速度在国内时而抽风,偶尔还会出现图片 404。公共图床我也试过,比如有些免费图床确实方便,但免费服务说不准哪天就关停,图片数据不在自己手里,心里始终不踏实。

最让我下定决心自建图床的,是一次博客迁移经历。我辛辛苦苦写了一年的文章,图片全部挂在第三方图床上,结果对方调整了域名策略,整整 200 多篇文章的图片全部失效。那种“所有内容一夜之间塌掉一半”的感觉,经历过一次就再也不想经历第二次。

自建图床成了刚需。我研究了一圈开源方案,最后选了兰空图床(Lsky Pro)。它有几个让我无法拒绝的点:界面清爽,后台管理方便,支持本地存储和对象存储,最关键的它有完善的 API 接口,可以让我用程序自动上传图片。这意味着我不需要每次手动打开网页传图、复制链接,一切都可以交给脚本完成。

Typora 本身虽然内置了 iPic、PicGo 这些图床上传方式,但我需要的是一个完全自主可控、能按自己需求定制、还能对接自建服务的上传通道。Typora 的“自定义命令”功能正好提供了这样一个接口,它允许我在粘贴图片后自动调用外部程序,并把程序输出的链接替换到 Markdown 文档中。Python 在这里扮演的角色,就是那个“外部程序”。

1.2 图床选型对比:为什么是兰空图床

市面上图床方案很多,我简单列一下自己试用过的几种:

方案优点缺点适合场景
阿里云 OSS / 腾讯云 COS稳定、速度快、有免费额度需要懂点对象存储概念,涉及 Bucket 权限配置;直接传图还要额外开发生产环境、流量较大的站点
GitHub + jsDelivr免费、配置简单国内访问不稳;仓库过大会被限制临时项目、个人学习笔记
公共图床上传简单、无需维护容易跑路、审核规则不可控、可能压缩画质偶尔传一两张图
兰空图床(自建)数据自主、API 完善、支持多种存储策略需要一台服务器和维护成本;需要自己处理备份个人博客、团队内部图片管理

选兰空图床还有一个核心因素:它的上传 API 足够简单直接,返回 JSON 里带图片 URL,二次开发成本极低。我在写上传脚本时基本没翻什么文档,对着接口试了几次就通了。如果你本地有 Docker 环境,几分钟就能起一个实例,后面我会详细讲。

1.3 为什么用 Python 写上传客户端

可能有人会问:Typora 已经支持 PicGo 了,为什么还要用 Python 自己写?直接用 PicGo 搭配兰空图床插件不是更省事吗?

这话没错,但我的诉求和普通用户不太一样。PicGo 虽然功能丰富,但它是一个桌面应用,行为像一个“黑盒”,我需要上传前对图片做处理(比如压缩、重命名、转格式),需要把上传日志保存到特定位置,需要能灵活切换上传策略,这些在 PicGo 里实现起来反而别扭。

Python 脚本的好处就是逻辑透明、改造成本极低。它就是一个简单的命令行程序,输入图片路径,输出图片 URL,Typora 拿到这个输出自动替换到文档里。我可以随时加功能、改逻辑,不需要迁就任何第三方工具的框架限制。而且一旦写好了,这套脚本以后还能复用到其他场景,比如批量处理历史文章里的本地图片、写自动化脚本时上传测试截图,一次投入,长期收益。

2. 环境准备与兰空图床部署要点

2.1 兰空图床安装与存储策略配置

如果你已经有可用的兰空图床实例,直接跳到 2.2 节。没有的话,我推荐用 Docker 部署,这是最省心的方式。

兰空图床官方提供了 Docker 镜像,一个简单的docker-compose.yml就能把服务带起来。我的部署结构大致是:

version: '3' services: lsky: image: dko0/lsky-pro:latest container_name: lsky-pro restart: always ports: - "7791:80" volumes: - ./lsky:/var/www/html - ./data:/var/www/html/storage/app environment: - TZ=Asia/Shanghai

注意几个细节。首先是端口映射,我习惯把容器内部的 80 端口映射到宿主机的一个不常用端口,比如 7791,再用 Nginx 反向代理绑定域名。其次是数据卷,. /data目录存放的是上传的图片文件,这个目录一定要定期备份,图床的核心资产就是这些图片。兰空图床本身是 PHP 项目,底层存储目录在storage/app下,映射出来后方便直接查看和备份。

部署完成后,浏览器访问域名进入安装向导,按要求填数据库信息和管理员账号。数据库可以用 MySQL 也可以用 SQLite,图片量不大、不想折腾的就选 SQLite,我个人用的是 MySQL,因为后续想接一些统计分析功能。

装好后进入后台,在“存储策略”里配置存储方式。兰空图床支持本地存储、阿里云 OSS、腾讯云 COS、七牛云、S3 兼容存储等。如果你就一台服务器、图片量不大,选本地存储最省事。这里有一个容易踩的坑:本地存储的“访问域名”配置项一定要填浏览器能够直接访问到的域名或 IP,比如https://img.example.com,不要填 localhost 或内网地址,否则脚本上传成功后拿到的图片链接别人根本打不开。

2.2 创建 API 调用账号并获取访问凭证

兰空图床的 API 是基于 Token 认证的,所以你需要一个能调用 API 的账号。

兰空图床的角色权限配置比较灵活,后台“用户管理”里可以给用户分配角色,每个角色可以勾选不同的权限。我的做法是单独创建一个名为api的角色,只勾选“上传图片”“查看图片”相关权限,不给管理权限,然后把专门用于 API 上传的账号绑定到这个角色上。这样万一脚本被泄露,风险可控,不会让别人把整个图床后台给端了。

创建好账号后,调用接口获取 Token。兰空图床的 Token 获取接口是:

POST /api/v1/tokens Content-Type: application/json { "email": "你的账号邮箱", "password": "你的密码" }

返回结果大概是这样的:

{ "status": true, "message": "获取成功", "data": { "token": "xxx.yyy.zzz", "expires_at": "2026-01-01 00:00:00" } }

这个 Token 就是后续上传图片时需要携带的凭证。拿到 Token 后,上传图片的接口是:

POST /api/v1/upload Authorization: Bearer xxx.yyy.zzz Content-Type: multipart/form-data file: @本地图片文件

返回的 JSON 里会包含图片的访问链接,路径大概是data.urldata.links.url,不同版本字段名略有差异,需要以实际返回为准。我第一次对接的时候就是因为没注意字段名差异,解析错了返回值,走了不少弯路。

3. 核心实现:Python 上传脚本开发

3.1 脚本整体设计

写脚本之前,先把需求理清楚。Typora 在调用外部程序时,会把需要上传的图片路径作为参数传给程序,多张图片就传多个参数。脚本要做的事就是逐个读取这些图片文件,上传到兰空图床,然后把上传成功后的 URL 逐行打印出来。Typora 会读取标准输出,把每一行 URL 依次替换到文档中对应的图片位置。

理解了这个交互过程,脚本的职责就非常清晰了:

  1. 读取命令行参数,拿到一个或多个图片路径
  2. 过滤掉不需要上传的文件(比如非图片文件、不存在的文件)
  3. 获取有效的 API Token(有缓存就用缓存,没有或过期就重新登录)
  4. 逐张上传图片,收集返回的 URL
  5. 把全部 URL 按行输出到标准输出

需要注意的是,脚本的print()输出内容只能有 URL,不能有任何日志或者调试信息,否则 Typora 会把日志当成图片链接替换进文档里。需要记录日志时,请写入文件而不是打印到屏幕。

3.2 关键代码实现

下面是我实际在用的上传脚本,做了精简但保留了核心逻辑。你把它保存为upload_typora.py,改掉配置项就能用。

#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Typora 自定义上传脚本:对接兰空图床 用法: python upload_typora.py <image_path1> <image_path2> ... """ import os import sys import time import json import mimetypes import logging import tempfile import requests from pathlib import Path # ========== 配置区域 ========== API_BASE_URL = "https://img.example.com/api/v1" # 兰空图床API地址 EMAIL = "your@email.com" # 图床账号 PASSWORD = "your_password" # 图床密码 TOKEN_CACHE_FILE = os.path.join(tempfile.gettempdir(), "lsky_token.json") LOG_FILE = os.path.join(tempfile.gettempdir(), "typora_upload.log") # ============================= logging.basicConfig( filename=LOG_FILE, level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" ) # 允许上传的图片格式 ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"} def get_token(): """获取有效的 API Token,优先使用缓存的 token""" # 尝试读取缓存的 token if os.path.exists(TOKEN_CACHE_FILE): try: with open(TOKEN_CACHE_FILE, "r", encoding="utf-8") as f: cache = json.load(f) # 如果 token 还没过期,直接复用 if cache.get("expires_at", 0) > time.time() + 300: return cache["token"] except Exception as e: logging.warning(f"读取 token 缓存失败: {e}") # 重新登录获取 token resp = requests.post( f"{API_BASE_URL}/tokens", json={"email": EMAIL, "password": PASSWORD}, timeout=10 ) resp.raise_for_status() data = resp.json() if not data.get("status"): raise RuntimeError(f"登录失败: {data.get('message')}") token = data["data"]["token"] expires_at = data["data"].get("expires_at") # 解析过期时间,如果接口没返回就默认 12 小时后过期 if expires_at: expires_ts = time.mktime(time.strptime(expires_at, "%Y-%m-%d %H:%M:%S")) else: expires_ts = time.time() + 12 * 3600 with open(TOKEN_CACHE_FILE, "w", encoding="utf-8") as f: json.dump({"token": token, "expires_at": expires_ts}, f) return token def upload_image(token: str, image_path: str) -> str: """上传单张图片,返回图片 URL""" headers = {"Authorization": f"Bearer {token}"} filename = Path(image_path).name mime_type = mimetypes.guess_type(image_path)[0] or "application/octet-stream" with open(image_path, "rb") as f: files = {"file": (filename, f, mime_type)} resp = requests.post( f"{API_BASE_URL}/upload", headers=headers, files=files, timeout=30 ) resp.raise_for_status() data = resp.json() if not data.get("status"): raise RuntimeError(f"上传失败: {data.get('message')}") # 兰空图床不同版本返回字段不一样,这里做兼容 # 1.x 返回 data.url,2.x 返回 data.links.url url = data["data"].get("url") or data["data"].get("links", {}).get("url") if not url: # 如果 url 是相对路径,手动拼接域名 if url.startswith("/"): url = API_BASE_URL.replace("/api/v1", "") + url else: raise RuntimeError(f"无法解析图片 URL: {data}") return url def main(): image_paths = sys.argv[1:] if not image_paths: logging.error("没有传入图片路径") sys.exit(1) token = get_token() uploaded_urls = [] for image_path in image_paths: try: # 过滤:跳过以 http 开头的绝对地址 if image_path.startswith("http://") or image_path.startswith("https://"): logging.info(f"跳过已是在线地址: {image_path}") continue # 过滤:文件不存在 if not os.path.exists(image_path): logging.warning(f"文件不存在: {image_path}") continue # 过滤:非图片格式 ext = Path(image_path).suffix.lower() if ext not in ALLOWED_EXTENSIONS: logging.warning(f"不支持的图片格式: {image_path}") continue logging.info(f"开始上传: {image_path}") url = upload_image(token, image_path) uploaded_urls.append(url) logging.info(f"上传成功: {url}") except Exception as e: logging.error(f"上传失败 {image_path}: {e}") # 上传失败的图片,输出原始路径占位,避免 Typora 替换为异常内容 uploaded_urls.append(image_path) # 只输出 URL,每行一个 for url in uploaded_urls: print(url) if __name__ == "__main__": main()

这段代码的重点我一个个说。

Token 缓存逻辑是为了避免每次粘贴图片都重新登录一次图床。兰空图床的 Token 有效期默认可能比较长,但稳妥起见我加了一个 300 秒的提前刷新时间,避免在有效期边界正好碰上请求被拒。缓存文件放在系统临时目录,避免污染项目目录。

上传函数的字段兼容逻辑是血泪教训。兰空图床从 1.x 升级到 2.x 之后,上传接口返回的 JSON 结构发生了变化,url字段从顶层挪到了links对象里,而且变成了相对路径。我原来的脚本在升级后就罢工了,排查了半天才发现是字段变了。所以脚本里我对两种结构都做了兼容,并且如果拿到的是相对路径就手动拼上域名。

输出环节的每个print()都必须是 URL,这一点怎么强调都不过分。我在调试时曾经顺手打印了一句“上传中,请稍等……”,结果 Typora 直接把这句中文当成图片链接塞进了文档里,整篇文章瞬间多了一行怪异的“图片”。

3.3 Typora 自定义命令配置全流程

脚本写好后,接下来是 Typora 端的配置。这一步操作不复杂,但细节不少。

打开 Typora,进入文件 -> 偏好设置 -> 图像,这里有三个关键选项。第一个是“插入图片时”,我选择“上传图片”。第二个是“上传图片应用”,这是重点,下拉列表里选中“Custom Command”。第三个是你需要自定义命令,点击后面的“自定义命令”按钮,在弹出的输入框里填写调用命令。

命令格式是:

python /your/path/upload_typora.py

注意,这里不需要在命令里加图片路径参数,Typora 会自动把图片路径追加在命令后面。比如你粘贴了三张图,Typora 实际执行的就是:

python /your/path/upload_typora.py /path/to/img1.png /path/to/img2.png /path/to/img3.png

这也是为什么脚本里用sys.argv[1:]来接收所有图片路径。

配置完成后,界面上有个“测试上传”按钮,会弹出一个文件选择框,选定一张图片后自动测试整个上传流程。如果配置有误,这里就能直接看到错误信息。我建议做完所有配置后一定要点一次测试,确认能拿到 URL 再正式使用。

关于 Python 路径,Windows 用户如果安装了多个 Python 版本,建议用绝对路径指定解释器,比如C:\Python312\python.exe D:\scripts\upload_typora.py,避免 Typora 调用的环境跟脚本依赖不一致。没错,脚本依赖requests库,你需要提前安装:pip install requests

4. 实操过程与典型坑位记录

4.1 第一次完整联调实录

脚本写好、Typora 配置好后,我第一次真正“粘贴图片”时,心里还是有点忐忑的,毕竟链路涉及 Typora 路径解析、Python 脚本执行、图床 API 认证、上传反馈多个环节,任何一环出问题都可能导致图片传不上去。

我先用命令行手动模拟 Typora 的调用方式验证脚本。随便复制一张测试图片到桌面,然后在终端里执行:

python /opt/scripts/upload_typora.py /Users/me/Desktop/test.png

脚本很快就打印出了一个图片 URL。我复制到浏览器里打开,图片正常显示,说明脚本本身是通的。

接着打开 Typora,在文档里粘贴一张截图。此时注意 Typora 底部状态栏,会短暂出现一个“正在上传图片”的提示,几秒钟后文档里的图片已经变成了远程 URL。我把文档剪切到别的电脑上打开,图片依然能正常显示,那一刻的成就感是很实在的。

第一次联调全程没出问题,但后来真正用了几天,各种问题才陆续浮出水面。

4.2 排查经验:脚本输出、路径参数、token 缓存三大坑

下面这些坑是我真实踩过的,整理成表格方便对照排查。

现象原因解决办法
Typora 文档里图片位置出现一串日志文字脚本print()输出了非 URL 信息检查脚本所有print(),只允许输出 URL;日志改用logging写入文件
上传成功但图片不显示兰空图床返回的是相对路径,被原样插入文档解析 URL 时判断是否以/开头,是则拼接图床域名
一段时间不用后,粘贴图片报认证失败Token 过期,缓存未正确处理Token 缓存时保存expires_at,判断剩余时间不足自动重新登录
多张图片一起粘贴时,顺序错乱脚本并行上传导致返回顺序不稳定脚本按参数顺序逐张上传,逐张把 URL 加入列表,不搞并发
Windows 下双击测试按钮无反应Python 命令路径不对,或依赖缺失使用 Python 绝对路径,确认requests库已安装
图片上传成功但被压缩或变形图床开启了压缩策略在兰空图床存储策略中关闭自动压缩,或改用不压图的原图策略

其中 Token 过期这个问题特别容易“潜伏”。我脚本刚写好的时候,图床返回的 Token 有效期是 7 天,没太在意。结果一周后发现自己写博客时所有图片都传不上去了,打开日志一看全是 401。后来加了 Token 缓存和自动续期逻辑,这个问题就彻底消失了。

另外有个细节很多人不会提到:如果你在 Typora 里粘贴的图片路径本身就是一个在线 URL(比如从网页上复制了一张图片粘贴进来),Typora 不会触发上传动作,因为图片已经是远程资源了。但如果你的 Markdown 里有![](本地路径)这样的老文档,把它们拖进 Typora 再重新设置图片上传,Typora 会尝试把本地图片上传,这时候脚本里的“跳过 http 开头的地址”逻辑就能防止误传在线图片。

4.3 上传失败的兜底策略

写脚本时我专门考虑了一个问题:如果图床服务挂了,或者网络不通,脚本该怎么办?

我的策略是:上传失败的图片,把原始本地路径原样输出给 Typora。这样做的结果是,如果图床暂时不可用,Markdown 文档里图片位置仍然是本地路径,文档不会变成一张诡异的错误信息图片。等图床恢复后,我可以再次全选文档,执行“上传所有本地图片”操作,Typora 会自动把未上传的本地图片重新走一遍上传流程。

这个兜底策略虽然简单,但非常实用。有一次我服务器硬盘满了导致图床 502,当时正在写一篇长文,如果没有这个兜底,所有插图都会变成错误占位符,事后恢复很麻烦。有了本地路径兜底,文章依然可读,只是图片暂时引用本地文件,等我清理完服务器空间,一键重传就好。

5. 实用经验与后续扩展

5.1 脚本通用化改造:支持更多图床

兰空图床写死的脚本只能用于兰空,但如果哪天你想换图床,不必推倒重来。这里分享一个通用化思路:把“上传图片”这个动作抽象成接口,不同图床只实现不同的上传逻辑。

比如你可以把脚本拆成这样:

class BaseUploader: def upload(self, image_path: str) -> str: raise NotImplementedError class LskyUploader(BaseUploader): def __init__(self, api_url, email, password): ... def upload(self, image_path: str) -> str: # 兰空图床的上传逻辑 ... class SmmsUploader(BaseUploader): def __init__(self, api_key): ... def upload(self, image_path: str) -> str: # sm.ms 图床的上传逻辑 ... def get_uploader(config): if config["type"] == "lsky": return LskyUploader(...) elif config["type"] == "smms": return SmmsUploader(...)

这样改造之后,以后换图床只需要新增一个 Uploader 类,主流程不用动。我的建议是趁脚本规模小的时候尽早做这个抽象,别等到代码越写越长再重构,那时候就懒得动了。

5.2 提升体验的几个小细节

用这套流程时间长了,我陆续加了一些小功能,每个都能明显提升体验。

第一个是上传前自动压缩。截图图片动辄一两兆,直接传图床既占用存储又拖慢页面加载速度。我在脚本里加了一个可选的对 PNG/JPG 的压缩逻辑,用 Pillow 库把宽度超过 2000 像素的图片等比缩小。这一步大概只需要五六行代码,但效果立竿见影,博客页面明显变快。

第二个是文件名重命名。直接上传时,我习惯保留原始文件名,但有些截图文件名包含中文和空格,生成 URL 时会百分号编码,看起来非常长。我在上传前会用uuid.uuid4().hex[:12]生成一个随机文件名,保证 URL 简短且不暴露本地文件名信息。

第三个是保留完整日志。脚本的日志文件会记录每次上传的文件名、大小、返回 URL、耗时。这些日志平时没什么用,但出了问题时就是排查利器。比如某次图片 403,我翻日志发现是 Token 用了三天没刷新导致权限失效,几秒钟就定位了问题。

第四个是 Docker 部署时做好备份。兰空图床的数据目录记得做定时备份,我写了一个简单的 cron 任务,每天凌晨把整个data目录打包传到另一个存储空间。图片这东西丢了就是丢了,永远找不回来,备份怎么强调都不过分。

5.3 一个让你更省心的进阶用法

最后分享一个我在用的进阶玩法:把上传脚本同时用于 GitHub Actions 的自动化流程中。

我博客的构建流程里有一个步骤,是抓取一些自动生成的报表截图并上传到图床。以前这个步骤我都是用的临时脚本,每次都要重新写。后来发现,我在 Typora 里用的这个上传脚本完全可以复用到 CI 里,只需要调整 Token 的获取方式,改成从 GitHub Secrets 里读取,就能让 CI 流程也使用同一个图床。

这样做的好处是:所有代码仓库里的图片资源都统一由自己的图床管理,不存在第三方服务挂掉的风险,也不存在图片散落各处的问题。那感觉就像给自己的数字资产上了一把锁,钥匙在自己手里。

这套方案整体投入的代码量不大,却彻底解决了我 Markdown 写作中的图片管理老大难。现在无论写技术博客、工作文档还是知识库,粘贴图片永远是一条龙式的自动上传,我再也不用关心图片存在哪、链接会不会失效了。如果你是重度 Markdown 用户,强烈建议花一个下午把这条链路搭起来,往后省下的时间会告诉你这波操作有多值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询