需求来源

Hexo博客难以长期维护

博客,是学习计算机科学的同学所必备的网站,好的博文能够体现一个人的能力和思考水平,一个长期维护的博客更是一个人是否拥有长期坚持品质的重要证明。毕竟,如果你能长期维护好一个博客,那么由你负责的项目大概率不会被你中途放弃。

说起博客,我自22年11月起搭建起了个人博客CagurZhan's Blog (现改名AjaxZhan's Blog),我原先使用的是Hexo进行博客的发布,然而,Hexo博客的最大头疼点就在于折腾,由于他是纯前端,导致文章的编辑、发布、上传都十分麻烦。

当时我不以为然,我认为一个计算机的同学最需要的就是动手能力,hexo的繁琐配置正好可以锻炼我的动手能力。然而事实证明,当你的博客规模日渐扩大,这些繁琐的事情会让你压根不想维护这个博客,导致我后面零零散散地将博文发布到CSDN和掘金。

CMS:将博客更换为Halo

鉴于上面的原因,我在近日将博客换成了Halo。然而,如何将博客的文章搬运到新系统就成为一个问题。之前我的博文主要写到了下面几个地方:

  • 本地Obsidian:所有笔记都存于此,部分经过整理和润色后会发布。
  • Hexo:原来大部分博客存放的地方。
  • 掘金:主要是一些后端技术文章。
  • CSDN:主要是一些学科复习笔记。

手工迁移博文十分麻烦,我们的时间应该花在更有价值的事情。上面的几种博客来源在迁移过程中分别存在一些问题:

  • 本地Obsidian:图片格式一般是![[]],不同于一般的markdown图片格式。而且图片都在本地,需要手动上传到OSS。
  • Hexo:文中用了大量地外挂标签,无法简单的搬运,需要手工修改。
  • 掘金/CSDN:图片都在它们的服务器上,无法在Halo中显示,估计是加了限制。

从人工到自动化

思路

从手工到自动化

对于Obsidian,以前我都是手工搬运的,就是将写好的笔记复制到CSDN/掘金,然后人工一个个替换图片。

这件事情也很繁琐和搬砖,因此我打算写一个脚本自动处理。具体来说,我的思路如下:

  • 先读取markdown文件内容
  • 通过正则匹配图片链接
  • 自动将图片上传到对象存储服务
  • 将返回的CDN链接自动替换原有的本地文件链接

对于Hexo,“外挂标签”用法较多,无法简单通过正则匹配来修改,而且麻烦的就是front matter。由于Halo这边没有front matter的功能,最好的做法是将front matter解析出来,然后发HTTP请求的形式创建文章,由于这个比较麻烦,我暂时还没开发。

对于CSDN/掘金的文章,我们的核心需求就是自动化地将上传到juejin/CSDN的图片下载下来,然后替换为我们自己CDN的图片。这里我的思路比较简单,就是正则匹配链接后,发HTTP请求下载文件,再传到对象存储和替换链接。

代码

下面的Python代码可以实现上述需求,阅读下面代码读者需要的前置知识有:

  • 了解OSS的基本概念
  • 有七牛云SDK的使用经历
  • 知道什么是HTTP请求
  • 了解正则的概念

我对代码的封装性做了优化,想要直接使用的话只需要修改前面配置部分的内容就可以啦。目前支持将obsidian文章中的图片自动传到OSS并替换链接,同时图片格式为普通markdown格式;还支持从CSDN/juejin上下载图片并传到OSS。

PS:要想替换Obsidian的链接,起码你的图片要有规律。推荐在设置中固定将资源放到pwd/assets,找起来和用起来也方便。如果你的文件名不是assets,可以在下方代码的配置中替换。

使用方法:

# 使用示例,type可选obsidian 或 internet,name是本地markdown的路径
python main.py --type obsidian --name 你的markdown文件名
import argparse
import json
import re
import os
import time
import requests
from qiniu import Auth, put_data

# 七牛云配置
access_key = '' # ak
secret_key = '' # sk
bucket_name = ''  # 桶名字
oss_domain = "" # CDN域名
oss_folder_prefix = "test" # OSS中的文件夹前缀
# 配置
obsidian_img_prefix = "./assets" # obsidian中的资源路径,建议和我一样统一将文件存放到 ./assets/
new_file_predix = "new-" # 生成的新文件前缀,默认文件名是这个前缀+时间戳

# 常量
img_pattern_dict = { # 不同类型的正则表达式,都用于查找图片链接
    "obsidian": r'!\[\[(.*?)(?:\|.*?)?\]\]',
    "internet": r'!\[.*?\]\((.*?)\)'
}
task_type="" # 处理方式:默认是Obsidian处理方式

def upload_to_qiniu(file_data, file_name):
    """
    上传文件到七牛云存储。

    参数:
    file_data: 要上传的文件数据。
    file_name: 上传到七牛云后的文件名。

    返回值:
    成功上传后返回文件的访问URL,如果上传失败则抛出异常。
    """
    # 构建鉴权对象
    q = Auth(access_key, secret_key)
    # 生成上传 Token
    token = q.upload_token(bucket_name, file_name, 3600)
    # 上传文件
    _, info = put_data(token, file_name, file_data)
    if info.status_code == 200:
        return f"{oss_domain}/{file_name}"
    else:
        raise Exception(f"Upload failed: {info}")

def process_markdown_file(md_path):
    """
    处理Markdown文件中的图片链接,将其上传到七牛云,并将原链接替换为七牛云上的链接。
    
    :param md_path: Markdown文件的路径
    """
    
    # 读取Markdown文件内容
    with open(md_path, 'r', encoding='utf-8') as file:
        content = file.read()

    # 根据类型不同,选择不同的正则表达式,用于匹配所有的图片链接
    img_pattern = img_pattern_dict[task_type]
    img_paths = re.findall(img_pattern, content)
    print(img_paths)
    if not img_paths and task_type == "obsidian":
        print("No images found in the markdown file.")
        return
    
    new_content = content
    
    # 处理每个图片路径
    for idx,img_path in enumerate(img_paths):
        if(task_type == "obsidian"):
            # ob中的文件名
            img_path_with_assets = os.path.join(obsidian_img_prefix, img_path)
            # 资源的相对路径
            full_img_path = os.path.join(os.path.dirname(md_path), img_path_with_assets)
            if not os.path.exists(full_img_path):
                print(f"Image file not found: {full_img_path}")
                continue
            
            # 读取图片文件到内存
            with open(full_img_path, 'rb') as img_file:
                img_data = img_file.read()
            
            # 上传到七牛云
            file_name = f"{oss_folder_prefix}/{int(time.time())}_{idx}"
            qiniu_url = upload_to_qiniu(img_data, file_name)
            print(f"[obsidian-upload-success-{idx}]: 上传成功,原始图片为{img_path},原始文件名为{full_img_path},新图片地址是{qiniu_url}")

            # 替换Markdown内容中的图片链接, obsidian的 ![[]] 格式,需要换成![]()
            new_content = re.sub(
                rf'!\[\[{re.escape(img_path)}(?:\|.*?)?\]\]',
                f'![]({qiniu_url})',
                new_content
            )
        elif(task_type == "internet"):
            # 将图片地址下载到内存并上传到七牛云
            response = requests.get(img_path)
            img_data = response.content

            # 上传到七牛云
            file_name = f"{oss_folder_prefix}/{int(time.time())}_{idx}"
            qiniu_url = upload_to_qiniu(img_data, file_name)
            print(f"[internet-upload-success-{idx}]: 上传成功,原始图片地址为{img_path},新图片地址是{qiniu_url}")

            # 替换markdown
            new_content = new_content.replace(img_path, qiniu_url)

    
    # 将新的内容写到新的Markdown文件
    new_md_path = os.path.join(os.path.dirname(md_path), new_file_predix + os.path.basename(md_path))
    with open(new_md_path, 'w', encoding='utf-8') as file:
        file.write(new_content)


def main():
    # 读取参数
    parser = argparse.ArgumentParser(description="Process Markdown files and upload images to Qiniu Cloud.")
    parser.add_argument('--name', nargs='+', required=True, help="List of Markdown files to process.")
    parser.add_argument('--type',required=False,default="obsidian", help="Process Obsidian or Internet Markdown")
    args = parser.parse_args()

    # 修改全局变量
    global task_type
    task_type = args.type

    # 对于每个文件,替换markdown中的本地图片上传到云端,并将图片地址替换为云端地址    
    for md_file in args.name:
        if os.path.exists(md_file):
            process_markdown_file(md_file)
            print(f"==>恭喜您!文件名:{md_file},所有图片处理完成!")
        else:
            print(f"File not found: {md_file}")
    

if __name__ == "__main__":
    main()

总结与后续

这个例子体现了自动化的好处,在实际学习和工作中,自动化能帮助我们很好地从重复性劳动中解放出来,去做更加有创造性价值的事情。

后续这个项目还可以继续完善,比如打造成一款通用的markdown博客转换器,在不同平台之间丝滑切换;又比如说加个UI界面等等。

但这都是后话了,通过挖掘工作中的需求并用自动化的方式解决它,再次过程中不断优化解决方案并沉淀为经验,从而把自己和他人从重复性劳动中解放出来,这是这篇博客想要分享的中心。