2671 字
13 分钟

数据迁移之「从 Waline 到 Twikoo」

本文是对上一篇中提到的待解决问题给出的应对方案,可以点此阅读 LeanCloud停止对外服务&第一次博客迁移

距离上一次发文已经有足足十二天了!这篇文章也大概拖了有一个多星期了,不过还是先不讲这么多废话了吧。

前言#

上一篇文章其实是有提到我将博客评论系统从 Waline 迁移到 Twikoo 的,但是当时并没有迁移原来的评论数据,因为 Twikoo 的管理面板中并不支持直接从 Waline 导入,目前好像只支持从 ValineDisqusArtalkTwikoo 本身导入;本来我还想着因为 WalineValine 的进化版,可不可以把 Waline 的评论数据通过 Valine 选项导入……但是非常不幸的是,失败了!看来这两者之间的数据格式还是与些许不兼容的。

——那么,正所谓“自己动手,丰衣足食”,既然前面的选项都无法直接导入,那就干脆自己从零开始制作一个程序把 Waline 格式的评论数据转换为 Twikoo 格式的评论数据然后通过 Twikoo 选项导入啦~(谁会想着自己一个一个手动迁移呢)

自己动手,丰衣足食#

对比数据结构#

迁移之前,当然要先充分了解 WalineTwikoo 各自的数据结构特点,找出其中的相同和不同之处,才能更加系统地制作迁移程序。

总览全局#

首先,放长眼光,总览全局,两者导出的数据都是 JSON 格式的文件:

不难发现 Waline 的数据结构大致是:

waline.json
{
"data": {
"Comment": [
{/*...*/},
{/*...*/},
{/*...*/},
],
/*...*/
},
}

Twikoo 的数据结构大致是:

twikoo.json
[
{/*...*/},
{/*...*/},
{/*...*/},
]

对比非常明显,Twikoo 的数据文件应该是要比 Waline 小很多的,而且结构也更加清晰。在仔细观察后,就会发现 Twikoo 的数据结构整体的这个数组就对应了 Waline 数据结构中的 Comment 字段的数组,而且这个数组中的每一个值都是一个对象,代表着每一条评论数据。

深入探究#

其次,分析两者评论数据数组中的具体内容,请看下面两组例子(这里就拿我的博客的一些评论数据举例):

  1. 第一组(非回复评论):

    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
    },
  2. 第二组(回复评论):

    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
    },

不知道大家有没有发现些什么,WalineTwikoo 这两种评论系统的数据结构非常相似呢,例如 nick, mail, comment 等等;当然也有一些不一样的地方,比如 WalineobjectIdTwikoo_idWalinestatusTwikooisSpam

所以这里就需要我们逐个分析,一一对比,便可得到如下表格数据对应表格:

字段含义Waline 字段名称Twikoo 字段名称
评论者昵称nick
评论者邮箱mail
评论者用户代理ua
评论者IP地址ip
评论内容comment
评论者网站link
评论地址资源路径url
回复的父评论IDpid(null | 24位MongoDB ObjectId / UUID4)
回复的根评论IDrid(null | 24位MongoDB ObjectId / UUID4)
点赞人数/列表like(number / string[])
评论IDobjectId(24位MongoDB ObjectId)_id(UUID4)
Twikoo中的用户ID-uid(UUID4)
Waline中的用户IDuser_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)
注意

非常重要的一点,这里需要注意相同字段在两种不同的评论系统下数据类型可能不同

程序构思#

为了更好地编写迁移程序,我们需要在这之前梳理程序思路,思考这些数据的具体处理方式。

数据处理#

以下条例中将用 中括号[] 代指 Twikoo 字段,用 尖括号<> 代指 Waline 字段。

  • 完全照搬: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.

提醒
  1. <user_id> 字段可以完全不用理会,因为未注册的用户没有这一属性,所以这里以 <mail> 作为判断是否为同一用户的依据。
  2. <like> 字段也可以不用理会,因为 Waline 中的 <like> 字段存储数据为点赞总量,而 Twikoo 中的 [like] 字段则是一个包含点赞用户ID的数组,所以不兼容,只可从 TwikooWaline 单向转换。

流程&算法#

  1. 最开始当然是先读取原来 Waline 的评论数据啦。
  2. 第一次循环,遍历原评论数据。因为 [_id]<objectId> 一一对应,而且需要根据 <mail> 确定 [uid],又考虑到后面转换数据时可能有多层嵌套关系,所以第一次遍历原评论数据应该先建立以上字段的映射,以便后来使用。
  3. 第二次遍历原评论数据。这一次则需要根据上文提到的表格进行数据转换,这里需要充分利用好刚才的映射。
  4. 然后就可以将转换结果写入文件啦~

这样一来,我们就可以大致画出整个程序的流程图了:

程序流程图

Coding!#

数据和算法逻辑都有了,接下来就是纯手工活了——这里使用 Python 为编程语言编写了这个迁移程序,依照上面的思路,我设计了一款交互式输入的迁移程序并部署至 GitHub 仓库

Horean0574
/
waline2twikoo
Waiting for api.github.com...
00K
0K
0K
Waiting...

也可以直接在这里下载运行主要代码文件(更多详细说明还请参考 GitHub 仓库):

注意

根据以上流程,本程序需提前安装的第三方库:click, Markdown:

Terminal window
pip install click markdown
main.py
import json
import uuid
import hashlib
import click
from pathlib import Path
from datetime import datetime
from urllib.parse import quote
from 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 哦~ 感谢大家对本项目的支持!如有任何疑问或想法也欢迎在仓库上提交 IssuePull request

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
数据迁移之「从 Waline 到 Twikoo」
https://blog.hxrch.top/posts/数据迁移之从-waline-到-twikoo/
作者
Horean
发布于
2026-02-20
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Horean
Before was was was, was was is.
公告
调对频率的人,会找到这里。
分类
标签
站点统计
文章
25
分类
5
标签
79
总字数
48,678
运行时长
0
最后活动
0 天前

目录

;