数据迁移之「从 Waline 到 Twikoo」
本文是对上一篇中提到的待解决问题给出的应对方案,可以点此阅读 LeanCloud停止对外服务&第一次博客迁移。
距离上一次发文已经有足足十二天了!这篇文章也大概拖了有一个多星期了,不过还是先不讲这么多废话了吧。
前言
上一篇文章其实是有提到我将博客评论系统从 Waline 迁移到 Twikoo 的,但是当时并没有迁移原来的评论数据,因为 Twikoo 的管理面板中并不支持直接从 Waline 导入,目前好像只支持从 Valine、Disqus、Artalk 和 Twikoo 本身导入;本来我还想着因为 Waline 是 Valine 的进化版,可不可以把 Waline 的评论数据通过 Valine 选项导入……但是非常不幸的是,失败了!看来这两者之间的数据格式还是与些许不兼容的。
——那么,正所谓“自己动手,丰衣足食”,既然前面的选项都无法直接导入,那就干脆自己从零开始制作一个程序把 Waline 格式的评论数据转换为 Twikoo 格式的评论数据然后通过 Twikoo 选项导入啦~(谁会想着自己一个一个手动迁移呢)
自己动手,丰衣足食
对比数据结构
迁移之前,当然要先充分了解 Waline 和 Twikoo 各自的数据结构特点,找出其中的相同和不同之处,才能更加系统地制作迁移程序。
总览全局
首先,放长眼光,总览全局,两者导出的数据都是 JSON 格式的文件:
不难发现 Waline 的数据结构大致是:
{ "data": { "Comment": [ {/*...*/}, {/*...*/}, {/*...*/}, ], /*...*/ },}而 Twikoo 的数据结构大致是:
[ {/*...*/}, {/*...*/}, {/*...*/},]对比非常明显,Twikoo 的数据文件应该是要比 Waline 小很多的,而且结构也更加清晰。在仔细观察后,就会发现 Twikoo 的数据结构整体的这个数组就对应了 Waline 数据结构中的 Comment 字段的数组,而且这个数组中的每一个值都是一个对象,代表着每一条评论数据。
深入探究
其次,分析两者评论数据数组中的具体内容,请看下面两组例子(这里就拿我的博客的一些评论数据举例):
-
第一组(非回复评论):
waline.json {"nick": "静凇","ip": "12.345.678.90","like": 1,"mail": "abcdefg@example.com","ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","insertedAt": "2023-12-27T13:29:58.613Z","status": "approved","link": "","comment": "谢谢,帮了大忙了","url": "/posts/hexo-redefine-theme-踩过的坑/","objectId": "68799fea6e25c660f962a2c7","createdAt": "2025-07-18T01:14:18.723Z","updatedAt": "2025-07-18T01:14:18.723Z"},twikoo.json {"_id": "f34674e295fa49539b8984cb99a59f94","nick": "静凇","mail": "abcdefg@example.com","link": "","ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","ip": "12.345.678.90","url": "/posts/hexo-redefine-theme-%E8%B8%A9%E8%BF%87%E7%9A%84%E5%9D%91/","comment": "<p>谢谢,帮了大忙了</p>","uid": "8bc585221ed5410c8d73b2d629bd24a7","mailMd5": "1a7ca9215b81484630893cb8d5f73e0f","master": false,"href": "https://blog.hxrch.top/posts/hexo-redefine-theme-%E8%B8%A9%E8%BF%87%E7%9A%84%E5%9D%91/","isSpam": false,"created": 1703683798613,"updated": 1752801258723,"top": false}, -
第二组(回复评论):
waline.json {"nick": "Horean0574","ip": "98.765.432.10","mail": "uvwxyz@example.com","ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","insertedAt": "2025-07-18T01:28:52.287Z","pid": "68799fea6e25c660f962a2c7","status": "approved","link": "https://www.hxrch.top","comment": "感谢您的认可<img class=\"wl-emoji\" src=\"//unpkg.com/@waline/emojis@1.2.0/bmoji/bmoji_unavailble_doge.png\" alt=\"bmoji_unavailble_doge\">","url": "/posts/hexo-redefine-theme-踩过的坑/","user_id": "64eabd5c7eac3a7867657e83","rid": "68799fea6e25c660f962a2c7","objectId": "6879a35413c0fe150872ea9e","createdAt": "2025-07-18T01:28:52.561Z","updatedAt": "2025-07-18T01:28:52.561Z"},twikoo.json {"_id": "6d26831b6dcf40f082d2f66d1c7e5c51","nick": "Horean","mail": "uvwxyz@example.com","link": "https://www.hxrch.top","ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36","ip": "98.765.432.10","url": "/posts/hexo-redefine-theme-%E8%B8%A9%E8%BF%87%E7%9A%84%E5%9D%91/","comment": "<p>感谢您的认可<img class=\"wl-emoji\" src=\"//unpkg.com/@waline/emojis@1.2.0/bmoji/bmoji_unavailble_doge.png\" alt=\"bmoji_unavailble_doge\"></p>","pid": "f34674e295fa49539b8984cb99a59f94","rid": "f34674e295fa49539b8984cb99a59f94","uid": "51fa62a9deed478544da9e60663434d8","mailMd5": "65b03c3c3cdd117c392cf74f5e588083","master": true,"href": "https://blog.hxrch.top/posts/hexo-redefine-theme-%E8%B8%A9%E8%BF%87%E7%9A%84%E5%9D%91/","isSpam": false,"created": 1752802132287,"updated": 1752802132561,"top": false},
不知道大家有没有发现些什么,Waline 与 Twikoo 这两种评论系统的数据结构非常相似呢,例如 nick, mail, comment 等等;当然也有一些不一样的地方,比如 Waline 的 objectId 和 Twikoo 的 _id、 Waline 的 status 和 Twikoo 的 isSpam。
所以这里就需要我们逐个分析,一一对比,便可得到如下表格数据对应表格:
| 字段含义 | Waline 字段名称 | Twikoo 字段名称 |
|---|---|---|
| 评论者昵称 | nick | |
| 评论者邮箱 | ||
| 评论者用户代理 | ua | |
| 评论者IP地址 | ip | |
| 评论内容 | comment | |
| 评论者网站 | link | |
| 评论地址资源路径 | url | |
| 回复的父评论ID | pid(null | 24位MongoDB ObjectId / UUID4) | |
| 回复的根评论ID | rid(null | 24位MongoDB ObjectId / UUID4) | |
| 点赞人数/列表 | like(number / string[]) | |
| 评论ID | objectId(24位MongoDB ObjectId) | _id(UUID4) |
| Twikoo中的用户ID | - | uid(UUID4) |
| Waline中的用户ID | user_id(24位MongoDB ObjectId) | - |
| 评论者邮箱的MD5密文 | - | mailMd5 |
| 是否博主评论 | - | master(boolean) |
| 评论完整地址 | - | href |
| 评论状态 | status("approved" | "waiting" | "spam") | isSpam(boolean) |
| 评论插入/创建时间 | insertedAt(ISO 8601) | created(UNIX 时间戳) |
| Waline中评论创建时间 | createdAt(ISO 8601) | - |
| 评论更新时间 | updatedAt(ISO 8601) | updated(UNIX 时间戳) |
| 是否置顶 | sticky(number) | top(boolean) |
非常重要的一点,这里需要注意相同字段在两种不同的评论系统下数据类型可能不同。
程序构思
为了更好地编写迁移程序,我们需要在这之前梳理程序思路,思考这些数据的具体处理方式。
数据处理
-
完全照搬:
nick,mail,ua,ip,comment,link,url. -
[_id]与<objectId>一一对应,直接生成新的 UUID4 作为[_id]. -
当
<status>为 “waiting” 或 “spam” 时,[isSpam]为 true. -
当
<status>为 “approved” 时,[isSpam]为 false. -
[created]与<insertedAt>一一对应(需将 ISO 8601 时间转换为 UNIX 时间戳).为什么不是与
<createdAt>对应呢?因为<createdAt>可能会随 Waline 系统的更新而改变[我的推测],而<insertedAt>代指这条评论插入数据库的时间,就不会受到影响。 -
[updated]与<updatedAt>一一对应(需将 ISO 8601 时间转换为 UNIX 时间戳). -
<url>中的非 ASCII 字符(如中文字符)需要进行URL编码后才可以存为[url],否则无效. -
[href]由博客域名与[url]拼接而得. -
[master]取决于评论者邮箱是否为博主邮箱. -
[pid],[rid]格式与[_id]相同,均为 UUID4 或者当其为 非回复评论 时,其值为 null. -
<pid>,<rid>格式与<objectId>相同,均为 MongoDB ObjectId 或者当其为 非回复评论 时,则不存在. -
[mailMd5]由[mail]字段进行 MD5 加密而得,32位或64位皆可,这里取32位. -
[top]与<sticky>一一对应,当<sticky>的值大于零时,[top]的值为真. -
[comment]应为 HTML 格式,而<comment>可以选择是否保留 HTML 标签,所以转换时需要把<comment>从 Markdown 进一步转换为 HTML.
流程&算法
- 最开始当然是先读取原来 Waline 的评论数据啦。
- 第一次循环,遍历原评论数据。因为
[_id]与<objectId>一一对应,而且需要根据<mail>确定[uid],又考虑到后面转换数据时可能有多层嵌套关系,所以第一次遍历原评论数据应该先建立以上字段的映射,以便后来使用。 - 第二次遍历原评论数据。这一次则需要根据上文提到的表格进行数据转换,这里需要充分利用好刚才的映射。
- 然后就可以将转换结果写入文件啦~
这样一来,我们就可以大致画出整个程序的流程图了:
Coding!
数据和算法逻辑都有了,接下来就是纯手工活了——这里使用 Python 为编程语言编写了这个迁移程序,依照上面的思路,我设计了一款交互式输入的迁移程序并部署至 GitHub 仓库:
也可以直接在这里下载运行主要代码文件(更多详细说明还请参考 GitHub 仓库):
根据以上流程,本程序需提前安装的第三方库:click, Markdown:
pip install click markdownimport jsonimport uuidimport hashlibimport clickfrom pathlib import Pathfrom datetime import datetimefrom urllib.parse import quotefrom markdown import markdown
cidMap = { }uidMap = { }res = []
def step_start(prompt): click.echo(prompt, nl=False)
def step_complete(another_newline=False): if another_newline: click.echo(" 完成✅\n") else: click.echo(" 完成✅")
def new_uuid(): return str(uuid.uuid4()).replace("-", "")
def md5_encrypt(data): md5 = hashlib.md5() md5.update(data.encode("UTF-8")) return md5.hexdigest()
def iso2unix(iso): dt = datetime.fromisoformat(iso) return int(dt.timestamp() * 1000)
def get_converted(item, site_domain, master_mail): url = quote(item["url"]) return { "nick": item["nick"], "mail": item["mail"], "link": item["link"], "ua": item["ua"], "ip": item["ip"], "url": url, "comment": markdown(item["comment"].replace("\n", "\n\n")), "pid": cidMap[item["pid"]] if "pid" in item else None, "rid": cidMap[item["rid"]] if "rid" in item else None, "_id": cidMap[item["objectId"]], "uid": uidMap[item["mail"]], "mailMd5": md5_encrypt(item["mail"]), "master": bool(item["mail"] == master_mail), "href": "https://" + site_domain + url, "isSpam": bool(item["status"] != "approved"), "created": iso2unix(item["insertedAt"]), "updated": iso2unix(item["updatedAt"]), "top": bool(item["sticky"] > 0) if "sticky" in item else False, }
def read_waline(read_file): step_start("读取 Waline 评论数据中……") with open(read_file, "r", encoding="UTF-8") as f: waline = json.load(f)["data"]["Comment"] total = len(waline) cnt = 0 step_complete() return waline, total, cnt
def establish_map(waline): step_start("映射建立中……") for item in waline: cidMap[item["objectId"]] = new_uuid() if item["mail"] not in uidMap: uidMap[item["mail"]] = new_uuid() step_complete(True)
def convert_all(site_domain, master_mail, waline, total, cnt): for item in waline: cnt += 1 step_start(f"{cnt}/{total}: 正在转换来自 [{item["nick"]}] 的评论……") res.append(get_converted(item, site_domain, master_mail)) step_complete()
def write_twikoo(write_file): step_start("\n写入文件中……") output_path = Path(write_file) output_path.parent.mkdir(parents=True, exist_ok=True) with open(write_file, "w", encoding="UTF-8") as f: json.dump(res, f, ensure_ascii=False, indent=2) step_complete()
def main(site_domain, master_mail, master_uid, read_file, write_file): global uidMap if master_uid != "": uidMap = { master_mail: master_uid } waline, total, cnt = read_waline(read_file) establish_map(waline) convert_all(site_domain, master_mail, waline, total, cnt) write_twikoo(write_file)
def interactive_input(): site_domain = click.prompt("你的站点域名") bcf = click.prompt("你(博主)有在新的Twikoo评论系统上评论过吗?(y/N)", type=bool, default=False, show_default=False) if bcf: master_mail = click.prompt("你的电子邮件") master_uid = click.prompt("你的 Twikoo UID(可在导出 Twikoo 评论数据后看到)") else: master_mail = master_uid = "" read_file = click.prompt("原 Waline 评论数据文件路径(相对路径,JSON文件)") write_file = click.prompt("新的 Twikoo 评论数据文件存储路径(相对路径,JSON文件)") click.echo() main(site_domain, master_mail, master_uid, read_file, write_file)
if __name__ == '__main__': click.echo("Program started.\n") interactive_input() click.echo("\nProgram ended.")后记
总结一下要点:自己制作一个评论数据迁移程序,大概就是 分析数据结构、程序构思 再到 编码 这三个过程,整体上来看难度不大,但是有点费时,不过写完并正确运行之后真的非常有成就感,就会觉得这段时间的功夫没有白费,想想就觉得舒服~ 同时也推荐大家自己去尝试编写这样一个程序。
如果觉得本项目不错的,欢迎在 GitHub 仓库 上给个 Star 哦~ 感谢大家对本项目的支持!如有任何疑问或想法也欢迎在仓库上提交 Issue 或 Pull request。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!
Horean's Blog