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

静态 HTML 站迁移到 WordPress:页面转文章与 URL 保持方案

手上有个几十上百个 HTML 文件的老站怎么搬到 WordPress?本文给出页面解析转文章、目录结构映射为分类、`.html` 后缀 URL 保持、模板转主题的实操方案与脚本。

U
UFO_PF 2026-02-10 · 阅读 22 分钟
静态 网站迁移
阅读提示
本文约 8,795 字,预计阅读 22 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:一堆 HTML 文件,怎么变成可后台管理的站点

很多运行了十年以上的企业站、个人主页,本质就是一堆静态 HTML 文件:首页 index.html、关于我们 about.html、产品页 product/1.html……改一个页脚要改 30 个文件,加一篇文章要手动复制模板再改,最后还要用 FTP 上传。

想换成 WordPress,摆在面前的具体问题有三个:一百多个 HTML 文件怎么变成可编辑的文章?原来的 URL(尤其是 .html 结尾的)能不能保持不变,保住百度收录?老页面的设计和样式怎么复用,是套新主题还是把老模板改成主题?

静态站迁移其实比 CMS 之间迁移更可控——没有数据库结构差异,只有文件解析和 URL 映射两个问题。

原理分析:静态站的三个结构特征

特征一:内容与模板混在一起。 HTML 文件里既有正文,也有头部导航、侧边栏、页脚。迁移时要”抽取正文区域”,其余部分由 WordPress 主题统一提供。抽取的规则通常是定位到某个容器标签(如 <div class="content">、main、article)。

特征二:URL 与文件路径一一对应。 /news/2020/0115.html 就是 news/2020/0115.html 这个真实文件。迁移到 WordPress 后,URL 由固定链接结构(permalink structure)动态生成,与文件路径不再有关系。这就是 URL 保持方案要解决的核心矛盾。

特征三:目录结构天然是分类。 product/ 目录对应”产品中心”,news/ 对应”新闻中心”。这个天然结构可以直接映射为 WordPress 的分类或页面层级,比人工重新归类省事得多。

URL 保持的三档方案,按代价从低到高:

方案 做法 收录影响 适用场景
A. 完全保持 固定链接设为 /%postname%.html,别名取原文件名 无影响 原 URL 是扁平结构且别名规范
B. 路径保持 用自定义文章类型或分类前缀 + 别名 无影响 原 URL 带目录层级
C. 变化后 301 接受新 URL,全量配置 301 1–3 个月波动 原 URL 混乱(中文名、超长参数)

关键实现点:WordPress 的固定链接可以用自定义结构加上 .html 后缀。 在「设置 → 固定链接」里选择”自定义结构”,填入 /%postname%.html,生成的 URL 就是 https://域名/文章别名.html,与原静态站格式一致。

保姆级实操:五个阶段完成搬迁

阶段 1:盘点文件、梳理目录结构。

# 统计各目录的 HTML 文件数量
find /old_site -name '*.html' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn

# 列出所有 HTML 文件及其标题,输出成待办清单
for f in $(find /old_site -name '*.html'); do
  title=$(grep -oP '(?<=<title>).*?(?=</title>)' "$f" | head -1)
  echo -e "${f}\t${title}"
done > file_list.tsv

wc -l file_list.tsv

把输出结果整理成一张”文件 → 目标类型(文章/页面)→ 目标分类”的映射表。判断标准:目录型的(news/、blog/)→ 文章;孤立的(about.html、contact.html)→ 页面。

阶段 2:解析 HTML 抽取正文。 用 Python + BeautifulSoup 是最省事的方案:

#!/usr/bin/env python3
# extract_html.py —— 批量解析静态 HTML,输出可用于导入的 CSV
import os, re, csv, hashlib
from bs4 import BeautifulSoup

SRC_DIR = r"C:\old_site"        # Windows 路径;Linux 改为 /old_site
OUT_CSV = "import_ready.csv"
# 正文容器选择器,按老站实际结构调整
CONTENT_SELECTORS = [".content", ".article-body", "main", "article", "#main"]

rows = []

for root, dirs, files in os.walk(SRC_DIR):
    for fn in files:
        if not fn.lower().endswith((".html", ".htm")):
            continue
        path = os.path.join(root, fn)
        rel  = os.path.relpath(path, SRC_DIR).replace("\\", "/")

        with open(path, "r", encoding="utf-8", errors="ignore") as fp:
            soup = BeautifulSoup(fp.read(), "html.parser")

        # 标题:优先 h1,其次 <title>
        h1 = soup.find("h1")
        title = h1.get_text(strip=True) if h1 else (soup.title.get_text(strip=True) if soup.title else fn)

        # 正文:按选择器优先级找容器,找不到就用 body
        body = None
        for sel in CONTENT_SELECTORS:
            body = soup.select_one(sel)
            if body:
                break
        body = body or soup.body

        # 清理:去掉脚本、样式、老站导航
        for tag in body.find_all(["script", "style", "noscript"]):
            tag.decompose()
        for tag in body.find_all(class_=re.compile(r"(nav|menu|footer|header|sidebar|breadcrumb)")):
            tag.decompose()

        content = str(body).strip()

        # 抓取 meta description 作为摘要
        meta = soup.find("meta", attrs={"name": "description"})
        desc = meta.get("content", "") if meta else ""

        # 抓取 meta keywords 作为标签
        kw = soup.find("meta", attrs={"name": "keywords"})
        tags = kw.get("content", "") if kw else ""

        # 抓取发布时间(常见写法)
        date = ""
        for sel in [".date", ".post-time", "time"]:
            t = soup.select_one(sel)
            if t:
                date = t.get("datetime") or t.get_text(strip=True)
                break

        # 图片路径归一化为站点相对路径
        content = re.sub(r'src=["\'](?:/|)(uploads|images)/', r'src="/wp-content/uploads/', content)

        rows.append({
            "old_path":  rel,
            "title":     title,
            "content":   content,
            "excerpt":   desc,
            "tags":      tags,
            "date":      date,
            "slug":      os.path.splitext(fn)[0],
            "top_dir":   rel.split("/")[0] if "/" in rel else "",
        })

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}")

阶段 3:拷贝图片与静态资源。

# 假设老站的图片都在 uploads/ 和 images/ 目录
rsync -av /old_site/uploads/ /var/www/html/wp-content/uploads/
rsync -av /old_site/images/  /var/www/html/wp-content/uploads/images/

# 清理可能混入的缩略图与备份文件
find /var/www/html/wp-content/uploads -name '*.bak' -delete
find /var/www/html/wp-content/uploads -name 'Thumbs.db' -delete

# 修正权限
chown -R www-data:www-data /var/www/html/wp-content/uploads

阶段 4:把 CSV 导入 WordPress。 用 WP-CLI 写导入脚本,比在后台点更可控:

<?php
/**
 * import_static.php —— 从 extract_html.py 生成的 CSV 导入静态页面
 * 执行:wp eval-file import_static.php
 */
$csv  = '/tmp/import_ready.csv';
$dir_map = [ 'news' => 5, 'product' => 6, 'case' => 7 ]; // 目录 → 分类 term_id
$handle = fopen( $csv, 'r' );
$header = fgetcsv( $handle );

$page_slugs = [ 'about.html', 'contact.html', 'index.html' ]; // 这些做成页面

$ok = 0;
while ( ( $row = fgetcsv( $handle ) ) !== false ) {
    $data = array_combine( $header, $row );

    $is_page = in_array( $data['old_path'], $page_slugs, true ) || $data['top_dir'] === '';
    $post_type = $is_page ? 'page' : 'post';

    $post_id = wp_insert_post( [
        'post_title'   => $data['title'],
        'post_content' => $data['content'],
        'post_excerpt' => $data['excerpt'],
        'post_name'    => sanitize_title( $data['slug'] ),
        'post_type'    => $post_type,
        'post_status'  => 'publish',
        'post_date'    => $data['date'] ?: current_time( 'mysql' ),
    ] );

    if ( ! $post_id ) { continue; }

    // 保留原始路径,用于生成 301
    update_post_meta( $post_id, '_static_old_path', '/' . $data['old_path'] );

    // 目录映射为分类
    if ( ! $is_page && isset( $dir_map[ $data['top_dir'] ] ) ) {
        wp_set_post_categories( $post_id, [ $dir_map[ $data['top_dir'] ] ] );
    }

    // meta keywords 转成标签
    if ( ! empty( $data['tags'] ) ) {
        wp_set_post_tags( $post_id, array_filter( array_map( 'trim', explode( ',', $data['tags'] ) ) ), false );
    }

    $ok++;
}
fclose( $handle );
echo "导入完成,共 {$ok} 条\n";

阶段 5:配置固定链接与 301,锁住 URL。

# 设置固定链接为 /别名.html(与原静态站一致)
wp rewrite structure '/%postname%.html' --hard
wp rewrite flush --hard

# 检查生成结果
wp post list --post_type=post --field=url --posts_per_page=5

对于无法完全保持的 URL(比如中文文件名),用元数据生成映射并导入 Redirection:

# 导出旧路径 → 新 URL 的映射
wp eval '
$q = new WP_Query(["post_type" => "any", "posts_per_page" => -1, "meta_key" => "_static_old_path"]);
while ($q->have_posts()) { $q->the_post();
  $old = get_post_meta(get_the_ID(), "_static_old_path", true);
  $new = str_replace(home_url(), "", get_permalink());
  printf("%s,%s,0,301,静态站迁移\n", $old, $new);
}' > /tmp/static_redirects.csv

Nginx 侧补一条兜底规则,处理漏网的路径:

# 老站的 /news/2020/0115.html → 新站的 /0115.html
location ~ ^/news/\d{4}/(.+)\.html$ {
    return 301 https://www.mimangfei.com/$1.html;
}

常见踩坑

  1. 正文抽取不干净,把导航和页脚一起导进去了。 每篇文章开头都是”首页 > 产品中心 > “,结尾是备案号。解决方式是先用上面的选择器精确定位正文容器,并在导入前随机抽 10 条人工检查。
  2. 中文文件名导致 slug 混乱。 产品中心.html 转 slug 后可能变成 %e4%ba%a7%e5%93%81... 或空值。导入时显式指定 post_name,用拼音或英文别名。
  3. 固定链接没加 .html 后缀。 老 URL 是 /about.html,新 URL 是 /about/,虽然只差一个后缀,但对搜索引擎是两个不同的 URL,必须 301。
  4. 图片路径是相对路径(../images/a.jpg)。 导入后图片全部裂图,因为相对基准变了。解析脚本里要把相对路径改写成绝对路径。
  5. 忘了处理首页和栏目页。 首页 index.html 应该做成 WordPress 的”静态首页”,栏目页(news/index.html)应该做成分类归档页,而不是当成普通文章导入。
  6. 导入后没刷新固定链接。 表现为所有新页面 404,执行 wp rewrite flush --hard 即可。
  7. 案例:一次”导入成功但整站样式丢失”。 客户的老站有一套自定义 CSS 和一个 1200 行的 jQuery 导航脚本,团队把 HTML 转成了 WordPress 文章,但没处理 CSS。新站用了默认主题,正文里的 <div class="content"> 与新主题的样式类冲突,页面排版全乱,表格溢出、图片超出容器。后来他们的做法是:把老站的 CSS 里与正文相关的部分(.content table、.content img 等)单独抽出来,做成子主题的 style.css 的一部分,其余的导航、页脚样式全部丢弃由新主题接管。教训:静态站迁移时不要试图 100% 复用老样式,只保留”正文内部”的样式规则,布局相关的交给新主题,这样既能保住文章排版,又能获得响应式和管理便利性。

总结

静态站迁移的核心是”抽取内容、保持 URL、丢弃模板”。执行顺序:盘点目录 → 用 BeautifulSoup 批量抽取正文与 meta 信息 → 拷贝图片到 uploads → 用 wp_insert_post 导入(目录映射为分类、孤立页做成页面)→ 固定链接设为 /%postname%.html → 用 _static_old_path 生成 301 映射 → 抽样验证。判断标准是:老 URL 抽样 20 条全部返回 301 且跳转到正确新页面,文章内图片无裂图。老站模板只保留正文相关 CSS,布局交给新主题,这是成本和效果的最优解。迁移完成后的收录监控与 网站改版 URL 映射 里的方法一致,迁移前记得按 网站数据备份策略 做完整备份。

U
UFO_PF作者

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

留下你的想法

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