本文约 12,189 字,预计阅读 31 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:博客长大了,原来的工具装不下
Typecho 和 Hexo 都是很优秀的写作工具:Typecho 轻量、数据库结构简单,Hexo 静态、部署到 GitHub Pages 免费。但它们各自有明确的边界——Typecho 的插件生态和后台能力有限,做企业站、做电商、需要多用户协作时捉襟见肘;Hexo 每次发布要本地生成再推送,非技术同事无法参与,做 SEO 时缺少站点地图、结构化数据、内容管理等配套设施。
于是有了迁移需求。好消息是:这两类站点的内容都是结构清晰的,迁移难度远低于织梦这类老 CMS。 Typecho 的内容在一张表里,Hexo 的内容是可读的 Markdown 文件。真正的难点在两个细节:一是Markdown 内容如何保留(WordPress 原生编辑器是区块编辑器,正文存的是 HTML,Markdown 需要转换或保留源码);二是固定链接如何映射(Typecho 默认 /archives/{cid}/,Hexo 默认 /YYYY/MM/DD/slug/,与 WordPress 不同)。
原理分析:两种内容源的结构差异
Typecho 的数据结构非常干净,核心表只有几张:
| Typecho 表 | 作用 | WordPress 对应 |
|---|---|---|
typecho_contents |
文章与页面(type 字段区分 post/page) | wp_posts |
typecho_metas |
分类与标签(type 字段区分 category/tag) | wp_terms + wp_term_taxonomy |
typecho_relationships |
内容与分类/标签的关系 | wp_term_relationships |
typecho_comments |
评论 | wp_comments |
typecho_users |
用户 | wp_users |
typecho_fields |
自定义字段 | wp_postmeta |
typecho_contents 的关键字段:cid(内容 ID)、title、slug、created(Unix 时间戳)、modified、text(正文,Typecho 原生存 Markdown 源码)、type(post/page)、status(publish/draft)、authorId、commentsNum。
关键差异:Typecho 的 text 字段存储格式。 Typecho 原生使用 Markdown,但数据库里存的可能是 Markdown 源码,也可能是经过转换的 HTML——这取决于你用的是原生编辑器还是插件(比如 EditorMD、Joe 主题的编辑器)。判断方法很简单:看 text 字段开头是否可疑的 <!--markdown--> 标记,以及内容里是否有 []() 语法。
Hexo 的内容是文件系统上的 Markdown,每篇文章是一个 .md 文件,开头是 YAML Front Matter:
---
title: 文章标题
date: 2023-05-12 10:30:00
tags:
- WordPress
- 迁移
categories:
- 技术笔记
cover: /images/cover.jpg
---
正文部分是标准 Markdown。分类与标签写在 Front Matter 里而不是目录结构里(虽然 _posts 的路径不体现分类)。
WordPress 这边的选择:是否保留 Markdown?
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
A. 转 HTML 存入 post_content |
导入时把 Markdown 渲染成 HTML | 兼容所有主题与插件,区块编辑器可直接编辑 | 无法再用 Markdown 编辑 |
| B. 保留 Markdown 源码 | 源码存入 post_content,前端渲染 |
可继续用 Markdown 写作 | 需要插件支持,与部分 SEO 插件的摘要提取配合不佳 |
| C. 双份存储 | HTML 存 post_content,Markdown 存 postmeta |
兼顾 | 两份内容可能不同步 |
推荐方案 A:转成 HTML 存入 post_content,Markdown 源码存到 wp_postmeta 的 _md_source 留底。 这样兼容性最好,未来想回 Markdown 也有原始数据。
保姆级实操:两条路径分别处理
路径一:Typecho → WordPress
步骤 1:导出 Typecho 数据。 直接在 MySQL 里查询核对:
-- 统计文章与页面数量
SELECT type, status, COUNT(*) FROM typecho_contents GROUP BY type, status;
-- 查看分类与标签分布
SELECT m.type, COUNT(*) FROM typecho_metas m GROUP BY m.type;
-- 抽样看正文格式(确认是 Markdown 还是 HTML)
SELECT cid, LEFT(text, 200) FROM typecho_contents WHERE type='post' LIMIT 3;
-- 导出文章主体(用于人工核对)
SELECT c.cid, c.title, c.slug, c.created, c.text
FROM typecho_contents c
WHERE c.type = 'post' AND c.status = 'publish'
ORDER BY c.cid;
步骤 2:用插件导入(省事方案)。 Typecho 生态里有 TypechoToWordPress 插件,WordPress 侧也可以导出 Typecho 的 WXR 再导入。命令行方式更可控:
# 安装官方导入器
wp plugin install wordpress-importer --activate
# 导入 WXR(需先用 Typecho 侧的导出插件生成 XML)
wp import /tmp/typecho_export.xml --authors=create
步骤 3:写脚本直连数据库导入(精确方案)。 需要处理 Markdown 转换:
<?php
/**
* typecho2wp.php —— Typecho 迁移到 WordPress
* 执行:wp eval-file typecho2wp.php
*/
require_once __DIR__ . '/wp-load.php';
$old = new mysqli( '127.0.0.1', 'typecho_user', 'typecho_pass', 'typecho_db' );
$old->set_charset( 'utf8mb4' );
// 分类 ID 映射:Typecho mid → WordPress term_id
$cat_map = [ 1 => 5, 2 => 6 ];
$tag_map = [ 3 => 10, 4 => 11 ];
// 正文是否 Markdown:根据抽样结果设置 true / false
$IS_MARKDOWN = true;
$res = $old->query(
"SELECT cid, title, slug, created, modified, text, type, authorId
FROM typecho_contents
WHERE status = 'publish'
ORDER BY cid ASC"
);
$ok = 0;
while ( $r = $res->fetch_assoc() ) {
$raw = $r['text'];
// Typecho 原生编辑器会在正文前加 <!--markdown--> 标记
$raw = preg_replace( '/^<!--markdown-->/', '', $raw );
if ( $IS_MARKDOWN ) {
// 方案 A:转 HTML 存正文,源码存 postmeta 留底
$html = markdown_to_html( $raw );
} else {
$html = $raw;
}
$post_id = wp_insert_post( [
'post_title' => $r['title'],
'post_content' => $html,
'post_name' => $r['slug'] ?: 'p' . $r['cid'],
'post_type' => ( $r['type'] === 'page' ) ? 'page' : 'post',
'post_status' => 'publish',
'post_date' => date( 'Y-m-d H:i:s', $r['created'] ),
'post_author' => 1,
] );
if ( ! $post_id ) { continue; }
update_post_meta( $post_id, '_typecho_cid', $r['cid'] );
update_post_meta( $post_id, '_md_source', $raw ); // Markdown 源码留底
update_post_meta( $post_id, '_typecho_url', '/archives/' . $r['cid'] . '/' );
// 建立分类与标签关系
$rel = $old->query( "SELECT mid FROM typecho_relationships WHERE cid = " . (int) $r['cid'] );
$cats = $tags = [];
while ( $rr = $rel->fetch_assoc() ) {
$mid = (int) $rr['mid'];
if ( isset( $cat_map[ $mid ] ) ) { $cats[] = $cat_map[ $mid ]; }
if ( isset( $tag_map[ $mid ] ) ) { $tags[] = get_term( $tag_map[ $mid ] )->name; }
}
if ( $cats ) { wp_set_post_categories( $post_id, $cats ); }
if ( $tags ) { wp_set_post_tags( $post_id, $tags, false ); }
$ok++;
}
/**
* 简易 Markdown → HTML 转换(生产环境建议改用 Parsedown 或 league/commonmark)
*/
function markdown_to_html( $md ) {
$h = $md;
// 代码块优先保护,避免内部内容被后续规则误伤
$h = preg_replace_callback( '/```(\w*)\n(.*?)```/s', function ( $m ) {
return '<pre><code class="language-' . esc_attr( $m[1] ) . '">'
. esc_html( $m[2] ) . '</code></pre>';
}, $h );
// 标题、粗体、斜体、行内代码、链接、图片
$h = preg_replace( '/^######\s+(.*)$/m', '<h6>$1</h6>', $h );
$h = preg_replace( '/^#####\s+(.*)$/m', '<h5>$1</h5>', $h );
$h = preg_replace( '/^####\s+(.*)$/m', '<h4>$1</h4>', $h );
$h = preg_replace( '/^###\s+(.*)$/m', '<h3>$1</h3>', $h );
$h = preg_replace( '/^##\s+(.*)$/m', '<h2>$1</h2>', $h );
$h = preg_replace( '/^#\s+(.*)$/m', '<h1>$1</h1>', $h );
$h = preg_replace( '/\*\*(.+?)\*\*/', '<strong>$1</strong>', $h );
$h = preg_replace( '/(?<!\*)\*([^*\n]+)\*(?!\*)/', '<em>$1</em>', $h );
$h = preg_replace( '/`([^`]+)`/', '<code>$1</code>', $h );
$h = preg_replace( '/!\[([^\]]*)\]\(([^)]+)\)/', '<img src="$2" alt="$1" />', $h );
$h = preg_replace( '/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $h );
// 段落包裹
$h = preg_replace( '/\n{2,}/', "</p>\n<p>", $h );
return "<p>" . $h . "</p>";
}
echo "Typecho 迁移完成,共 {$ok} 条\n";
生产环境建议: 上面的简易转换只适合格式简单的文章。如果文章里有表格、任务列表、脚注,建议用 Composer 安装成熟的解析库:
composer require league/commonmark
use League\CommonMark\CommonMarkConverter;
$converter = new CommonMarkConverter( [
'html_input' => 'allow',
'allow_unsafe_links' => false,
] );
$html = $converter->convert( $raw )->getContent();
路径二:Hexo → WordPress
步骤 1:解析 _posts 目录下的 Markdown 文件。 Hexo 的 Front Matter 是标准 YAML,用 Python 处理最顺手:
#!/usr/bin/env python3
# hexo2wxr.py —— 解析 Hexo 文章,生成 WordPress 可导入的 WXR 片段 / CSV
import os, re, json, csv
POSTS_DIR = r"D:\blog\source\_posts"
OUT_CSV = "hexo_posts.csv"
def parse_front_matter(text):
"""解析 YAML Front Matter,只处理简单的 key: value 与列表,避免引入依赖"""
if not text.startswith("---"):
return {}, text
end = text.find("\n---", 3)
if end == -1:
return {}, text
fm_text, body = text[3:end], text[end+4:].lstrip("\n")
data, current_key = {}, None
for line in fm_text.splitlines():
if not line.strip():
continue
if line.startswith(" - ") and current_key: # 列表项
data.setdefault(current_key, [])
if isinstance(data[current_key], list):
data[current_key].append(line[4:].strip().strip('"\''))
continue
if ":" in line:
k, _, v = line.partition(":")
k, v = k.strip(), v.strip().strip('"\'')
if v == "":
current_key = k
data[k] = []
else:
current_key = k
data[k] = v
return data, body
rows = []
for fn in sorted(os.listdir(POSTS_DIR)):
if not fn.endswith(".md"):
continue
path = os.path.join(POSTS_DIR, fn)
with open(path, "r", encoding="utf-8") as fp:
raw = fp.read()
fm, body = parse_front_matter(raw)
# Hexo 的 abbrlink / permalink 优先作为别名,保持 URL 一致性
slug = fm.get("abbrlink") or os.path.splitext(fn)[0]
# Hexo 文件名常见格式 2023-05-12-title.md,去掉日期前缀
slug = re.sub(r"^\d{4}-\d{2}-\d{2}-", "", slug)
rows.append({
"title": fm.get("title", os.path.splitext(fn)[0]),
"slug": slug,
"date": fm.get("date", ""),
"updated": fm.get("updated", ""),
"categories": "|".join(fm.get("categories", [])) if isinstance(fm.get("categories"), list) else str(fm.get("categories", "")),
"tags": "|".join(fm.get("tags", [])) if isinstance(fm.get("tags"), list) else str(fm.get("tags", "")),
"cover": fm.get("cover", ""),
"body_md": body,
"old_url": "/" + (fm.get("abbrlink") or re.sub(r"^\d{4}-\d{2}-\d{2}-", "", os.path.splitext(fn)[0])) + "/",
})
with open(OUT_CSV, "w", newline="", encoding="utf-8-sig") as fp:
w = csv.DictWriter(fp, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
print(f"解析完成:{len(rows)} 篇 -> {OUT_CSV}")
如果不想自己解析,也可以用现成方案:Hexo 有 hexo-generator-feed 生成 RSS,再用 WordPress 的 RSS 导入器导入(正文会丢失 Markdown 格式,但保留 HTML)。
步骤 2:迁移图片。 Hexo 的图片通常在 source/images/ 或使用图床:
# 本地图片
rsync -av /hexo/source/images/ /var/www/html/wp-content/uploads/
# 如果是图床(外链),建议本地化,避免图床挂掉导致全站裂图
# 批量下载正文中的远程图片并替换为本地路径
wp media import https://图床域名/images/a.jpg
步骤 3:固定链接映射。 两种系统的默认 URL 不同,处理方式:
| 源 | 默认 URL | WordPress 对应设置 | 301 需求 |
|---|---|---|---|
| Typecho | /archives/{cid}/ |
需 301 到 /{slug}.html 或 /{slug}/ |
必须 |
| Typecho(自定义) | /{slug}.html |
固定链接设为 /%postname%.html |
无需 |
| Hexo(默认) | /YYYY/MM/DD/{slug}/ |
固定链接设为 /%year%/%monthnum%/%day%/%postname%/ |
无需 |
| Hexo(abbrlink) | /posts/{abbrlink}/ |
别名用 abbrlink,结构用 /%postname%/ |
需要(去掉 posts 前缀) |
# Typecho 默认结构的 301(Nginx)
# location = /archives/123/ 这种逐条映射用 map 更合适
# Hexo abbrlink 结构去掉 /posts/ 前缀
# rewrite ^/posts/(.+)/?$ /$1/ permanent;
# 固定链接设置
wp rewrite structure '/%postname%.html' --hard # Typecho 风格
wp rewrite structure '/%year%/%monthnum%/%day%/%postname%/' --hard # Hexo 默认风格
wp rewrite flush --hard
常见踩坑
- Markdown 转 HTML 后代码块格式丢失。 简易正则转换处理不了缩进代码块、表格、任务列表。用
league/commonmark或Parsedown这类成熟库。 - 图片是相对路径且带 Hexo 的资源目录。 Hexo 开启
post_asset_folder后,图片在与文章同名的目录里,路径是相对文件名。迁移时要改写为wp-content/uploads/下的绝对路径。 - 标签被当成分类或反之。 Typecho 的
typecho_metas用 type 字段区分,导入时务必按 type 分流,否则会出现大量uncategorized或标签变成分类。 - slug 为中文或含特殊字符。 Hexo 的
abbrlink通常是随机字符串,可直接用作 WordPress 的post_name;Typecho 的中文 slug 导入后会被 URL 编码,建议导入前统一转拼音。 - 时间戳处理错误。 Typecho 的
created是 Unix 时间戳,Hexo 的date是字符串。Typecho 要用date('Y-m-d H:i:s', $r['created'])转换,且要确认时区。 - 导入后摘要为空。 Typecho 和 Hexo 通常不单独存摘要,Hexo 用
<!-- more -->截断。导入时把<!-- more -->之前的内容提取为post_excerpt:
php
$parts = preg_split( '/<!--\s*more\s*-->/i', $html );
$excerpt = isset( $parts[1] ) ? wp_trim_words( strip_tags( $parts[0] ), 120 ) : '';
$html = str_replace( '<!--more-->', '', $html );
- 案例:一次”图片全裂”的 Hexo 迁移。 客户的 Hexo 博客用了图床(某个免费图床服务),正文里全是
https://图床域名/2023/05/abc.jpg这样的外链。迁移到 WordPress 时只导了 Markdown,图片仍指向图床。半年后该图床停止免费服务,全站 400 多张图片变成裂图。补救方式是:从 Wayback Machine 爬回部分图片,其余只能重画。教训:迁移是图片本地化的最佳时机,尤其是依赖第三方图床的站点——图床的生命周期通常短于你的博客。批量下载并本地化后,再考虑用对象存储 + CDN 托管。
总结
Typecho / Hexo 迁移的判断标准是”内容完整、格式正确、URL 有映射、图片已本地化”。执行顺序:确认正文存储格式(Markdown 还是 HTML)→ 导出数据(Typecho 查库 / Hexo 解析 md)→ 用成熟库转换 Markdown 并保留源码到 _md_source → 迁移并本地化图片 → 设置匹配的固定链接结构 → 对无法保持的 URL 配置 301。推荐把固定链接结构设成与原站一致,这样连 301 都省了。 迁移过程中的通用数据映射思路可以参考 老站迁移到 WordPress,URL 处理参照 网站改版 URL 映射,操作前记得按 网站数据备份策略 备份。
