---
title: Typecho 评论迁移至 cusdis 的记录
date: 2022-08-14T20:00:00+08:00
tags:
  - 技术

---

[最近](https://zgq.me/posts/blog-migration/) 把博客从 [Typecho](https://typecho.org/) 搬到了 [Next.js](https://nextjs.org/) + [Vercel](https://vercel.com/) 驱动、Notion 为 CMS 的架构（使用 [nobelium](https://github.com/craigary/nobelium) 搭建），其中评论区转到了 [cusdis](https://cusdis.com/) 作为后端。原博客有文章 `112` 篇，评论总量大约 `1.2k` 条，文章迁移手动操作还行，评论数据的迁移经历了一些小坎坷，断断续续花了两个周末才弄完，也写一写踩过的坑，还有一些小小的心得。

### 整体思路

目前 cusdis 只支持 Disqus 的数据导入，Disqus 支持导入 WordPress 格式的 [WXR 格式](https://devtidbits.com/2011/03/16/the-wordpress-extended-rss-wxr-exportimport-xml-document-format-decoded-and-explained/)，并能导出自己的 XML 格式。想到 Typecho 和 WordPress 的各种功能、数据都非常相似，搜索了一下，发现 Typecho 社区恰好有人做了个支持 WXR 导出的插件 [panxianhai/TypExport](https://github.com/panxianhai/TypExport)。由此我也采用 Typecho → Disqus → Cusdis 的思路，从可能省时省力的视角出发，实在不行再手动倒腾。

### 格式转换

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

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

```xml
<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. 垃圾评论未做过滤，会随之一起导入。

```xml
<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 手动导入。

```jsx
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 相关的使用经验](https://juejin.cn/post/7002186401802371109)，想到，也许可以用 DOM 操作的思路去解决？理论上社区应该会有不少 `XML-DOM` 相关的类库。

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

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

```python
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](https://github.com/xmldom/xmldom)，想必应该是 JS 处理 XML 的更合适解决方案，类似 [inikulin/parse5](https://github.com/inikulin/parse5) 这样的 HTML 解析器，在语法树的层面去操作，可以少很多不必要的烦恼。

### 本地部署 cusdis

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

决定手动部署一套~~（无奈还是自己动手了，还好不算折腾）~~，参考 cusdis 的 [Manually Install](https://cusdis.com/doc#/self-host/manual) 指南，跑了个 Docker 镜像，并选择了 SQLite 作为评论的存储，配好反向代理、域名、证书等细节。

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

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

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

### 总结

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