sublime里面开发博客的发布和删除工具
sublime里面开发博客的发布和删除工具
我们用sublime编写md文档是比较方便的,那编写完成之后,想要快速的发布到博客系统,目前还没有现成的工具。所以,就自己开发了一个easyblog
。先支持cnblogs
的发布和删除,后面可以扩展其他的博客系统,因为基本上的博客系统都差不多。主要内容就是:
- 账号管理系统
- 发布新博客或者是更新博客
- 删除博客
- 分类或者标签管理
程序的基本结构
目前先支持cnblogs
,后面可以把其他的博客系统接入进来,大体都差不多,提供基本的api,获取博客信息、新博客、更新博客、上传图片等。
基础通用类
用于初始化博客的用户名、密码和博客地址等信息,这个理论上,各个博客系统可以复用。默认将各个博客系统的账号密码保存在home目录,这样,密码不会对外暴露,暂未做加密处理。
class EasyblogObject(object):
settings_file = 'easyblog.sublime-settings'
def __init__(self, blog_name, blog_config_tmpl):
super()
print(blog_name, blog_config_tmpl)
assert blog_name is not None and isinstance(blog_name, str)
assert blog_config_tmpl is not None and isinstance(blog_config_tmpl, dict) and len(blog_config_tmpl) > 0
self.blog_name = blog_name
self.blog_config_tmpl = blog_config_tmpl
self.settings = sublime.load_settings(self.settings_file)
self.config_dir = os.path.expanduser(self.settings.get("config_dir", "~"))
self.config_file = os.path.join(self.config_dir, ".easyblog.json")
self.init_item_len = 0
self.init_item_index = 0
self.cur_key = ''
self.config = {}
self.blog_config = {}
def save_config(self, config):
config[self.blog_name] = self.blog_config
if not(os.path.exists(self.config_dir)):
os.makedirs(self.config_dir)
with open(self.config_file, mode='w') as f:
json.dump(config, f)
def get_config(self):
if not(os.path.exists(self.config_file)):
return {}
else:
with open(self.config_file, mode="r+") as f:
config = json.load(f)
if config is None or not(isinstance(config, dict)):
return {}
else:
return config
def init(self):
# if len(self.blog_config_tmpl) == 0:
# sublime.status_message("blog_config is not valid dict: " + str(self.blog_config_tmpl))
# return
self.config = self.get_config()
if self.config.get(self.blog_name) is not None:
if not(sublime.ok_cancel_dialog("config alread exists, do you want to override?")):
sublime.status_message("init blog config canceled!")
return
self.blog_config = self.blog_config_tmpl.copy()
self.init_item_len = len(self.blog_config)
self.init_item_index = 0
item = self.blog_config.popitem()
self.cur_key = item[0]
self.input_panel_view = sublime.active_window().show_input_panel(
self.cur_key, item[1], self.on_done, None, self.on_cancel)
def on_done(self, input_string):
print("done")
assert self.blog_config is not None
self.blog_config[self.cur_key] = input_string
self.init_item_index += 1
if self.init_item_index < self.init_item_len:
item = self.blog_config.popitem()
self.cur_key = item[0]
self.input_panel_view = sublime.active_window().show_input_panel(
self.cur_key, item[1], self.on_done, None, self.on_cancel)
else:
self.config[self.blog_name] = self.blog_config
self.save_config(self.config)
def on_cancel(self):
print("cancel")
sublime.status_message("init blog config canceled!")
def load_config(self):
self.config = self.get_config()
self.blog_config = self.config.get(self.blog_name)
if self.blog_config is None or len(self.blog_config) == 0:
sublime.error_message("Not init yet, please init first!")
self.init()
return False
else:
return True
class EasyblogCnblogsCommand(EasyblogObject):
def __init__(self, *args):
super().__init__(CNBLOGS, {
# 'user_unique_name': '', # 你的用户名,用于拼接文章 url
'url': 'https://rpc.cnblogs.com/metaweblog/', # 你的 MetaWeblog 访问地址
'username': '', # 你的登录用户名,可能跟上面的不一致
'password': '', # 你的登录密码
})
self.blog_posts = {} # title: blogid
self.blog_posts_file = os.path.join(self.config_dir, ".easyblog_posts/cnblogs.json")
def load_blog_posts(self):
if not(os.path.exists(self.blog_posts_file)):
sublime.status_message("Posts may not be synced!")
return False
else:
with open(self.blog_posts_file, 'r') as f:
self.blog_posts = json.load(f)
return True
def save_blog_posts(self):
if not(os.path.exists(os.path.dirname(self.blog_posts_file))):
os.makedirs(os.path.dirname(self.blog_posts_file))
with open(self.blog_posts_file, 'w') as f:
json.dump(self.blog_posts, f)
def get_cnblogs(self):
assert self.blog_config is not None
return Cnblogs(self.blog_config['url'], self.blog_config['username'], self.blog_config['password'])
并行上传图片,替换里面的图片链接
图片上传基本上可以通用,里面的上传功能通过callback的形式,用func进行传递,把上传的具体方法传入,不同的博客系统可以传入不同的方法:
def upload(md_path, func, write_flag=True):
md_path = os.path.expanduser(md_path)
change_flag = False
with open(md_path, 'r', encoding='utf-8') as f:
md = f.read()
images = find_md_img(md)
if images: # 有本地图片,异步上传
net_images = asyncio.run(upload_tasks(images, func))
# print(net_images)
image_mapping = dict(zip(images, net_images))
md = replace_md_img(md_path, image_mapping)
change_flag = True
else:
print('无需上传图片')
if change_flag and write_flag:
with open(md_path, 'w', encoding='utf-8') as f:
f.write(md)
print(f'图片链接替换完成,更新markdown')
# print(md)
return md
async def upload_tasks(images, func):
tasks = []
for img_path in images:
task = asyncio.create_task(upload_img(img_path, func))
task.add_done_callback(print_process)
tasks.append(task)
return await asyncio.gather(*tasks)
async def upload_img(path, func):
"""上传图片"""
if os.path.exists(path):
name = os.path.basename(path)
_, suffix = os.path.splitext(name)
with open(path, 'rb') as f:
file = {
"bits": f.read(),
"name": name,
"type": mime_mapping[suffix]
}
return func(file)
else:
return ''
def print_process(task):
"""回调,获取url"""
url = task.result()
if url:
print(f'图片上传成功,url:{url}')
else:
print('图片上传失败')
def find_md_img(md):
"""查找markdown中的图片,排除网络图片(不用上传)"""
uploaded_images = re.findall('<!-- !\\[[^\\]]*?\\]\\(([^\\)]*?)\\).*? -->', md)
uploaded_images += re.findall('<!-- <center><img src="([^"]*?)"[^>]*?/></center> -->', md)
print("uploaded_images", uploaded_images)
images = re.findall("!\\[[^\\]]*?\\]\\(([^\\)]*)\\)", md)
images += re.findall('<img src="([^"]*?)"', md)
images = list(set(images))
images = [img for img in images
if not re.match("((http(s?))|(ftp))://.*", img) and uploaded_images.count(img) == 0]
print(f'共找到{len(images)}张本地图片{images}')
return images
def replace_md_img(path, img_mapping):
"""替换markdown中的图片链接"""
with open(path, 'r', encoding='utf-8') as f:
md = f.read()
for local, net in img_mapping.items(): # 替换图片链接
images = list(set(re.findall("(!\\[[^\\]]*?\\]\\(%s\\)(\\{[^\\}]*?\\})?)" % local, md)))
# print("images", images)
for img in images:
if isinstance(img, tuple):
repl = '<!-- %s --><center><img src="%s" %s/></center>' % (img[0], net, img[1][1:-1])
md = md.replace(img[0], repl)
else:
repl = '<!-- %s --><center><img src="%s"/></center>' % (img, net)
md = md.replace(img, repl)
# print("repl", repl)
images = set(re.findall('<img src="%s"[^>]*?/>' % local, md))
# print("images2", images)
for img in images:
if len(img) > len(local) + 13:
params = img[len(local)+11:-2]
repl = '<!-- %s --><center><img src="%s" %s/></center>' % (img, net, params)
else:
repl = '<!-- %s --><center><img src="%s"/></center>' % (img, net)
# print(repl)
md = md.replace(img, repl)
return md
mime_mapping = {
".323": "text/h323",
".asx": "video/x-ms-asf",
".acx": "application/internet-property-stream",
".ai": "application/postscript",
".aif": "audio/x-aiff",
".aiff": "audio/aiff",
".axs": "application/olescript",
".aifc": "audio/aiff",
".asr": "video/x-ms-asf",
".avi": "video/x-msvideo",
".asf": "video/x-ms-asf",
".au": "audio/basic",
".application": "application/x-ms-application",
".bin": "application/octet-stream",
".bas": "text/plain",
".bcpio": "application/x-bcpio",
".bmp": "image/bmp",
".cdf": "application/x-cdf",
".cat": "application/vndms-pkiseccat",
".crt": "application/x-x509-ca-cert",
".c": "text/plain",
".css": "text/css",
".cer": "application/x-x509-ca-cert",
".crl": "application/pkix-crl",
".cmx": "image/x-cmx",
".csh": "application/x-csh",
".cod": "image/cis-cod",
".cpio": "application/x-cpio",
".clp": "application/x-msclip",
".crd": "application/x-mscardfile",
".deploy": "application/octet-stream",
".dll": "application/x-msdownload",
".dot": "application/msword",
".doc": "application/msword",
".dvi": "application/x-dvi",
".dir": "application/x-director",
".dxr": "application/x-director",
".der": "application/x-x509-ca-cert",
".dib": "image/bmp",
".dcr": "application/x-director",
".disco": "text/xml",
".exe": "application/octet-stream",
".etx": "text/x-setext",
".evy": "application/envoy",
".eml": "message/rfc822",
".eps": "application/postscript",
".flr": "x-world/x-vrml",
".fif": "application/fractals",
".gtar": "application/x-gtar",
".gif": "image/gif",
".gz": "application/x-gzip",
".hta": "application/hta",
".htc": "text/x-component",
".htt": "text/webviewhtml",
".h": "text/plain",
".hdf": "application/x-hdf",
".hlp": "application/winhlp",
".html": "text/html",
".htm": "text/html",
".hqx": "application/mac-binhex40",
".isp": "application/x-internet-signup",
".iii": "application/x-iphone",
".ief": "image/ief",
".ivf": "video/x-ivf",
".ins": "application/x-internet-signup",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jfif": "image/pjpeg",
".jpe": "image/jpeg",
".jpeg": "image/jpeg",
".js": "application/x-javascript",
".lsx": "video/x-la-asf",
".latex": "application/x-latex",
".lsf": "video/x-la-asf",
".manifest": "application/x-ms-manifest",
".mhtml": "message/rfc822",
".mny": "application/x-msmoney",
".mht": "message/rfc822",
".mid": "audio/mid",
".mpv2": "video/mpeg",
".man": "application/x-troff-man",
".mvb": "application/x-msmediaview",
".mpeg": "video/mpeg",
".m3u": "audio/x-mpegurl",
".mdb": "application/x-msaccess",
".mpp": "application/vnd.ms-project",
".m1v": "video/mpeg",
".mpa": "video/mpeg",
".me": "application/x-troff-me",
".m13": "application/x-msmediaview",
".movie": "video/x-sgi-movie",
".m14": "application/x-msmediaview",
".mpe": "video/mpeg",
".mp2": "video/mpeg",
".mov": "video/quicktime",
".mp3": "audio/mpeg",
".mpg": "video/mpeg",
".ms": "application/x-troff-ms",
".nc": "application/x-netcdf",
".nws": "message/rfc822",
".oda": "application/oda",
".ods": "application/oleobject",
".pmc": "application/x-perfmon",
".p7r": "application/x-pkcs7-certreqresp",
".p7b": "application/x-pkcs7-certificates",
".p7s": "application/pkcs7-signature",
".pmw": "application/x-perfmon",
".ps": "application/postscript",
".p7c": "application/pkcs7-mime",
".pbm": "image/x-portable-bitmap",
".ppm": "image/x-portable-pixmap",
".pub": "application/x-mspublisher",
".pnm": "image/x-portable-anymap",
".png": "image/png",
".pml": "application/x-perfmon",
".p10": "application/pkcs10",
".pfx": "application/x-pkcs12",
".p12": "application/x-pkcs12",
".pdf": "application/pdf",
".pps": "application/vnd.ms-powerpoint",
".p7m": "application/pkcs7-mime",
".pko": "application/vndms-pkipko",
".ppt": "application/vnd.ms-powerpoint",
".pmr": "application/x-perfmon",
".pma": "application/x-perfmon",
".pot": "application/vnd.ms-powerpoint",
".prf": "application/pics-rules",
".pgm": "image/x-portable-graymap",
".qt": "video/quicktime",
".ra": "audio/x-pn-realaudio",
".rgb": "image/x-rgb",
".ram": "audio/x-pn-realaudio",
".rmi": "audio/mid",
".ras": "image/x-cmu-raster",
".roff": "application/x-troff",
".rtf": "application/rtf",
".rtx": "text/richtext",
".sv4crc": "application/x-sv4crc",
".spc": "application/x-pkcs7-certificates",
".setreg": "application/set-registration-initiation",
".snd": "audio/basic",
".stl": "application/vndms-pkistl",
".setpay": "application/set-payment-initiation",
".stm": "text/html",
".shar": "application/x-shar",
".sh": "application/x-sh",
".sit": "application/x-stuffit",
".spl": "application/futuresplash",
".sct": "text/scriptlet",
".scd": "application/x-msschedule",
".sst": "application/vndms-pkicertstore",
".src": "application/x-wais-source",
".sv4cpio": "application/x-sv4cpio",
".tex": "application/x-tex",
".tgz": "application/x-compressed",
".t": "application/x-troff",
".tar": "application/x-tar",
".tr": "application/x-troff",
".tif": "image/tiff",
".txt": "text/plain",
".texinfo": "application/x-texinfo",
".trm": "application/x-msterminal",
".tiff": "image/tiff",
".tcl": "application/x-tcl",
".texi": "application/x-texinfo",
".tsv": "text/tab-separated-values",
".ustar": "application/x-ustar",
".uls": "text/iuls",
".vcf": "text/x-vcard",
".wps": "application/vnd.ms-works",
".wav": "audio/wav",
".wrz": "x-world/x-vrml",
".wri": "application/x-mswrite",
".wks": "application/vnd.ms-works",
".wmf": "application/x-msmetafile",
".wcm": "application/vnd.ms-works",
".wrl": "x-world/x-vrml",
".wdb": "application/vnd.ms-works",
".wsdl": "text/xml",
".xap": "application/x-silverlight-app",
".xml": "text/xml",
".xlm": "application/vnd.ms-excel",
".xaf": "x-world/x-vrml",
".xla": "application/vnd.ms-excel",
".xls": "application/vnd.ms-excel",
".xof": "x-world/x-vrml",
".xlt": "application/vnd.ms-excel",
".xlc": "application/vnd.ms-excel",
".xsl": "text/xml",
".xbm": "image/x-xbitmap",
".xlw": "application/vnd.ms-excel",
".xpm": "image/x-xpixmap",
".xwd": "image/x-xwindowdump",
".xsd": "text/xml",
".z": "application/x-compress",
".zip": "application/x-zip-compressed",
".*": "application/octet-stream",
}
cnblogs的方法
import xmlrpc.client
import ssl
import os
import sys
class Cnblogs:
def __init__(self, url, username, password):
self.url, self.username, self.password = url, username, password
self.proxy = xmlrpc.client.ServerProxy(self.url)
def deletePost(self, post_id):
'''
删除博文
'''
return self.proxy.blogger.deletePost('', post_id, self.username, self.password, True)
def getUsersBlogs(self):
'''
获取空间的标语
'''
return self.proxy.blogger.getUsersBlogs('', self.username, self.password)
def editPost(self, post_id, article):
'''
编辑已经上传的博文,编程成功返回True。如果是md格式,必须包含 'categories':['[Markdown]'],也可以包含其他的分类
'''
return self.proxy.metaWeblog.editPost(post_id, self.username, self.password,
dict(title=article['title'],
categories=article['categories'],
description=article['content'],
mt_keywords=article['tags']),
True)
def getCategories(self):
'''
获取分类
'''
usersBlogs = self.getUsersBlogs()
if usersBlogs is not None and isinstance(usersBlogs, list) and len(usersBlogs) > 0:
blog_id = usersBlogs[0]['blogid']
return self.proxy.metaWeblog.getCategories(blog_id, self.username, self.password)
def getPost(self, post_id):
'''
获取博文
'''
return self.proxy.metaWeblog.getPost(post_id, self.username, self.password)
def getRecentPosts(self, count):
'''
获取最近多少篇博文,最多99
'''
return self.proxy.metaWeblog.getRecentPosts('', self.username, self.password, count)
def newMediaObject(self, file_obj):
'''
发布新的博文,返回post_id字符串. 如果是md格式,必须包含 'categories':['[Markdown]'],也可以包含其他的分类
'''
return self.proxy.metaWeblog.newMediaObject('', self.username, self.password,
dict(bits=file_obj['bits'],
name=file_obj['name'],
type=file_obj['type']))
def newPost(self, article):
'''
发布新的博文,返回post_id字符串. 如果是md格式,必须包含 'categories':['[Markdown]'],也可以包含其他的分类
'''
return self.proxy.metaWeblog.newPost('', self.username, self.password,
dict(title=article['title'],
categories=article['categories'],
description=article['content'],
mt_keywords=article['tags']),
True)
def newCategory(self, category):
'''
创建新的分类,返回id,int类型
'''
usersBlogs = self.getUsersBlogs()
if usersBlogs is not None and isinstance(usersBlogs, list) and len(usersBlogs) > 0:
blog_id = usersBlogs[0]['blogid']
return self.proxy.wp.newCategory(blog_id, self.username, self.password, category)
cnblogs在sublime中的命令
easyblog_cnblogs_init
进行账户、密码、网址的设定easyblog_cnblogs_sync
进行最近99篇文章的同步,把文章title和postid的映射关系建立好,后面发布的时候可以判断是新增还是更新easyblog_cnblogs_post
进行文章的发布,会确保已经初始化和同步过文章,然后,判断是新增还是更新博客,其中图片会并行上传,替换后会把文章更新掉,后面改动,不会上传相同的图片,加快速度,对于要发布到多个博客系统的情况,要把链接替换文章的开关关掉,不然,有些博客系统对于引用其他博客的图片有限制easyblog_cnblogs_delete
进行发布文章的删除,这个功能有时可用,有时不可用,估计和博客系统有关系。
class EasyblogCnblogsCommand(EasyblogObject):
def __init__(self, *args):
super().__init__(CNBLOGS, {
# 'user_unique_name': '', # 你的用户名,用于拼接文章 url
'url': 'https://rpc.cnblogs.com/metaweblog/', # 你的 MetaWeblog 访问地址
'username': '', # 你的登录用户名,可能跟上面的不一致
'password': '', # 你的登录密码
})
self.blog_posts = {} # title: blogid
self.blog_posts_file = os.path.join(self.config_dir, ".easyblog_posts/cnblogs.json")
def load_blog_posts(self):
if not(os.path.exists(self.blog_posts_file)):
sublime.status_message("Posts may not be synced!")
return False
else:
with open(self.blog_posts_file, 'r') as f:
self.blog_posts = json.load(f)
return True
def save_blog_posts(self):
if not(os.path.exists(os.path.dirname(self.blog_posts_file))):
os.makedirs(os.path.dirname(self.blog_posts_file))
with open(self.blog_posts_file, 'w') as f:
json.dump(self.blog_posts, f)
def get_cnblogs(self):
assert self.blog_config is not None
return Cnblogs(self.blog_config['url'], self.blog_config['username'], self.blog_config['password'])
class EasyblogCnblogsInitCommand(EasyblogCnblogsCommand, sublime_plugin.WindowCommand):
def run(self):
self.init()
class EasyblogCnblogsSyncCommand(EasyblogCnblogsCommand, sublime_plugin.WindowCommand):
def run(self):
if not(self.load_config()):
return
self.blog_posts = {}
self.load_blog_posts()
cnblogs = self.get_cnblogs()
posts = cnblogs.getRecentPosts(99)
if posts is None or not(isinstance(posts, list)) or len(posts) == 0:
sublime.status_message("No posts to sync!")
else:
for post in posts:
# print(post)
self.blog_posts[post['title']] = post['postid']
self.save_blog_posts()
class EasyblogCnblogsPostCommand(EasyblogCnblogsCommand, sublime_plugin.TextCommand):
def __init__(self, view):
print(view)
super().__init__()
self.view = view
def run(self, _):
if not(self.load_config()):
return
if not(self.load_blog_posts()):
self.view.run_command("easyblog_cnblogs_sync")
if self.view is not None:
file_name = self.view.file_name()
print(file_name)
if file_name is not None and file_name.endswith('.md'):
self.view.run_command("save")
cnblogs = self.get_cnblogs()
title = os.path.splitext(os.path.basename(file_name))[0]
blogid = self.blog_posts.get(title)
content = upload(file_name, lambda f: self.upload_img(cnblogs, f), True)
article = {
'title': title,
'categories': ['[Markdown]'],
'tags': os.path.basename(os.path.dirname(file_name)),
'content': content
}
# print(blogid, article)
if blogid is None:
blogid = cnblogs.newPost(article)
self.blog_posts[title] = blogid
self.save_blog_posts()
sublime.status_message('blog has been posted!')
else:
cnblogs.editPost(blogid, article)
sublime.status_message('blog has been edit!')
else:
sublime.status_message("Only .md files support!")
else:
sublime.status_message("No view selected.")
def upload_img(self, cnblogs: Cnblogs, file_obj: dict):
result = cnblogs.newMediaObject(file_obj)
# result = {'url': file_obj['name']}
if result is not None and isinstance(result, dict):
return result['url']
else:
return ''
class EasyblogCnblogsDeleteCommand(EasyblogCnblogsCommand, sublime_plugin.TextCommand):
def __init__(self, view):
super().__init__()
self.view = view
def run(self, _):
if not(self.load_config()):
return
if not(self.load_blog_posts()):
self.view.run_command("easyblog_cnblogs_sync")
if self.view is not None:
file_name = self.view.file_name()
if file_name is not None and file_name.endswith('.md'):
self.view.run_command("save")
title = os.path.splitext(os.path.basename(file_name))[0]
blogid = self.blog_posts.get(title)
print(blogid)
if blogid is None:
sublime.status_message('Blog has not been posted yet!')
else:
cnblogs = self.get_cnblogs()
if cnblogs.deletePost(blogid):
del self.blog_posts[title]
self.save_blog_posts()
sublime.status_message('Blog has been deleted!')
else:
sublime.status_message('Blog delet failed!')
else:
sublime.status_message("Only .md files support!")
else:
sublime.status_message("No view selected.")