从Typecho完美迁移到Hexo | 字数总计: 3.5k | 阅读时长: 15分钟 | 阅读量: |
请不要直接拷贝运行本文中的脚本!需要根据你博客的设置进行修改。 迁移前务必备份数据。 迁移前务必备份数据。 迁移前务必备份数据。
经过了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 refrom datetime import datetimeimport mysql.connectordb = 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 osimport randomimport stringimport sysfrom os.path import existsfrom urllib.parse import urlparseimport requestsif 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 globimport reimport mysql.connectordb = 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('&' , '&' ).replace('<' , '<' ).replace('>' , '>' ) 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 jsonimport sqlite3from datetime import datetime, timedeltawith 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 globimport reimport sqlite3import mysql.connectordb = 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('&' , '&' ).replace('<' , '<' ).replace( '>' , '>' ) 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 ]) 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主题的自定义,看看什么时候有空再写吧……