请不要直接拷贝运行本文中的脚本!需要根据你博客的设置进行修改。
迁移前务必备份数据。
迁移前务必备份数据。
迁移前务必备份数据。

经过了5年时间,typecho和handsome主题伴随着我的博客走到现在,在此向各位开发者表示感谢。
然而,随着时间的推移以下问题越来越凸显:

  • typecho及插件年久失修。虽然前些日子typecho诈尸了1.2版本,但各种插件基本都躺尸了,兼容问题很难解决。
  • handsome主题代码膨胀。目前最新版8.4.1仅php就有2w左右LoC,因为要支持各种需求作者添加了很多新功能导致代码量增长很快。这里绝对不是批判的意思,这个主题可以说是typecho第一梯队模板之一了,要不然我也不会用了5年。可惜很久以前我就用不到更新的新功能了,因为没有公开repo,每次更新还需要自行维护版本修改自定义内容,说实话挺累的……
  • 安全性问题令人担忧。typecho、插件以及主题暴露的攻击面实在是无法控制,php又是一门神奇的语言,上面两个问题更加剧了这一点。虽然我做了容器化但问题依然存在。

年初就想迁移了,但由于各种事情一直拖着摆烂,而压垮骆驼的最后一根稻草是一周前有无聊的人来刷评论,由于屏蔽插件失效只能从nginx层面来做,最后花了一周时间整体迁移到了Hexo。

船新版本和存在的挑战

Hexo大名大家都听过,至于为什么不选更快的Hugo主要是生态问题,插件和主题都不是一个量级的。主题的选择原本打算用简洁而美的NEXT,然后逛着逛着发现Butterfly更漂亮,很多需要的功能也集成了(静态博客就不怎么需要考虑安全性了,相反文件大小控制很重要),于是乎直接转投蝴蝶的怀抱(x

问题在于需要平滑且完美的从旧博客迁移,主要解决下面三个问题:

  • 文章/评论数据导出
  • url跳转
  • Butterfly主题自定义(这个留到下一篇讲吧)

翻了翻网上没有什么近期好用的工具,为了数据完整性我还是自己写脚本来搞吧……同时也能方便后人。

数据导出

文章内容导出

首先hexo的文章都是markdown文件形式的,还好typecho也是md格式的文章,不需要再做兼容了,我们只需要提取就行了,需要注意如下几个问题

  • 创建和修改时间需要保留(通过date和updated)
  • 分类和标签需要保留(查表join)
  • 文件名命名(按标题命名,将不合法路径字符替换为 -

因为我的文章只有一个分类,下面的脚本只处理了单分类的情况,如果你文章有多个分类,需要自行修改部分代码。

写了个脚本来整:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import re
from datetime import datetime

import mysql.connector

db = mysql.connector.connect(
host="localhost",
user="root",
password="123456",
database="db"
)

cur = db.cursor()
cur.execute(
"SELECT * FROM `typecho_contents` WHERE `template` IS NULL AND `type`='post'")
res = cur.fetchall()

for x in res:
content = x[5].removeprefix('<!--markdown-->')
created = datetime.fromtimestamp(x[3]).strftime("%Y-%m-%d %H:%M:%S")
updated = datetime.fromtimestamp(x[4]).strftime("%Y-%m-%d %H:%M:%S")
cur.execute(
f'SELECT * FROM `typecho_relationships` JOIN `typecho_metas` WHERE `typecho_relationships`.`mid`=`typecho_metas`.`mid` AND `typecho_relationships`.`cid`={x[0]}')
meta = cur.fetchall()
category = []
tag = []
for y in meta:
if y[5] == 'category':
category.append([y[3], y[4]])
elif y[5] == 'tag':
tag.append(y[4])
else:
print(x[1], 'error unhandled', y[5])
if len(category) != 1:
print(x[1], 'category length warning', len(category))
continue
link = f'{category[0][1]}/{x[0]}.html'
title = re.sub(r'[^\w\-_\. ]', '-', x[1])
with open(f'./source/_posts/{title}.md', 'w') as f:
f.write(f'''---
title: {x[1]}
date: {created}
updated: {updated}
tags: [{','.join(tag)}]
categories: {category[0][0]}
---

{content}
''')

至于handsome提供的一些短代码之类没什么好办法,搜索然后自行替换吧……

还有个小问题是图片问题,可以直接复制原本的目录格式这样不用改之前的文章。至于新文章hexo这一点做的比较垃圾,当然有大佬做了可视化编辑器,勤快点的话可以装上试试。然而我想让图片和文章url类似(参考下文,即为随机字符串),于是乎写了个小工具来自动搞。
下面这个脚本会将本地文件移动或下载在线文件,然后以8位随机字符文件名([0-9a-f])保存在 source/attachments 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import os
import random
import string
import sys
from os.path import exists
from urllib.parse import urlparse

import requests

if len(sys.argv) != 2:
print('No file provided')

file = sys.argv[1]
newname = ''.join(random.choices('abcdef' + string.digits, k=8))
if file.startswith('http'):
r = requests.get(file)
path = urlparse(file).path
ext = os.path.splitext(path)[1]
if exists(f'./source/attachments/{newname}{ext}'):
print('conflict')
exit(1)
with open(f'./source/attachments/{newname}{ext}', 'wb') as f:
f.write(r.content)
else:
ext = os.path.splitext(file)[1]
if exists(f'./source/attachments/{newname}{ext}'):
print('conflict')
exit(1)
os.rename(file, f'./source/attachments/{newname}{ext}')
print(f'/attachments/{newname}{ext}')

以上我们基本完成了文章迁移,可能有些有点兼容性问题,生成之后需要看一眼。

文章链接迁移

hexo我用了hexo-abbrlink生成永久链接,由于和typecho的文章地址格式不一样,需要做一个映射,可以参考下面这个脚本:

以下脚本需要先 hexo server 生成一下 abbrlink,仅适用于 /slug/id.html 格式的typecho设置,其他格式需要自行修改。
同样只处理了单分类的情况,标题中的特殊字符也需要处理一下(参考代码中的 title.replace)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import glob
import re

import mysql.connector

db = mysql.connector.connect(
host="localhost",
user="root",
password="123456",
database="db"
)

for fname in glob.glob('./source/_posts/*.md'):
with open(fname, 'r') as f:
data = f.read()
ret = re.search(r'title: (.*)$', data, re.M)
if not ret:
print(f"error1! {fname}")
exit(0)
title = ret.group(1)
ret = re.search(r'abbrlink: \'?(.*?)\'?$', data, re.M)
if not ret:
print(f"error2! {fname}")
exit(0)
link = f'/posts/{ret.group(1)}.html'
searchtitle = title.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
cur = db.cursor()
cur.execute(
f"SELECT * FROM `typecho_contents` WHERE `title`='{searchtitle}'")
res = cur.fetchall()
if len(res) != 1:
print(f'error3! {fname}', searchtitle)
exit(0)
x = res[0]
cur.execute(
f'SELECT * FROM `typecho_relationships` JOIN `typecho_metas` WHERE `typecho_relationships`.`mid`=`typecho_metas`.`mid` AND `typecho_relationships`.`cid`={x[0]}')
meta = cur.fetchall()
category = []
tag = []
for y in meta:
if y[5] == 'category':
category.append([y[3], y[4]])
elif y[5] == 'tag':
tag.append(y[4])
else:
print(x[1], 'error unhandled', y[5])
if len(category) != 1:
print(x[1], 'category length warning', len(category))
continue
prevlink = f'/{category[0][1]}/{x[0]}.html'
print(prevlink + ',' + link)

评论数据导出

由于是静态博客,没有后端处理评论了,调研了目前的几个解决方案。首先disqus这类被墙的直接排除,然后基于github的由于国内访问不稳定也排除,剩下就是基于SaaS/FaaS的和waline这类有后端的。由于需要审核机制,加上不想将数据交给第三方,这里我选择了waline作为评论系统。
瞅了眼源码还挺好懂的,方便以后自定义。用的过程中顺手修了两个bug(笑):#1271#1276

至于迁移官方提供了一个指南,但我想放在本地而不是第三方,照文档说的Export2Valine这个玩意儿压根就不支持(我也比较怀疑支不支持当前版本)……
没办法自己改咯,首先 Action.php 42行开始需要改成这样,要不然很难追踪父评论:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$arr = array(
"objectId" => $comment["coid"],
"QQAvatar" => "",
"comment" => $comment["text"],
"insertedAt" => array(
"__type" => "Date",
"iso" => $time
),
"createdAt" => $time,
"updatedAt" => $time,
"ip" => $comment["ip"],
"link" => $comment["url"],
"mail" => $comment["mail"],
"nick" => $comment["author"],
"ua" => $comment["agent"],
"url" => "/{$slug}.html"
);

if($comment["parent"]) {
$arr["pid"] = $comment["parent"];
$arr["rid"] = $this->getRootId($comment["coid"]);
}

然后启用这个插件(谢天谢地typecho 1.2版本还是兼容的),把 valine.xxx.jsonl 下载下来。因为格式不对需要手动删除开头的 #filetype:JSON-streaming {"type":"Class","class":"Comment"}\n\n,然后重命名为 valine.json

接下来用这个脚本导入到waline的sqlite数据库中,postmap.txt 即为前一小节生成的新旧地址映射。

其他数据库也可以,自行修改adapter。
需要处理独立文章,参考 if u not in ['/msg.html', '/links.html']:
需要处理博主评论,这个脚本只支持单博主,多个文章作者需要自行修改,参考 if obj['link'] == 'https://www.imwxz.com/':

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import json
import sqlite3
from datetime import datetime, timedelta

with open('valine.json', 'r') as f:
data = f.read()
data = data.split('}\\n{')

mp = {}
with open('postmap.txt', 'r') as f:
l = f.readlines()
for i in l:
d = i.split(',')
mp['/' + d[0].removeprefix('/').split('/')[1]] = d[1].strip()


def parseTime(t):
return datetime.strftime(datetime.strptime(t, '%Y-%m-%dT%H:%M:%S.%fZ') + timedelta(hours=8), '%Y-%m-%d %H:%M:%S')


idmap = {None: None}
id = 1
conn = sqlite3.connect('./data/waline.sqlite')
c = conn.cursor()
for i in data:
if i[0] != '{':
i = '{' + i
if i[-1] != '}':
i = i + '}'
obj = json.loads(i)
idmap[obj['objectId']] = id
id += 1

id = 1
for i in data:
if i[0] != '{':
i = '{' + i
if i[-1] != '}':
i = i + '}'
obj = json.loads(i)
u = obj['url']
if u not in ['/msg.html', '/links.html']:
u = mp[u]
if 'pid' not in obj:
obj['pid'] = None
if 'rid' not in obj:
obj['rid'] = None
user = None
if obj['link'] == 'https://www.imwxz.com/':
user = 1
obj['mail'] = 'me@imwxz.com'
obj['createdAt'] = parseTime(obj['createdAt'])
obj['updatedAt'] = parseTime(obj['updatedAt'])
obj['insertedAt']['iso'] = parseTime(obj['insertedAt']['iso'])
p = (id, user, obj['comment'], obj['insertedAt']['iso'],
obj['createdAt'], obj['updatedAt'], obj['ip'], obj['link'], obj['mail'], obj['nick'], obj['ua'], u, "approved", idmap[obj['pid']], idmap[obj['rid']])
c.execute(
"INSERT INTO wl_Comment (id,user_id,comment,insertedAt,createdAt,updatedAt,ip,link,mail,nick,ua,url,status,pid,rid) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);", p)
id += 1
conn.commit()
conn.close()

浏览量数据导出

用之前链接迁移的脚本改的,也可以在迁移的时候一并做掉,懒得改了:

注意合并之前文章链接迁移中的修改。
总访问量初始化是所有文章访问量的和,id为1,以后将会是首页的访问量,如有需要可以自己改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import glob
import re
import sqlite3

import mysql.connector

db = mysql.connector.connect(
host="localhost",
user="root",
password="123456",
database="db"
)

tot = 0
conn = sqlite3.connect('./data/waline.sqlite')
c = conn.cursor()
c.execute(
"INSERT INTO wl_Counter (time,url) VALUES (?,?);", (0, '/'))
for fname in glob.glob('./source/_posts/*.md'):
with open(fname, 'r') as f:
data = f.read()
ret = re.search(r'title: (.*)$', data, re.M)
if not ret:
print(f"error1! {fname}")
exit(0)
title = ret.group(1)
ret = re.search(r'abbrlink: \'?(.*?)\'?$', data, re.M)
if not ret:
print(f"error2! {fname}")
exit(0)
link = f'/posts/{ret.group(1)}.html'
searchtitle = title.replace('&', '&amp;').replace('<', '&lt;').replace(
'>', '&gt;')
cur = db.cursor()
cur.execute(
f"SELECT views FROM `typecho_contents` WHERE `title`='{searchtitle}'")
res = cur.fetchall()
if len(res) != 1:
print(f'error3! {fname}', searchtitle)
exit(0)
x = int(res[0][0])
# print(x)
tot += x
p = (x, link)
c.execute(
"INSERT INTO wl_Counter (time,url) VALUES (?,?);", p)

c.execute(f'UPDATE wl_Counter SET time={tot} WHERE id=1;')
conn.commit()
conn.close()

url跳转

文章内容导出差不多了,但是因为改变了地址,之前的SEO权重会被重置,如果不想丢失流量并且兼容之前的地址的话需要做301跳转,等过个半年一年左右稳定了就可以删掉了。这边给出一个nginx的高效配置生成脚本,postmap.txt 是前文里的映射文件:

这个脚本只生成了文章跳转,如果标签、分类等要跳转请自行增加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cat = {}
with open('postmap.txt', 'r') as f:
l = f.readlines()

for i in l:
d = i.split(',')
name = d[0].removeprefix('/').split('/')[0]
if name not in cat:
cat[name] = []
cat[name].append(d)

for k, v in cat.items():
content = ''
for j in v:
content += f'location = {j[0]} {{ return 301 {j[1].strip()}; }}\n'
print(content)

后续工作

到此为止数据迁移工作就做的差不多了,注意上面脚本基本没有鲁棒性,都是基于我这里的配置和假定条件搞得,如果你是小白希望直接复制代码大概率是不可行的,还请出门左拐重新开始。

然后如果你恰好懂一丢丢python并且运气好成功运行了代码,最好还是做一步生成了看一下,修修bug啥的。整体迁移耗时还是比较长的,请预留一周左右的时间,备份好数据再整。

下一篇将会是基于Butterfly主题的自定义,看看什么时候有空再写吧……