Typecho 评论迁移至 cusdis 的记录

技术

最近 把博客从 Typecho 搬到了 Next.js + Vercel 驱动、Notion 为 CMS 的架构(使用 nobelium 搭建),其中评论区转到了 cusdis 作为后端。原博客有文章 112 篇,评论总量大约 1.2k 条,文章迁移手动操作还行,评论数据的迁移经历了一些小坎坷,断断续续花了两个周末才弄完,也写一写踩过的坑,还有一些小小的心得。

整体思路

目前 cusdis 只支持 Disqus 的数据导入,Disqus 支持导入 WordPress 格式的 WXR 格式,并能导出自己的 XML 格式。想到 Typecho 和 WordPress 的各种功能、数据都非常相似,搜索了一下,发现 Typecho 社区恰好有人做了个支持 WXR 导出的插件 panxianhai/TypExport。由此我也采用 Typecho → Disqus → Cusdis 的思路,从可能省时省力的视角出发,实在不行再手动倒腾。

格式转换

TypExport 导出的 XML 手动编辑好,再导入 Disqus 基本没啥问题。观察 Disqus 导出数据的结构,发现一些需要手工处理的地方。

首先是文章 ID 的缺失,cusdis 依赖一个 id 字段,这里是个空值,我需要把它的值改为 nobelium 在集成 cusdis 时,传入 Notion 页面的 Block ID

<thread dsq:id="9274175710">
  <id />
  <forum>zgq-ink</forum>
  <category dsq:id="9623532" />
  <link>https://zgq.ink/posts/2021-summary</link>
  <title>2021</title>
  <message />
  <createdAt>2022-01-02T10:55:00Z</createdAt>
  <author>
    <name>zgq354</name>
    <isAnonymous>false</isAnonymous>
    <username>zgq354</username>
  </author>
  <isClosed>false</isClosed>
  <isDeleted>false</isDeleted>
</thread>

另外还遇到两点小问题(解决完上述 ID 问题才发现):

  1. 中文的作者名会经过 HTML Entity 编码转义两次,且名字过长时会被截断,需重新补充。
  2. 垃圾评论未做过滤,会随之一起导入。
<post dsq:id="5926519750">
  <id>wp_id=21</id>
  <message>
    <![CDATA[<p>我发现好多人用Typecho   渣渣用WordPress交换友链</p>]]>
  </message>
  <createdAt>2014-07-28T05:22:43Z</createdAt>
  <isDeleted>false</isDeleted>
  <isSpam>false</isSpam>
  <author>
    <name>&amp;#28023;&amp;#32982;&amp;#23376;</name>
    <isAnonymous>true</isAnonymous>
  </author>
  <thread dsq:id="9274175053" />
</post>

以上问题,理论上可以把 XML 解析为树状结构的节点,遍历、过滤的手段解决。我也想当然地,直接在 node 下用一个 xml2json 的库,把它解析为 JSON 并 parse 为 JS Object,再用类库自带的 toXML() 方法,把数据还原回 XML,供 cusdis 手动导入。

const parser = require('xml2json');
const fs = require('fs');

const xmlData = fs.readFileSync('./zgq-ink-data.xml', 'utf-8');
const parsedObj = JSON.parse(parser.toJson(xmlData));

const idMap = {
  "https://zgq.ink/about": "07992fb7-482b-4057-a0fc-7f371317d4fc",
  "https://zgq.ink/xxxxxxxxyyyyy": "86d5d78d-3083-429a-a1cd-824bbeac93cb",
	// ...
};

parsedObj.disqus.thread.forEach((item, idx) => {
  parsedObj.disqus.thread[idx] = {
    ...item,
    id: idMap[item.link],
  };
});

fs.writeFileSync('result.xml', parser.toXml(parsedObj));

实践证明我这一想法确实 too naive,生成的数据导入时直接报错,初步排查发现和 <![CDATA[]]> 有关。xml2json 把 XML 转换为 JSON 的时候,会自动抛掉 CDATA 相关的转义字符,再还原回来时,CDATA 内部的 HTML 文本和外部的 XML 混在一起,导致 cusdis 解析文本时发生了错误。

简而言之,以 JSON Object 的视角去处理 XML 数据,这个转换是不可逆的,XML 表达的东西要比单纯 JSON Object 要丰富许多,没法直接一一映射,想要用 JS 处理,需要的是在其语言环境中模拟出 XML 的数据模型。基于我过去写过一篇 HTML Parser 相关的使用经验,想到,也许可以用 DOM 操作的思路去解决?理论上社区应该会有不少 XML-DOM 相关的类库。

再想想 npm 鱼龙混杂的生态,我不过是写一些一次性代码,三更已至,周末也快结束,人生苦短,不想纠结,把目光转向了 Python 社区。意外发现它语言本身就内置了 XML 的支持,有一个名为 xml.dom.minidom 的内置 XML-DOM 库,在 Py 的视角上针对 XML 实现了 DOM Level 1 接口。

那好办!直接一把梭哈搞起,经过一通捣鼓,基于 Disqus 和 TypExport 导出的 XML 为原料,写了个小脚本:插入缺失的 ID;有问题的作者名位于 post -> author -> name ,直接用 WXR XML 的原始内容节点替换,最后再把编辑后的 XML DOM 序列化到文本,保存至文件系统,结束。

from xml.dom.expatbuilder import CDATA_SECTION_NODE
import xml.dom.minidom as md

idDict = {
  "https://zgq.ink/about": "07992fb7-482b-4057-a0fc-7f371317d4fc",
  "https://zgq.ink/undefined": "86d5d78d-3083-429a-a1cd-824bbeac93cb",
  "https://zgq.ink/posts/blog-migration": "62278c48-3f91-4b7e-816f-c1311606d679",
  # ...
}

wpXML = md.parse("wordpress.2022-07-23.xml")
commentList = wpXML.getElementsByTagName("wp:comment")
wpPostAuthorDict = {}

for comment in commentList:
  commentId = comment.getElementsByTagName("wp:comment_id")[0].firstChild.nodeValue
  nameNode = list(filter(lambda node: node.nodeType == CDATA_SECTION_NODE, comment.getElementsByTagName("wp:comment_author")[0].childNodes))[0]
  wpPostAuthorDict[commentId] = nameNode

xmlDom = md.parse("disqus-data.xml")
rootNode = xmlDom.firstChild

# threads
threads = rootNode.getElementsByTagName('thread')
r = list(filter(lambda node: node.parentNode == rootNode, threads))
# print(len(threads), len(r))
for thread in r:
  # print(thread.getAttribute('dsq:id'))
  url = thread.getElementsByTagName('link')[0].firstChild.nodeValue
  if (url in idDict):
    id = idDict[url]
    # print(id)
    thread.getElementsByTagName('id')[0].firstChild.nodeValue = id

# posts
posts = rootNode.getElementsByTagName('post')
for post in posts:
  wpId = post.getElementsByTagName('id')[0].firstChild.nodeValue[6:]
  authorNode = post.getElementsByTagName('name')[0]
  authorNode.replaceChild(wpPostAuthorDict[wpId], authorNode.firstChild)
  if (post.getElementsByTagName('isDeleted')[0].firstChild.nodeValue == 'true'):
    post.parentNode.removeChild(post)

with open('result.xml', 'w') as f:
  f.write(xmlDom.toxml())

观察代码可以发现,其实 Py 实现和 JS 差不太多,都是同一套 Object Model,缺点是 DOM 1.0 接口没有定义类浏览器 DOM 的 querySelector 等方法,找元素需要自己遍历和过滤,写着有点小绕,不过基本没太大障碍,稍微耐心些即可。

后来在 npm 发现一个类库 xmldom/xmldom,想必应该是 JS 处理 XML 的更合适解决方案,类似 inikulin/parse5 这样的 HTML 解析器,在语法树的层面去操作,可以少很多不必要的烦恼。

本地部署 cusdis

历经坎坷,终于搞出了一个格式合法的 XML,但上传到官方 cusdis.com 后台,尝试几次都是卡很久然后提示超时,看 文档 说官方服务跑在 Vercel 和 PostgresSQL 数据库上,想来 Vercel 的云函数服务无内置 state,依赖外部服务去做 state 持久化,一下导入量大的情况下,确实可能会带来较长的耗时。

决定手动部署一套~~(无奈还是自己动手了,还好不算折腾)~~,参考 cusdis 的 Manually Install 指南,跑了个 Docker 镜像,并选择了 SQLite 作为评论的存储,配好反向代理、域名、证书等细节。

部署好后即导入数据,不负所望,self-hosted 的 cusdis 导入 XML 速度确实贼快,提交片刻就处理好了。

导入后还需修复下数据,停服、copy SQLite 数据文件到本地,然后通过 DB Browser for SQLite 一通捣鼓,补齐了一些关键的字段,其中一些涉及批量 UPDATE 相关的操作,直接用 Python 生成 SQL 语句,再粘贴运行修改数据库,最后再以新的数据库文件替换掉线上的。

print('UPDATE comments SET by_email="%s" WHERE id="%s";' % (email, dsqId))

总结

  • 收获最大的还是 XML 数据的处理细节,当我们需要考虑可逆的情况,重心需要关注一颗完整的 DOM 树,而非停留在数据的流转、状态管理之类,JSON 在 XML 的视角上看,大概算是一个子集
  • SQLite 非常适合个人本地服务的后端,写入的频率相对比较小,查询的速度和稳定性都非常 nice,写博文时恰好看到这篇: Alpine, Tailwind, Deno, SQLite 我的本地服务四件套
  • 对 cusdis 也抱着一些期望,期待有更多人为其贡献,想到几个优化的点:
    • 支持来访者输入个人网站的地址,类似传统 WordPress 评论区的 site 字段
    • 限制回复嵌套的层数,明确开新楼层和在同楼层的区别
    • Dashboard 增加文章列表等页面,更完善的管理功能
    • 支持自有的数据导入导出的格式,避免依赖 disqus,手动修复数据、搓 SQLite 数据库等各种繁琐
    • 提供 PingBack 的集成支持