给自己的博客打通 XML-RPC 接口这件事,我拖了很久。总觉得"手动在后台写也挺好",直到需要批量整理旧文、或者想从本地脚本直接把 Markdown 推上去时,才发现命令行这条路的爽点。

现在这条链路已经跑通了:本地写 Markdown → 一条命令 → 草稿箱。整个过程踩了两个不小的坑,都记在这里。

一、接口地址是固定

Typecho 的 XML-RPC 入口不需要装插件,核心自带,路径固定:

https://你的域名/action/xmlrpc

先用 system.listMethods 探一下底:

import xmlrpc.client
p = xmlrpc.client.ServerProxy("https://你的域名/action/xmlrpc")
print(p.system.listMethods())

会吐出一长串方法名。完整数下来是 58 个,覆盖四个系列:

系列代表方法用途
metaWeblognewPost / editPost / deletePost文章增删改
metaWeblognewMediaObject上传附件
mtgetCategoryList / publishPost分类、发布状态
wpgetCategories / getProfile / getTags站点信息
systemlistMethods / multicall自省与批量调用

也就是说,发布、编辑、删除、查分类、传图片,全都支持。这是个完整可用的 CMS API。

二、第一个坑:301 重定向把标准客户端干翻了

第一次用 Python 标准库连,直接报错:

ProtocolError: 301 Moved Permanently

诡异的是,用 curl 打同一个地址却返回 200。原因在这里:

  • 请求不带尾斜杠的 /action/xmlrpc 时,服务器会先返回 301 跳转;
  • curl 手动 POST 的那条路径恰好没触发跳转,看着像正常;
  • 而 Python 的 xmlrpc.client.Transport 默认不跟随重定向,301 直接被当成错误抛出。

解决办法是自己重写 Transport,用 urllib 的 opener 发请求——它会自动跟随跳转:

import urllib.request
import xmlrpc.client
import ssl as _ssl


class RedirectSafeTransport(xmlrpc.client.Transport):
    def __init__(self, *a, **kw):
        super().__init__(*a, **kw)
        # 基类 __init__ 不设置 verbose,但 parse_response() 依赖它,
        # 不补这一行会报 AttributeError
        self.verbose = False

    def request(self, host, handler, request_body, verbose=False):
        self.verbose = verbose
        url = "https://%s%s" % (host, handler)
        ctx = _ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = _ssl.CERT_NONE
        req = urllib.request.Request(
            url, data=request_body,
            headers={"Content-Type": "text/xml",
                     "User-Agent": "Typecho-Client/1.0"},
        )
        opener = urllib.request.build_opener(
            urllib.request.HTTPSHandler(context=ctx))
        with opener.open(req, timeout=30) as r:
            return self.parse_response(r)

注意 self.verbose = False 那一行。Transport.__init__ 并不设置 verbose,而 parse_response() 会读它——自定义 Transport 时漏掉这行,就会得到一个莫名其妙的 AttributeError。

三、第二个坑:metaWeblog.getPost 恒返回 500

读文章列表没问题:

posts = p.metaWeblog.getRecentPosts("1", "用户名", "密码", 10)

但想按 ID 读单篇时:

post = p.metaWeblog.getPost("1", "用户名", "密码")   # HTTP 500

连已发布的正常文章也照样 500,排查了半天确认是 Typecho 这个版本的实现缺陷,不是参数或权限问题。绕开方式有两个:

  • 用 getRecentPosts 拉一页回来自己筛(字段最全,推荐);
  • 或改用 blogger.getPost,注意它要 4 个参数:
post = p.blogger.getPost("", "1", "用户名", "密码")

四、发布一篇 Markdown 文章

正文直接传 Markdown 源码,Typecho 侧会按站点设置解析渲染:

post_id = p.metaWeblog.newPost("1", "用户名", "密码", {
    "title": "文章标题",
    "description": "# 正文\n\n直接用 Markdown 写就行。",
    "mt_keywords": "Typecho,XML-RPC,Python",
    "categories": ["默认分类"],
    "post_status": "draft",
}, False)   # 最后一个参数 False = 存草稿,True = 直接发布

建议默认走草稿:先 publish=False 推进去,在后台看一眼排版,确认无误再 mt.publishPost 发布。尤其是带代码块和表格的长文,草稿能省掉"发出去又赶紧撤"的尴尬。

小结

事项结论
接口/action/xmlrpc,核心自带
重定向必须自定义 Transport 跟随 301
读单篇避开 metaWeblog.getPost,用 getRecentPosts
格式description 传 Markdown 源码
发布先草稿后发布

打通之后,写作和发布终于解耦了:内容在本地用熟悉的编辑器写,版本可以用 Git 管,发布只是最后一条命令。对习惯在终端里干活的人来说,这一步的收益远比想象中大。

下一步打算把「写完 Markdown 自动推草稿」做成一个脚本,配合 front-matter 填标题和标签,那就真的只剩敲字了。