搜索全站
网站迁移 31 分钟阅读

Typecho / Hexo 迁移到 WordPress:内容导入与固定链接映射

从 Typecho 或 Hexo 迁到 WordPress:本文讲清 Typecho 的 contents 表字段映射、Hexo Markdown 文件的 Front Matter 解析、图片与标签处理、固定链接保持方案与常用插件对比。

U
UFO_PF 2019-09-22 · 阅读 31 分钟
Ty 网站迁移
阅读提示
本文约 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

常见踩坑

  1. Markdown 转 HTML 后代码块格式丢失。 简易正则转换处理不了缩进代码块、表格、任务列表。用 league/commonmark 或 Parsedown 这类成熟库。
  2. 图片是相对路径且带 Hexo 的资源目录。 Hexo 开启 post_asset_folder 后,图片在与文章同名的目录里,路径是相对文件名。迁移时要改写为 wp-content/uploads/ 下的绝对路径。
  3. 标签被当成分类或反之。 Typecho 的 typecho_metas 用 type 字段区分,导入时务必按 type 分流,否则会出现大量 uncategorized 或标签变成分类。
  4. slug 为中文或含特殊字符。 Hexo 的 abbrlink 通常是随机字符串,可直接用作 WordPress 的 post_name;Typecho 的中文 slug 导入后会被 URL 编码,建议导入前统一转拼音。
  5. 时间戳处理错误。 Typecho 的 created 是 Unix 时间戳,Hexo 的 date 是字符串。Typecho 要用 date('Y-m-d H:i:s', $r['created']) 转换,且要确认时区。
  6. 导入后摘要为空。 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 );

  1. 案例:一次”图片全裂”的 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 映射,操作前记得按 网站数据备份策略 备份。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。