ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Cloudflare R2与Python脚本的Typora免费图床方案

基于Cloudflare R2与Python脚本的Typora免费图床方案 写 Markdown 最烦什么我的答案是两件事一是图片散落在各个文件夹里换个设备就找不到二是文章发出去以后图片还是白的。因为很多博客、公众号编辑器都不会自动托管你的图片图片一旦挂了整个文章结构就散了。我以前用过七牛、又拍云也试过各种免费的公共图床后来折腾来折腾去在 Typora 上稳定用了两年多没交过一份钱、也没丢过一张图的方案就是题目里这套Cloudflare R2 做存储Python 脚本做上传器Typora 负责把贴进来的图片自动传上去。它免费、轻量、不依赖任何第三方的现成图床服务动手能力稍微有点就能搞定适合每天用 Typora 写博客、记笔记、存素材的人也适合不想被云厂商流量费绑定的同学。1. 思路拆解为什么绕开现成图床自建一套 R2 Python1.1 现成图床为什么越用越难受很多人一开始图省事直接拿公共图床顶着用比如各种免费图片床、临时上传站或者干脆传微博、传知乎再复制链接。短期看着没问题但时间一长问题就全冒出来了图片可能被删除外链可能被限制域名可能被某些平台拉黑想批量迁移又没有 API你唯一的选择是手动一张张另存为。写文章最怕的就是“回看时图没了”道理跟备份一样数据不在自己手里什么时候出事完全看别人的心情。对象存储这边阿里 OSS、腾讯 COS 都有免费额度但真正用起来有几个很现实的门槛流量费每 GB 一算博客一旦被转载热点一上来一天几块钱甚至几十块钱就出去了另外国内存储桶经常要绑定备案域名手里没有备案域名的人直接被卡死。GitHub 仓库当图床是不少人用过的野路子但仓库一大、图片一多访问速度就很感人而且公共仓库里的图片谁都能扫到隐私性完全没有。所以说一个能自己控制的数据存储 稳定的分发链路 足够的免费额度这三条得同时满足才值得长期用。我后来锁定的方案是 Cloudflare R2原因特别简单免费额度给得大方而且不收出口流量费。再加上一段几十行 Python 脚本做上传器就能完全绕开“现成图床”的各种坑。1.2 Cloudflare R2 到底香在哪里R2 是 Cloudflare 推出的对象存储服务和 AWS S3 在接口层面兼容。它最打动我的是免费额度10GB 存储、100 万次读操作、100 万次写操作每个月都免费。对个人写博客、记笔记来说这个量级基本够用而且它是全球网络本身就是 CDN图片分发不需要再套一层 CDN。第二点是没有出口流量费。对象存储最坑的就是流量费很多云厂商收的就是这份钱R2 直接把它免了。哪怕某个页面突然被转发了流量跑高了R2 也不会因为出口流量让你欠费这部分对于个人站来说非常重要。第三是接口兼容 S3意味着你写好的配置和代码逻辑以后可以平移到任何兼容 S3 的服务商不会被一家绑死。我的上传脚本里只用了 PutObject 一个接口以后如果想把备份切到其他服务商改一下 endpoint 和凭证就能用成本几乎为零。1.3 方案的整体架构这套方案的工作流是这样的Typora 里粘贴截图或者拖入图片 → Typora 自动调用本地 Python 脚本 → 脚本通过 S3 API 签名上传到 Cloudflare R2 存储桶 → 上传成功后脚本返回一个公网可访问的图片 URL → Typora 自动把文档里的图片路径替换成这个 URL。整个人工干预几乎为零粘贴完照片抽根烟的功夫文档里就已经是外链了。关键设计选择是上传脚本放在本地运行而不是部署在一台服务器上。因为 Typora 本身就是本地编辑器本地调用最直接上传链路也就是本地到 R2 一跳速度比“本地到服务器再到 R2”更快而且不需要额外维护服务器不存在服务器宕机影响上传的问题。脚本本质上只是一个“胶水层”帮 Typora 完成一次 HTTP 上传并返回结果。2. 准备工作注册 R2 和创建存储桶2.1 创建 Cloudflare 账号并进入 R2先去 cloudflare.com 注册一个账号邮箱验证通过后登录套餐选择 Free 就行后面用到的 R2 免费额度属于免费套餐自带能力不需要开通任何付费项目。左侧导航栏找到 R2第一次进入会看到欢迎页点“Create Bucket”开始创建存储桶。存储桶名称建议取一个语义清楚的名字比如typora-images因为桶名会出现在访问域名前缀里同时也方便后续写生命周期管理规则时一眼认出用途。地区参数正常选择 APAC 就可以R2 对地区这个概念处理得比较灵活你选的位置更多是逻辑归属实际访问走的是 Cloudflare 全球网络所以不用像国内 OSS 那样纠结节点。创建完成后点进桶详情先不动其他设置下一步的关键是开启公网访问。2.2 设置 Bucket 为可公开读取R2 存储桶默认是私有的图片链接传上去之后如果直接访问会提示 404。我们需要让图片可以被公网访问这一步在桶设置的 Public Access 里操作。打开桶设置页找到 Public Access 或 Settings 里的 Custom Domain 区域。R2 公开访问有两种实现方式第一种是用官方提供的 r2.dev 子域名。比如某个桶会分配一个形如https://pub-xxx.r2.dev的域名在 Public Access 里把 R2.dev subdomain 开关打开就能访问。这种方式非常适合测试阶段但官方文档不推荐在生产环境长期用因为它有访问频率限制域名里还有一长串随机字符显得不太正式。第二种是绑定自己的自定义域名比如img.example.com。前提是你手里有一个域名并且把它的 DNS 托管到 Cloudflare。操作上在桶设置的 Custom Domain 里输入子域名系统会自动添加一条 DNS 记录等证书生成后就能通过这个域名访问桶内对象。我强烈推荐用这种方式因为请求会走 Cloudflare CDN访问速度更好而且可以把图片请求缓存到边缘节点分担源站压力。开启公开访问后图片的 URL 格式就是https://你的自定义域名或r2.dev域名/对象键。这里的“对象键”就是你要在脚本里上传文件时指定的路径和文件名。2.3 生成 Access Key 和 API 端点R2 兼容 S3 API所以需要一对 Access Key ID 和 Secret Access Key。在 Cloudflare 右上角点头像进 My Profile然后找到 API Tokens在 R2 相关的入口里创建新令牌。创建时有几个权限选项选“Object Read Write”作用范围可以指定到刚才创建的桶也可以选所有桶。这里我建议权限最小化只给对象读写权限不要给 Admin 全权避免以后令牌泄露造成更大损失。创建完成后页面会显示 Access Key ID 和 Secret Access Key。Secret 只会显示一次务必立刻复制到本地保存。同时还要找到你的 Account ID位置在 R2 概览页或者是 URL 里是一串 32 位的字符后面拼接 S3 API 端点要用到。R2 的 S3 API 端点格式是https://ACCOUNT_ID.r2.cloudflarestorage.com这个地址就是脚本上传时要请求的服务端地址和 AWS S3 的https://s3.amazonaws.com是一个位置。把这几个信息记下来后面写 Python 脚本就能直接用。3. 手写 Python 上传脚本3.1 依赖选型用 requests 还是 boto3既然 R2 兼容 S3 API第一反应可能是用 AWS 官方 SDK也就是 boto3。boto3 也能跑但有几个问题它是个很大的依赖包安装慢对于只需要上传一张图片这种小场景来说有点杀鸡用牛刀。我更倾向用纯requests加 Python 标准库实现。requests 负责发 HTTP 请求标准库里的 hashlib 和 hmac 负责做 AWS Signature V4 签名。整个脚本核心逻辑几十行没有多余的依赖跨平台还好维护。你要是环境里没有 requests直接pip install requests装一下就行。签名逻辑是 AWS S3 通用的不管你以后把数据迁到哪个兼容 S3 的对象存储这段签名代码都能复用。3.2 完整可用的上传脚本下面这个脚本就是我现在一直在用的版本配置好之后扔到任意目录通过命令行传入本地图片路径就能上传默认返回纯文本 URL这个输出格式和 Typora 的预期完全匹配。import os import sys import hashlib import hmac import mimetypes import uuid from datetime import datetime, timezone import requests # 需要根据自己的账号信息修改 ACCOUNT_ID 你的Account_ID ACCESS_KEY_ID 你的Access_Key_ID SECRET_ACCESS_KEY 你的Secret_Access_Key BUCKET_NAME typora-images PUBLIC_BASE_URL https://img.example.com # 自定义域名或 r2.dev 域名末尾不要带斜杠 # ENDPOINT fhttps://{ACCOUNT_ID}.r2.cloudflarestorage.com REGION auto def sign_v4(method, url, headers, payload_hash, access_key, secret_key, region, date_now): # 解析 host 和 uri parsed_url requests.utils.urlparse(url) host parsed_url.netloc canonical_uri parsed_url.path or / canonical_query parsed_url.query # canonical headers 里必须包含 host、x-amz-content-sha256、x-amz-date canonical_headers ( fcontent-type:{headers[Content-Type]}\n fhost:{host}\n fx-amz-content-sha256:{payload_hash}\n fx-amz-date:{date_now}\n ) signed_headers content-type;host;x-amz-content-sha256;x-amz-date canonical_request \n.join([ method, canonical_uri, canonical_query, canonical_headers, signed_headers, payload_hash, ]).encode(utf-8) datestamp date_now[:8] scope f{datestamp}/{region}/s3/aws4_request string_to_sign \n.join([ AWS4-HMAC-SHA256, date_now, scope, hashlib.sha256(canonical_request).hexdigest(), ]).encode(utf-8) def hmac_sha256(key, msg): return hmac.new(key, msg, hashlib.sha256).digest() date_key hmac_sha256((AWS4 secret_key).encode(utf-8), datestamp.encode(utf-8)) region_key hmac_sha256(date_key, region.encode(utf-8)) service_key hmac_sha256(region_key, bs3) signing_key hmac_sha256(service_key, baws4_request) signature hmac.new(signing_key, string_to_sign, hashlib.sha256).hexdigest() authorization ( fAWS4-HMAC-SHA256 Credential{access_key}/{scope}, fSignedHeaders{signed_headers}, Signature{signature} ) return authorization def upload_image(file_path): if not os.path.exists(file_path): print(f文件不存在: {file_path}, filesys.stderr) return 1 with open(file_path, rb) as f: data f.read() # 生成唯一对象名保留原始扩展名 ext os.path.splitext(file_path)[1].lower() object_key fuploads/{datetime.now(timezone.utc).strftime(%Y/%m)}/{uuid.uuid4().hex}{ext} url f{ENDPOINT}/{BUCKET_NAME}/{object_key} content_type mimetypes.guess_type(file_path)[0] or application/octet-stream payload_hash hashlib.sha256(data).hexdigest() date_now datetime.now(timezone.utc).strftime(%Y%m%dT%H%M%SZ) headers { Host: requests.utils.urlparse(url).netloc, Content-Type: content_type, x-amz-content-sha256: payload_hash, x-amz-date: date_now, } auth sign_v4( methodPUT, urlurl, headersheaders, payload_hashpayload_hash, access_keyACCESS_KEY_ID, secret_keySECRET_ACCESS_KEY, regionREGION, date_nowdate_now, ) headers[Authorization] auth resp requests.put(url, headersheaders, datadata) if resp.status_code 200: print(f{PUBLIC_BASE_URL}/{object_key}) return 0 else: print(f上传失败 {resp.status_code}: {resp.text}, filesys.stderr) return 1 if __name__ __main__: if len(sys.argv) 2: print(用法: python upload_to_r2.py 图片路径 [图片路径...], filesys.stderr) sys.exit(1) # 依次上传每张图片Typora 会逐行读取 stdout exit_code 0 for image_path in sys.argv[1:]: if upload_image(image_path) ! 0: exit_code 1 sys.exit(exit_code)使用方式很简单python upload_to_r2.py /path/to/your/image.png如果上传成功终端只会输出一行 URL例如https://img.example.com/uploads/2025/04/abc123.png。这段代码我用在 Typora 上两年多除了换域名改过一次环境变量基本没再动过。这里有个细节值得说一下脚本里将对象键设计成uploads/年份/月份/uuid文件名的格式。这样有几个好处一是按月份归类后续不管是在 Web 控制台看还是写清理脚本都非常直观二是用 UUID 而不是原文件名避免同名文件互相覆盖也防止 URL 中的文件名带上中文或空格导致编码问题三是在对象键加随机前缀能够有效避免全球 CDN 命中率过于集中虽然没有直接证据说明这能提升多少性能但从自己管理的角度来说更安心。3.3 签名过程的几个易错点这一段是整套方案里最容易出问题的地方。AWS Signature V4 的签名看起来复杂其实核心就三步构造规范化请求、构造待签字符串、用 HMAC 逐层派生签名密钥。第一次写这代码时最容易踩的坑是规范化请求里的 Header 顺序。AWS 要求所有参与签名的 Header 都要按字节序排序签名的时候也不能漏掉host、x-amz-date、x-amz-content-sha256这几个字段。之前见过有人用自己的本地时间戳签名结果因为和服务器 UTC 时间差了几个小时一直报403 SignatureDoesNotMatch。一定要注意date_now用的是datetime.now(timezone.utc)。第二个坑是 payload 哈希。PutObject 请求必须把文件内容的 SHA256 哈希填到请求头x-amz-content-sha256里同时参与签名计算。如果漏传这个字段或者填了固定字符串R2 会直接拒绝。requests 库默认不会自动计算这个值所以我在代码里先读取整个文件到内存再算哈希最后把原始字节data传进去确保发送的内容和哈希一致。第三个坑是 Host 字段。requests 库会自己设置 Host 头但在签名计算时你要保证签的是同一个 Host。我代码里是从 url 解析 netloc 塞进 headers这样就算换 endpoint也不容易签错。代码里headers[Host]看起来多此一举实际上是为了强制和签名对齐。3.4 不想在每个设备装 Python打包成单个可执行文件如果你觉得在目标电脑上装 Python 麻烦可以用 PyInstaller 把脚本打包成单个可执行文件这样那一端设备只需要一个 exe 或者二进制文件就能调用。打包命令pip install pyinstaller pyinstaller -F upload_to_r2.py完成后在dist目录下就能找到upload_to_r2的可执行文件。macOS 和 Linux 直接双击或者命令行运行Windows 上是 exe。之后 Typora 的自定义命令直接指向这个可执行文件就行不需要在那台机器上安装 Python 环境。打包出来的文件一般也就 10MB 左右对轻量方案来说完全能接受。4. 打通 Typora自定义命令配置的完整过程4.1 Typora 的“自定义命令”到底是怎么工作的Typora 本身不是图床但它做了一个很好的开放设计图片上传服务可以选“自定义命令”。原理很简单当你在文档里粘贴一张图片、拖入一张图片或者点击上传按钮时Typora 会把这个图片的本地绝对路径作为参数传给你在设置里填的命令然后捕获命令的标准输出把输出的每一行当作新的图片 URL 替换到文档里。理解了这个机制你就知道为什么不需要装什么专门插件了。只要任何程序能做到“接收图片路径 → 上传 → 输出 URL”Typora 就能把它当图床用。Python 脚本天然适合干这个活而且逻辑完全透明出了任何问题都能直接命令行手动测一遍。4.2 配置示例与验证打开 Typora进入偏好设置找到图像。在“上传服务”下拉框里选择“Custom Command”。然后在下方的自定义命令输入框里填上你的调用命令。Windows 上的写法是C:\Users\你的用户名\AppData\Local\Programs\Python\Python311\python.exe D:\tools\upload_to_r2.pymacOS 上的写法是/usr/bin/python3 /Users/你的用户名/scripts/upload_to_r2.pyLinux 上的写法类似直接写python3 /home/某用户/scripts/upload_to_r2.py。填完之后到图像设置的验证区域选择一张本地图片点击“上传”按钮测试。如果一切正常Typora 会把本地路径替换成https://img.example.com/uploads/2025/04/xxx.png这样一段外链。第一次测试的时候建议在“图像”选项卡里的“上传后删除本地图片副本”保持不勾选等确认脚本稳定了再按需开启。因为如果上传失败或者 Typora 返回异常本地文件还在还有补救机会一旦开了“删除本地文件”脚本失败时你连原始文件都没了在文档里只剩下一个坏链接。4.3 体验优化与注意事项Typora 的图像设置里还有几个选项比如“在拖动或粘贴图片时自动上传”“在导出时上传图片”“对本地图片应用以上规则”等。我个人的建议是只勾选“插入图片时自动上传”其他可以按自己的习惯开。导出时上传这个功能在某些场景下会批量重写文档里所有本地图片如果你的笔记库里历史图片很多第一次开的时候可能把整个库的图片全部重新传一遍时间会比较长而且部分早年间已经用相对路径关联的图片也可能被改写路径不好回退。截图粘贴是 Typora 里最顺手的操作。按下截图工具的快捷键粘贴到 Typora几乎同时脚本就会把图片放到 R2 上文档里的图片已经变成了外链。全程不需要你手动打开任何面板体验和用本地图片时没有任何区别。5. 避坑指南跑这套方案时最常踩的五个坑5.1 上传报 403 SignatureDoesNotMatch这是最常见的错误原因基本逃不开三种第一种本机系统时间和 UTC 时间差得太多。我之前把一台老 mbp 的系统时间调整过然后上传就开始报 403最开始还以为账号被封了后来发现是系统时钟快了五分钟。签名里的X-Amz-Date是 UTC 时间时间偏差只要超过十几分钟基本就签不过。解决办法是检查系统时间或者到系统设置里打开“自动设置时间”。第二种Secret Access Key 复制错误。创建 API Token 时 Secret 只会显示一次如果当时没存好后面重新猜一个肯定是错的。去 Cloudflare 后台重新生成一对 Access Key 就行。第三种权限不足。如果创建 API Token 时只给了读权限上传 PUT 请求会被拒绝。排查时直接用curl把脚本请求完整模拟一遍看返回的 XML 错误码能快速定位。实际上大部分 403 都是签名和时间问题。5.2 图片上传成功但 Typora 依然提示“上传失败”这个问题很隐蔽。R2 那边能看到文件但是 Typora 界面显示红色感叹号说明脚本的 stdout 输出不符合 Typora 的预期。最常见的原因是脚本里打印了日志比如调试时加的print(file_path)或者包了logger.info。Typora 会严格把每一行 stdout 当作图片链接一旦某一行不是完整 URL它就可能判定失败。另外多张图片同时上传时脚本必须一张图输出一行 URL不能多打空行也不能把错误信息混在 stdout 里。我脚本里的错误信息是打印到sys.stderr的这样既能方便命令行排查又不会干扰 Typora 解析输出内容。5.3 绑定了自定义域名但是图片打开很慢如果你用自定义域名记得去 Cloudflare DNS 控制台检查这条记录是否开启了橙色云朵代理。开了代理意味着请求会经过 Cloudflare CDN自动享有缓存和加速如果只是灰色的纯 DNS 记录请求会直接回源到 R2 的节点速度可能不如走了 CDN 快国外节点访问效果尤其明显。还有一个小细节R2 的桶设置里绑定的自定义域名和你在 DNS 控制台加的 CNAME 解析要对应好。Cloudflare 会自动帮你加记录前提是域名托管在同一个账号下。如果你另外有域名托管在其他 DNS 服务商就得自己把子域名 CNAME 到 R2 官方给的地址这一步在桶设置页面里能看到具体值。5.4 免费额度会不会不够用R2 的免费额度是每月 10GB 存储、100 万次读、100 万次写。对个人博客和笔记来说这个量级非常宽裕。我自己的博客写了一百多篇文章图片加起来不到 3GB读写次数每个月也就几千次远没到限制。但如果你把截图、录屏素材全往里扔那就另当别论了。真的超了也不用太担心一是可以在桶设置里配置生命周期规则比如设置“最近 90 天未访问的对象自动删除”把某些不重要的临时图清理掉二是可以按月生成子目录方便手动归档或者定期备份到本地清空不用的旧图。存储量大的话还可以考虑把大文件放到其他专门的存储服务R2 只放文章图片这种小文件。这样免费额度能覆盖日常使用。5.5 密钥和隐私内容的管理Access Key 一旦泄露别人就能往你的桶里传文件甚至把桶塞满产生费用。我的做法是把账号信息和密钥单独放在一个 Python 配置文件中用.gitignore忽略不用明文贴在脚本顶部如果团队合作可以用环境变量或.env文件管理脚本里通过os.environ读取。另外要特别注意开启公网访问的桶等于“全世界只要知道 URL 就能看图”所以身份证、聊天截图等隐私内容不要传进这个公开桶。如果确实需要存私密文件可以再建一个私有桶上传后不公开配合预签名 URL 让指定的人访问几分钟。R2 支持 S3 API 里的预签名 URL 功能需要的可以在现有脚本基础上扩展一个方法。6. 一些复盘和后续扩展6.1 这套方案还能往哪些方向延伸目前脚本只处理了上传和返回 URL其实可以在这个基础上做不少扩展。比如把脚本改造成 PicGo 的插件形式PicGo 本身就是专门的图床上传工具如果你以后不只使用 Typora还可以在 PicGo 里直接调用这个脚本。PyInstaller 打包后的可执行文件同样可以在其他软件里复用。再比如批量迁移历史图片。我之前在旧笔记里存了大量本地图片写了一小段 Python 脚本遍历 Markdown 文件用正则提取![alt](path)里的本地路径逐个调用上传函数再把 URL 写回文件。整个过程不到二十分钟就跑完了比手动传省力气得多。脚本本质上只是把这篇文章里的upload_image拿出来复用对接任何需要上传的批量任务都很顺手。R2 和 Cloudflare Workers 配合还能玩出更多花样。比如用 Worker 做图片压缩、添加水印、统计访问量或者对部分路径做鉴权。这些属于进阶玩法平时用不上但知道有这条路等需求来了就不用另起炉灶。6.2 用下来的几点体感这套方案最让我放心的一点是没有把自己绑在某个小众服务上。R2 是 Cloudflare 的产品一个以稳定和免费著称的服务商存储桶的数据永远在自己账号下随时可以迁走Python 脚本也是标准写法不存在依赖某些年久失修的插件问题。说白了这套方案的核心资产是数据本身而不是某个平台的 API。还有一个小技巧是给图片加日期前缀。比如把对象键设计成content/20250712/xxx.png以后如果想按时间段清理或者统计图片增长量直接靠对象键前缀过滤不用把结果拉到本地再分析。这个习惯我还是相当推荐的。如果你也在用 Typora或者平时需要大量截图写文档、做记录我建议花十分钟把这个图床搭好。第一次配置可能有点折腾但搭完以后基本就是一劳永逸之后你做文档或者博客排版时只需要专注内容本身图片这件事彻底交给 Python 和 R2。
返回列表