使用Python为Typora添加图片上传功能

Typora 是我目前最常用的 Markdown 编辑器,界面简洁美观,采用类似 Notion 的所见即所得编辑模式,写文档或博客非常方便。

对于写博客来说,插入图片是必不可少的。打开设置项,可以看到 Typora 内置支持多种图床,不过没有我平时使用的 Minio,好在它也支持自定义命令。

screenshot-20240902-225242

实现自定义程序也很简单。根据官方文档说明,当你拖拽图片到编辑界面时,编辑器会使用图片路径作为参数运行你的程序,然后读取程序标准输出的最后 n 行作为图片地址。也就是说,支持同时上传多个图片。于是就有了这个脚本,除了上传文件还会:

  1. 压缩图片
  2. 去除 EXIF 信息,保护隐私
  3. 支持多进程并发

使用前需要:

  1. 修改第一行,指明 Python 路径
  2. 安装程序依赖:pip install pillow minio
  3. 替换代码里的对象存储配置

理论上可以支持 AWS S3、Cloudflare R2 等众多对象存储。

#! /Users/lerry/code/server_tools/venv/bin/python

# pip install pillow minio

import datetime
import hashlib
import mimetypes
import os
import shutil
import sys
from typing import List
from concurrent.futures import ProcessPoolExecutor
from PIL import Image
import tempfile

from minio import Minio
from minio.error import S3Error

# 使用环境变量
PUBLIC_DOMAIN = "https://change.me"
MINIO_STORAGE_ACCESS_KEY = "CHANGE_ME"
MINIO_STORAGE_SECRET_KEY = "CHANGE_ME"
MINIO_STORAGE_ENDPOINT = "CHANGE_ME.xxx"
MINIO_STORAGE_MEDIA_BUCKET_NAME = "CHANGE_ME"
MINIO_STORAGE_MEDIA_OBJECT_METADATA = {"Cache-Control": "max-age=86400"}
MINIO_STORAGE_USE_HTTPS = True

upload_path = f"uploads/{datetime.datetime.now().strftime('%Y%m')}"


client = Minio(
    MINIO_STORAGE_ENDPOINT,
    access_key=MINIO_STORAGE_ACCESS_KEY,
    secret_key=MINIO_STORAGE_SECRET_KEY,
    secure=MINIO_STORAGE_USE_HTTPS,
)


def get_mime_type(path: str) -> str:
    return mimetypes.guess_type(path)[0] or "application/octet-stream"


def copy_to_tmp(path: str) -> tuple[str, str, str]:
    ext = os.path.splitext(path)[1]
    content_type = get_mime_type(path)
    tmp_file = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
    shutil.copy(path, tmp_file.name)
    print(f"Copy {path} to {tmp_file.name}, content type: {content_type}")
    return tmp_file.name, ext, content_type


def remove_exif(tmp_file_name: str) -> None:
    try:
        with Image.open(tmp_file_name) as img:
            data = list(img.getdata())
            image_without_exif = Image.new(img.mode, img.size)
            image_without_exif.putdata(data)
            image_without_exif.save(tmp_file_name, exif=b"")  # 设置exif为空
        print(f"Exif data removed from {tmp_file_name}")
    except Exception as e:
        print(f"Error removing Exif data from {tmp_file_name}: {e}")


def compress_image(tmp_file_name: str, quality: int = 90) -> None:
    try:
        with Image.open(tmp_file_name) as im:
            im.save(tmp_file_name, quality=quality, optimize=True)
        print(f"Image compressed: {tmp_file_name}")
    except Exception as e:
        print(f"Error compressing image {tmp_file_name}: {e}")


def get_md5sum(tmp_file_name: str) -> str:
    with open(tmp_file_name, "rb") as f:
        return hashlib.md5(f.read()).hexdigest()


def upload_image(path: str) -> str:
    try:
        tmp_file_name, ext, content_type = copy_to_tmp(path)
        remove_exif(tmp_file_name)
        compress_image(tmp_file_name)
        md5sum = get_md5sum(tmp_file_name)
        object_name = f"{upload_path}/{md5sum}{ext.lower()}"

        meta = MINIO_STORAGE_MEDIA_OBJECT_METADATA.copy()

        client.fput_object(
            MINIO_STORAGE_MEDIA_BUCKET_NAME,
            object_name,
            tmp_file_name,
            metadata=meta,
            content_type=content_type,
        )
        print(f"Upload {path} success!")
        os.unlink(tmp_file_name)  # 确保临时文件被删除
        return f"{PUBLIC_DOMAIN}/{MINIO_STORAGE_MEDIA_BUCKET_NAME}/{object_name}"
    except S3Error as exc:
        print(f"Error occurred while uploading {path}: {exc}")
        return ""
    except Exception as e:
        print(f"Unexpected error: {e}")
        return ""


def main(paths: List[str]) -> None:
    with ProcessPoolExecutor() as executor:
        urls = list(executor.map(upload_image, paths))

    succeed = [url for url in urls if url]
    print("\n".join(succeed))


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: upload-image.py <path1> [path2 ...]")
        sys.exit(1)
    else:
        main(sys.argv[1:])