又开始折腾了。

最近开始用obsidian作为主力笔记软件,自然也想用它写博客,但obsidian没有MWeb一样强大的发布功能,于是自己动手,基于Python和MetaWeblog API写了一段脚本,实现命令行发布文章到typecho博客。

这段时间博客中经常出现测试文章,就是在调试脚本。本文是obsidian写完、该脚本发布的。

一、脚本实现功能

  1. 发布文件,若已发布过,自动更新。
  2. 自动将obsidian的[[]]转化为markdown标准格式,包括链接和图片。
  3. 上传图片到又拍云,并替换文章中的图片链接。
  4. 支持常用的Front Matter信息,与正文之间用+++分隔,meta支持:

    1. title: 标题,必填
    2. date: 日期,必填,yyyymmddhhmm格式,比如202008231859
    3. slug: 自定义URL,可选,若不填以date中的一段作为slug
    4. categories: 分类,可选,英文逗号分隔
    5. tags: 标签,可选,英文逗号分隔

二、文章格式

如下图,+++分隔Front Matter和正文。

三、脚本使用

脚本在文末,基于Python3.8开发,依赖第三方库upyun(又拍云的SDK)。准备好环境后,正确填写脚本中的个人信息配置,保存为typecho.py,按如下格式使用:

typecho.py post_file.md post

第一个参数是要发布的markdown文件,必须。
第二个参数是可选的,表示发布文章的类型,post是文章,page是页面,不填默认是post。

更新成功返回如下内容:

更新成功,信息如下:
 {'title': '使用Python和MetaWeblog API发博客', 'date': '202008231735', 'categories': ['科技'], 'tags': 'Python,MetaWeblog API,Typecho', 'slug': '20082317'}

发布成功信息类似。

四、开发过程

非专业人士,代码不保证健壮性,勉强能用。过程中有些想法:

1、要实现更新文章,需要在本地保存发布状态,MetaWeblog API只能使用cid对文章进行更新,所以需要在本地保存cid,最初我考虑将cid回写到本地文件的Front Matter区域,但最后放弃了。因为回写是高风险的操作,可能破坏本地文件。所以,最后也选择了使用本地文件.slug_cid_mapping.txt保存发布状态。若是MetaWeblog API支持使用slug更新文章,就完美了。

2、图片的上传状态没有记录,所以原则上每次更新都会重新上传图片到又拍云,但发现又拍云上并没有重复,可能又拍云有相关策略控制。

3、Front Matter是个非常好的理念,可扩展性强,但我觉得每次写Front Matter还是有些麻烦。博文最重要的信息包括标题、发布日期、类目和slug,类目可以取本地文件夹名、标题和发布日期可以写在文件名上、slug可以直接用发布日期。这样就不需要Front Matter了。

4、[[]]的处理,没有过滤代码块,如果代码块中被正则匹配到,代码块也会被修改。这个问题暂时不会解,好在我的文章代码不多。

5、MetaWeblog API有个坑,struct中,categories是字典,mt_keywords(即tags)却是逗号分隔的字符串。

五、脚本分享

#!/usr/bin/python3
# -*- coding: UTF-8 -*-

import xmlrpc.client
import datetime
import time
import re
import upyun
import sys

# 个人信息配置
BLOG_USERNAME = '' # 博客用户名
BLOG_PASSWORD = '' # 博客密码
UP_SERVICE = '' #又拍云服务名
UP_USERNAME = '' # 又拍云操作员名称
UP_PASSWORD = '' #又拍云操作员密码
UP_PATH = '/blog_static/' # 又拍云上传图片的目录
PICTURE_PATH = '/User/obsidian_note/media/' # 本地保证图片的目录
METAWEBLOG_API = '' # 博客metaweblog api地址
SLUG_CID_MAPPING = '/User/obsidian_note/blog/.slug_cid_mapping.txt' # 保存slug与cid的mapping文件,默认隐藏


# 解析markdown文件
#     入参:markdown文件路径
#     返回:data字典,data['meta']为front_meta字典,包含title/date/category/tags(列表),data['content']为文章正文[[]]未经特殊处理
def file_read(file):
    with open(file, 'r') as f:
        file_content = f.read().strip()
    array = file_content.split('+++\n')
    if len(array) != 2:
        print('格式错误\n\n\n')
    else:
        front_meta_txt = array[0].strip().split('\n')
        front_meta_dict = {}
        for kv in front_meta_txt:
            key = kv.split(':')[0].strip()
            value = kv.split(':', 1)[1].strip()
            front_meta_dict[key] = value
        if 'title' not in front_meta_dict or front_meta_dict['title'] == '': 
            print('必须有title且不能为空')
        if 'date' not in front_meta_dict or front_meta_dict['date'] == '': 
            print('必须有date且不能为空')
        if 'categories' not in front_meta_dict:
            front_meta_dict['categories'] = []
        else:
            front_meta_dict['categories'] = list_strip(front_meta_dict['categories'].split(','))
        if 'tags' not in front_meta_dict:
            front_meta_dict['tags'] = ''
        else:
            front_meta_dict['tags'] = ','.join(list_strip(front_meta_dict['tags'].split(',')))
        if 'slug' not in front_meta_dict or front_meta_dict['slug'] == '':
            front_meta_dict['slug'] = front_meta_dict['date'][2:-2]
        data = {'meta': front_meta_dict, 'content': array[1].strip()}
        return data

# 格式化字符串list,去掉空值,去掉字符串两端的空字符
def list_strip(lst):
    result = []
    for v in lst:
        if v.strip() != '': result = result + [v]
    return result

# URL替换函数:把[[]]替换为[]()标准markdown链接
def url_repl(matchobj):
    meta = matchobj.group(0)[2:-2].split('|', 1)
    if len(meta) == 2:
        return '[{txt}]({url})'.format(txt=meta[1], url='/' + meta[0][-10:-2] + '.html')
    else:
        return '[{txt}]({url})'.format(txt=meta[0], url='/' + meta[0][-10:-2] + '.html')

# IMG替换函数:上传图片到又拍云,并把![[]]替换为![]()标准markdown图片
def img_repl(matchobj):
    meta = matchobj.group(0)[3:-2].split('|', 1)
    file_path = PICTURE_PATH + meta[0]
    up_path = '/blog_static/' + meta[0]
    upload_picture_to_upyun(file_path, up_path)
    if len(meta) == 2:
        return '![{txt}]({url})'.format(txt=meta[1], url=up_path)
    else:
        return '![{txt}]({url})'.format(txt='', url=up_path)

# 将[]]和![[](/]]和![[.html)转化为标准的[]()和![]()
def to_stard_markdown(content):
    content = re.sub('[^!]\[\[(.+?)\]\]', url_repl, content)
    content = re.sub('!\[\[(.+?[png|PNG|jpg|JPG|jpeg|JPEG|gif|GIF].*?)\]\]', img_repl, content)
    return content

# 上传图片到又拍云
# 入参:file_path本地文件路径,up_path定义又拍云路径
def upload_picture_to_upyun(file_path, up_path):
    up = upyun.UpYun(UP_SERVICE, UP_USERNAME, UP_PASSWORD)
    with open(file_path, 'rb') as f:
        res = up.put(up_path, f)
        
        
# 根据slug获取本地的cid
def get_cid(slug):
    with open(SLUG_CID_MAPPING, 'r') as f:
        kvs = list_strip(f.read().split('\n'))
    if kvs == []:
        return '0'
    else:
        mapping = {}
        for kv in kvs:
            mapping[kv.split(':')[0].strip()] = kv.split(':')[1].strip()
        if slug not in mapping:
            return '0'
        else:
            return mapping[slug]

# 保存slug和cid的关系
def save_cid(slug, cid):
    if int(cid) > 0:
        with open(SLUG_CID_MAPPING, 'a') as f:
            f.write(slug + ':' + cid + '\n')
    else:
        return 'cid不对,保存失败'
            
# 创建文章,若文件已经存在,则自动更新,
#     入参:file要发布的文件,post_type发布类型(post-文章,page:页面)
def new_post(file, post_type='post'):
    data = file_read(file)
    slug = data['meta']['slug'] # 基于date获取slug
    # 构建发布内容
    struct = {
        'title': data['meta']['title'],
        'wp_slug': slug, 
        'dateCreated': datetime.datetime.strptime(data['meta']['date'],'%Y%m%d%H%M')-datetime.timedelta(hours=8),
        'description': to_stard_markdown(data['content']),
        'categories': data['meta']['categories'],
        'mt_keywords': data['meta']['tags'],
        'post_type': post_type,
    }
    client = xmlrpc.client.ServerProxy(METAWEBLOG_API)
    
    cid = get_cid(slug)

    if int(cid) > 0:
        try:
            result = client.metaWeblog.editPost(cid, BLOG_USERNAME, BLOG_PASSWORD, struct, True)
            print('更新成功,信息如下:\n', data['meta'], '\n\n')
        except Exception as e:
            print(e)
    else:
        cid = client.metaWeblog.newPost('',BLOG_USERNAME, BLOG_PASSWORD, struct, True)
        if cid != 0:
            save_cid(str(slug), str(cid))
        print('发布成功,信息如下::\n', data['meta'], '\n\n')


if __name__ == '__main__':
    try:
        if len(sys.argv) == 3:
            file_path, post_type = sys.argv[1], sys.argv[2]
            new_post(file_path, post_type)
        elif len(sys.argv) == 2:
            file_path = sys.argv[1]
            new_post(file_path)
    except Exception as e:
        print(sys.argv)
        print(e)
🔔 Email 或 RSS 订阅本博客

已有 4 条评论

  1. yeschan yeschan

    好厉害,我回去研究看看

    1. 这是曲线救国,如果能直接开发一个obsidian插件就更好了,可惜我不会

  2. 我也试了一下,感觉如果这货出了手机版,那么市面上一个能打的都没有,包括我做群主的笔记软件

    1. 哈哈,你好快啊,我还在用这篇文章调试代码呢,终于搞定了。
      obsidian明显要做笔记界的vscode,非常期待v1.0版本api出来,到时应该能玩出各种花样来。

添加新评论