把图床搬进终端:一张图或整篇文章,都只输出链接
写博客要配图。图不能放在仓库里,得传图床,拿直链。
网页上传的流程是这样的:打开浏览器,登录,点上传,选文件,等进度条,找到那行链接,复制。一张图还好,一次传五张,来回切窗口就烦了。
我不想要一个上传页面。我要一条命令:给文件,还链接。
用法
单张图:
uv run imgchest49.py ./screenshot.png标准输出只有一行直链:
https://cdn.imgchest.com/files/09cc6fec1df1.jpg整篇 Markdown 也行:
uv run imgchest49.py docs/post/2026.02.24.183055-setu.md它会找到文中的本地图片引用,一张张上传,然后就地换掉:
已上传 ./151421457_p0-ハロウィン2B.jpg → https://cdn.imgchest.com/files/09cc6fec1df1.jpg
docs/post/2026.02.24.183055-setu.md:替换 1 处,跳过 0 处,网络图片 0 处未动单图模式下标准输出只有链接,能直接塞进 $(...) 或者 xargs。Markdown 模式的逐张上传进度走 stderr,最后一行汇总走 stdout,方便核对有没有漏图。
完整代码
两百多行,全放在这里,后面只拆几处。
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "httpx>=0.27.0",
# ]
# ///
"""上传本地图片到 imgchest 图床,拿到可外链的直链网址。
imgchest49.py <图片路径> 上传一张图,把直链打印到标准输出
imgchest49.py <Markdown 路径> 上传文中引用的本地图片,就地替换为直链
Markdown 模式只认内联写法 ``(含 `<路径 带空格>` 与 `"title"`):
网络图片原样不动,指向不存在文件的引用会跳过并在最后汇报;中途上传失败则整个
文件不写盘,保持原样——宁可没改,也不要留下改了一半的文档。
"""
import json
import mimetypes
import os
import re
import sys
import time
from pathlib import Path
from urllib.parse import urlsplit
import httpx # pyright: ignore[reportMissingImports]
API_URL = "https://api.imgchest.com/v1/post"
API_KEY_ENV_NAMES = ("IMGCHEST_API_KEY", "imgchest-key")
FORM_FIELDS = {"privacy": "hidden", "nsfw": "1"}
TIMEOUT = httpx.Timeout(60.0, connect=10.0)
MAX_ATTEMPTS = 3
RETRYABLE_STATUSES = {408, 409, 425, 429, 500, 502, 503, 504}
MARKDOWN_SUFFIXES = {".md", ".markdown"}
# 、、
IMAGE_REFERENCE = re.compile(
r"!\[[^\]]*\]\(\s*(?P<url><[^>]*>|[^\s)]+)(?:\s+(?:\"[^\"]*\"|'[^']*'))?\s*\)"
)
EXIT_USAGE = 1 # 参数、文件或配置错误
EXIT_RESPONSE = 2 # HTTP 状态码异常,或响应内容不符合预期
EXIT_NETWORK = 3 # 网络错误,重试耗尽
class UploadError(Exception):
"""可预期的失败:消息直接展示给用户,exit_code 作为进程退出码。"""
def __init__(self, message: str, exit_code: int = EXIT_USAGE) -> None:
super().__init__(message)
self.exit_code = exit_code
def parse_env(text: str) -> dict[str, str]:
"""把 .env 文本解析成键值对,忽略注释,去掉 export 前缀与值两侧引号。"""
pairs: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
name, separator, value = line.removeprefix("export ").partition("=")
if separator:
pairs[name.strip()] = value.strip().strip("\"'")
return pairs
def read_api_key() -> str:
"""读取 $SECRETS49/.env 中的图床 API key。"""
secrets_dir = os.environ.get("SECRETS49")
if not secrets_dir:
raise UploadError("配置错误: 未设置环境变量 SECRETS49")
env_path = Path(secrets_dir) / ".env"
try:
env = parse_env(env_path.read_text(encoding="utf-8"))
except OSError as exc:
raise UploadError(f"配置错误: 无法读取 {env_path}: {exc}") from exc
for name in API_KEY_ENV_NAMES:
if api_key := env.get(name):
return api_key
names = " / ".join(API_KEY_ENV_NAMES)
raise UploadError(f"配置错误: {env_path} 中没有非空的 API key(候选键名: {names})")
def post_image(client: httpx.Client, file_path: Path, api_key: str) -> httpx.Response:
"""提交一次上传请求。"""
mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
try:
image = file_path.open("rb")
except OSError as exc:
raise UploadError(f"本地文件错误: 无法打开 {file_path}: {exc}") from exc
with image:
return client.post(
API_URL,
data=FORM_FIELDS,
files={"images[]": (file_path.name, image, mime_type)},
headers={"Authorization": f"Bearer {api_key}"},
)
def upload_with_retry(client: httpx.Client, file_path: Path, api_key: str) -> httpx.Response:
"""上传图片;网络错误或可重试状态码按指数退避重试,返回最后一次响应。"""
network_error: httpx.TransportError | None = None
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
response = post_image(client, file_path, api_key)
except httpx.TransportError as exc:
network_error = exc
problem = f"网络错误: {exc}"
else:
if attempt == MAX_ATTEMPTS or response.status_code not in RETRYABLE_STATUSES:
return response
problem = f"HTTP {response.status_code}"
response.close()
if attempt == MAX_ATTEMPTS:
break
wait = 2 ** (attempt - 1)
print(f"{problem},{wait} 秒后重试({attempt}/{MAX_ATTEMPTS})", file=sys.stderr)
time.sleep(wait)
# 可重试状态码在最后一次尝试时直接返回,所以循环只可能因网络错误耗尽而走到这里
raise UploadError(
f"网络错误: 尝试 {MAX_ATTEMPTS} 次后仍失败: {network_error}", EXIT_NETWORK
) from network_error
def pretty_body(response: httpx.Response) -> str:
"""响应体的可读形式:JSON 缩进输出,其他原样返回。"""
try:
return json.dumps(response.json(), ensure_ascii=False, indent=2)
except ValueError:
return response.text
def extract_links(response: httpx.Response) -> list[str]:
"""校验响应并取出图片链接,不符合预期时抛 UploadError。"""
if not response.is_success:
raise UploadError(f"HTTP {response.status_code}\n{pretty_body(response)}", EXIT_RESPONSE)
try:
body = response.json()
except ValueError as exc:
raise UploadError(f"响应不是合法 JSON\n{response.text}", EXIT_RESPONSE) from exc
try:
links = [str(image["link"]) for image in body["data"]["images"]]
except (KeyError, TypeError) as exc:
raise UploadError(f"响应格式错误({exc})\n{pretty_body(response)}", EXIT_RESPONSE) from exc
if not links:
raise UploadError("响应中没有图片链接", EXIT_RESPONSE)
return links
def upload_image(client: httpx.Client, file_path: Path, api_key: str) -> str:
"""上传一张图片,返回直链。"""
return extract_links(upload_with_retry(client, file_path, api_key))[0]
def is_local(url: str) -> bool:
"""引用是否指向本地文件;http(s)、data: 之类网络地址返回 False。"""
return len(urlsplit(url).scheme) <= 1 # "C:" 这类单盘符仍算本地路径
def fenced_ranges(text: str) -> list[tuple[int, int]]:
"""返回围栏代码块覆盖的偏移区间,替换时跳过。"""
ranges: list[tuple[int, int]] = []
fence = ""
start = 0
offset = 0
for line in text.splitlines(keepends=True):
stripped = line.lstrip()
if not fence:
if stripped.startswith("```") or stripped.startswith("~~~"):
fence = stripped[:3]
start = offset
elif stripped.startswith(fence):
ranges.append((start, offset + len(line)))
fence = ""
offset += len(line)
if fence:
ranges.append((start, offset))
return ranges
def write_markdown(path: Path, text: str) -> None:
"""先写同目录临时文件再替换,避免中途失败留下半截文档。"""
try:
mode = path.stat().st_mode & 0o777
except OSError:
mode = 0o644
staging = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
staging.write_text(text, encoding="utf-8")
staging.chmod(mode)
os.replace(staging, path)
except OSError as exc:
raise UploadError(f"写入失败: {path}: {exc}") from exc
finally:
staging.unlink(missing_ok=True)
def replace_local_images(md_path: Path, client: httpx.Client, api_key: str) -> None:
"""把 Markdown 中引用的本地图片换成直链,就地写回。"""
try:
text = md_path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise UploadError(f"读取失败: {md_path} 不是 UTF-8 文本: {exc}") from exc
except OSError as exc:
raise UploadError(f"读取失败: {md_path}: {exc}") from exc
code_ranges = fenced_ranges(text)
links: dict[Path, str] = {} # 同一张图被引用多次时只上传一次
pieces: list[str] = []
cursor = 0
replaced = skipped = remote = 0
for match in IMAGE_REFERENCE.finditer(text):
if any(start <= match.start() < end for start, end in code_ranges):
continue
url = match["url"].strip("<>")
if not is_local(url):
remote += 1
continue
image_path = (md_path.parent / url).resolve()
if not image_path.is_file():
print(f"跳过不存在的文件: {url}", file=sys.stderr)
skipped += 1
continue
if image_path not in links:
links[image_path] = upload_image(client, image_path, api_key)
print(f"已上传 {url} → {links[image_path]}", file=sys.stderr)
# 只替换 URL 那一段,alt 文本与 title 原样保留
pieces.append(text[cursor : match.start("url")])
pieces.append(links[image_path])
cursor = match.end("url")
replaced += 1
if replaced:
pieces.append(text[cursor:])
write_markdown(md_path, "".join(pieces))
print(f"{md_path}:替换 {replaced} 处,跳过 {skipped} 处,网络图片 {remote} 处未动")
def main() -> int:
if len(sys.argv) != 2:
print(f"用法: uv run {sys.argv[0]} <图片或 Markdown 路径>", file=sys.stderr)
return EXIT_USAGE
target = Path(sys.argv[1])
if not target.is_file():
print(f"本地文件错误: 文件不存在或不是普通文件: {target}", file=sys.stderr)
return EXIT_USAGE
try:
api_key = read_api_key()
with httpx.Client(timeout=TIMEOUT, follow_redirects=True) as client:
if target.suffix.lower() in MARKDOWN_SUFFIXES:
replace_local_images(target, client, api_key)
else:
print(upload_image(client, target, api_key))
except UploadError as exc:
print(exc, file=sys.stderr)
return exc.exit_code
return 0
if __name__ == "__main__":
raise SystemExit(main())密钥的读法比功能本身还多
$SECRETS49/.env 是这台机器上所有脚本共用的密钥文件。格式约定得不严:有时写 export KEY=value,有时值带引号,有时中间有空格。
所以读取器不支持「一行一分割」这种偷懒写法:
def parse_env(text: str) -> dict[str, str]:
"""把 .env 文本解析成键值对,忽略注释,去掉 export 前缀与值两侧引号。"""
pairs: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
name, separator, value = line.removeprefix("export ").partition("=")
if separator:
pairs[name.strip()] = value.strip().strip("\"'")
return pairs空行和 # 开头的注释直接跳过,注释里带等号也不会被误认成键值。export 前缀去掉,键和值各自 strip,值外面的单双引号剥掉。够用了,再复杂就该上 dotenv 库,但我这里就一个 key,不值得。
支持两个键名:IMGCHEST_API_KEY 和 imgchest-key。前者是通用写法,后者跟我其他脚本的命名一致。找不到时错误信息会把候选名都列出来:
配置错误: /path/.env 中没有非空的 API key(候选键名: IMGCHEST_API_KEY / imgchest-key)不让人去猜「到底该写哪个名字」。省得每次翻文档。
只重试值得重试的
图床接口偶尔会抽风。直接失败很浪费,无脑重试又可能把账号打出问题。所以只对明确「过一会儿再来」的状态码重试:
RETRYABLE_STATUSES = {408, 409, 425, 429, 500, 502, 503, 504}
if attempt == MAX_ATTEMPTS:
break
wait = 2 ** (attempt - 1)
print(f"{problem},{wait} 秒后重试({attempt}/{MAX_ATTEMPTS})", file=sys.stderr)
time.sleep(wait)429 限流、5xx 服务端错误、408 超时,这些重试有意义。401 密钥错了、413 文件太大,重试一百次也没用,直接报错。
最多三次。前两次失败后等 1 秒、2 秒;第三次直接报错,不再空等。等待都在 3 秒以内,足够让一次临时故障恢复,又不至于让用户以为卡死了。
网络层的 TransportError 也走同一条重试路径。连接被重置、DNS 抽风,这些和 502 本质一样,都是「现在不行,等会儿可能行」。
退出码分三档
EXIT_USAGE = 1 # 参数、文件或配置错误
EXIT_RESPONSE = 2 # HTTP 状态码异常,或响应内容不符合预期
EXIT_NETWORK = 3 # 网络错误,重试耗尽参数错了是 1,服务器返回不对是 2,网络不通是 3。
分三档有实际用处。批量上传时,我可以根据退出码决定下一步:参数错就是脚本写错了,停下来;网络错就换个时间重跑;响应错就把响应贴出来看。
如果所有错误都返回 1,调用方只能知道「失败了」,不知道「为什么失败、该不该重试」。
失败时把响应原样贴出来
接口出错时,最烦的就是只看到「请求失败」。到底是 JSON 字段变了,还是图片格式不支持,还是账号被限了?
这版的处理是:
def pretty_body(response: httpx.Response) -> str:
"""响应体的可读形式:JSON 缩进输出,其他原样返回。"""
try:
return json.dumps(response.json(), ensure_ascii=False, indent=2)
except ValueError:
return response.text
def extract_links(response: httpx.Response) -> list[str]:
if not response.is_success:
raise UploadError(f"HTTP {response.status_code}\n{pretty_body(response)}", EXIT_RESPONSE)是 JSON 就缩进排版,不是 JSON 就原样输出。服务器的原话比我概括的有用。
解析成功但结构不对时也一样:
try:
links = [image["link"] for image in body["data"]["images"]]
except (KeyError, TypeError) as exc:
raise UploadError(f"响应格式错误({exc})\n{pretty_body(response)}", EXIT_RESPONSE) from excKeyError 会告诉你缺了哪个键,TypeError 会告诉你哪里类型不对,后面跟着完整响应。拿到这个报错,我不用复现、不用抓包,看一遍就知道接口改了什么。
解析失败也有单独的说法:响应不是合法 JSON,后面跟原始文本。省得把「服务器返回了 HTML 错误页」误判成「字段名变了」。
匿名上传没了
imgchest 前一阵把匿名上传关了。脚本第一次撞上时,返回的是:
{"error": "Anonymous uploads are currently disabled. Upload without the anonymous option."}表单里删掉 anonymous 就好了:
FORM_FIELDS = {"privacy": "hidden", "nsfw": "1"}现在图片会挂在 API key 对应的账号下,不再是匿名上传。privacy=hidden 仍然让它不进公开列表,nsfw=1 是保守标记——图床的自动审核经常误判,标了反而省事。
这些值还是写死在常量里。我每次上传都要的东西,不该每次在命令行里敲。
Markdown 模式
最初只有单图模式。后来写文章要配图,一张张上传再一张张替换,实在烦,就加了 Markdown 模式。
它只认内联写法 ,<路径 带空格> 和带 "title" 的变体也支持。路径按 Markdown 文件所在目录解析,./img/a.png、../b.png 都行。已经是 http(s):// 或 data: 的引用原样不动。围栏代码块里的示例不会被替换——否则文章里贴一段 Markdown 代码,连示例图片都会被传上去。
关键几行:
for match in IMAGE_REFERENCE.finditer(text):
if any(start <= match.start() < end for start, end in code_ranges):
continue
url = match["url"].strip("<>")
if not is_local(url):
remote += 1
continue
image_path = (md_path.parent / url).resolve()
if not image_path.is_file():
print(f"跳过不存在的文件: {url}", file=sys.stderr)
skipped += 1
continue
if image_path not in links:
links[image_path] = upload_image(client, image_path, api_key)同一张图被引用多次,只上传一次,后面的引用复用第一个链接。替换只动 URL 那一段,alt 文本和 title 原样保留。
指向不存在文件的引用跳过,并在最后汇报数量。只要有一张图上传失败,异常会一路抛到 main,文件根本不写盘。真正写回时,也先写同目录临时文件再 os.replace,中途被打断不会留下半截文档。宁可没改,也不留改了一半的文档。
现在怎么用
单图直接传,文章就整篇丢进去。刚才那篇存档页就是跑了一遍 Markdown 模式:一张 5.7MB 的 jpg,替换 1 处,本地路径变成 imgchest 直链。
它一共两百多行,功能就两件事:把文件变成链接,或者把一整篇文章里的本地图片都变成链接。
麻烦还是那几个:密钥格式、重试策略、退出码、错误信息、输出分流。单看都不算什么,凑一起就是每次上传多花两分钟。
我现在写文章,截图存本地,要么单张上传,要么直接对整篇 Markdown 跑一次。中间没有浏览器,没有复制粘贴,也不用猜「上传失败」失败在哪一步。