换个域名,比想象中麻烦

博客从 blog.demo.fan 迁移到 blog.demohappy.top。这件事看起来就是把 _config.yml 里的 url: 改一行、重新部署。

改完确实生效了——页面能打开,只是有两件事没跟着走。

第一件:搜索结果里点进来,<link rel="canonical"> 还指着 blog.demo.fan。这意味着搜索引擎会认为新域名上的每一页都是旧域名内容的副本,新域名的权重永远是零。

第二件:sitemap.xml 里 149 条 URL,全部是旧域。

为什么会这样:三类引用的生命周期不同

把「站点里出现的域名」按来源分类,会发现它们的更新方式完全不同:

类型 来源 改 url: 生效吗
canonical / og:url / JSON-LD / sitemap 模板运行时读 config.url ✅ 重新生成即生效
copyright_url(每篇 front-matter) 文章里的字面量 ❌ 永远不会变
robots.txt 的 Sitemap: 行 源文件里的手写字符串 ❌ 永远不会变
about 页、友链、正文里的绝对链接 手写 ❌ 永远不会变
部署脚本里拼的地址 代码里的字面量 ❌ 永远不会变

所以「改了 config 就完事」是错的。真正容易出事的是第三类:写死在内容里的绝对地址。

而且这里有个更隐蔽的坑:不止一处硬编码。当时博客每天有一个自动写文章的脚本,它会给新文章写 front-matter:

1
copyright_url: https://blog.demo.fan/posts/xxxx/

这个地址是脚本里拼的字符串常量。结果就是——config 改完了,sitemap 也指向新域了,但每天新增的文章还在把旧域名写回 front-matter。如果不修脚本,几周后全站又会有一批旧域链接。

判定标准:任何「要拼站点绝对地址」的脚本,都必须从 _config.yml 读 url:,不允许硬编码。这是单一真源原则,和下面要说的顺序原则一样重要。

六步迁移清单

按顺序执行,每一步都有验证方式。

第 0 步(最重要):先确认新域名已经能打开站点

顺序不能颠倒。 如果先改 config.url、而新域名还没绑定好,会有一段窗口期:canonical、sitemap、og:url 全部指向一个 NXDOMAIN 或 403 的地址。搜索引擎此时来抓,看到的是「所有页面都声明自己的正版地址不存在」——这在 SEO 上是明确的负面信号。

正确顺序:

1
新域名解析完成 → 能打开站点 → 改 config.url → 重新构建部署

第 1 步:改 _config.yml 的 url

1
2
3
# URL
url: https://blog.demohappy.top
root: /

注意 subtitle 如果也写了域名,要单独判断要不要改(它会影响部分主题的 <title> 拼接)。这个字段建议显式决定,不要顺带改掉,否则首页标题会意外变化。

第 2 步:改 source/robots.txt 的 Sitemap 行

1
2
3
4
User-agent: *
Allow: /

Sitemap: https://blog.demohappy.top/sitemap.xml

这行不跟着 config 走,必须手改。改完检查站点根部的 /robots.txt 确实返回了新域名——有些托管平台会覆盖这个文件,需要单独确认。

1
grep -rl "blog.demo.fan" source/_posts/*.md | wc -l

如果输出不是 0,写个脚本批量替换。关键约束是只改结构性引用,不碰正文:正文里可能正写着「本站曾用域名 blog.demo.fan」这类历史叙述,改了就把历史篡改成了错误信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import re
from pathlib import Path

OLD, NEW = "blog.demo.fan", "blog.demohappy.top"

for p in Path("source/_posts").glob("*.md"):
text = p.read_text(encoding="utf-8")
# 只替换以 copyright_url: 开头的那一行
new_text, n = re.subn(
r"^copyright_url:\s*\S*" + re.escape(OLD) + r"\S*$",
f"copyright_url: https://{NEW}/",
text,
flags=re.M,
)
if n:
p.write_text(new_text, encoding="utf-8")
print(f"{p.name}: {n} 处")

第 4 步:修掉生成脚本里的硬编码

找出所有会写文件、且包含域名的地方:

1
grep -rn "blog\.demo\.fan" tools/ .workbuddy/scripts/ 2>/dev/null

把里面的字面量换成从配置读取:

1
2
3
4
5
6
7
8
def _site_url() -> str:
"""站点根地址唯一真源:_config.yml 的 url:"""
cfg = Path(__file__).resolve().parents[2] / "_config.yml"
for line in cfg.read_text(encoding="utf-8").splitlines():
m = re.match(r"^url:\s*(\S+)", line)
if m:
return m.group(1).rstrip("/")
raise SystemExit("_config.yml 里没找到 url:")

这样下次换域名,只要改 _config.yml 一处。

第 5 步:hexo clean 再构建

这一步不能省。hexo g 是增量构建,不会删除已经不存在页面的旧产物。如果之前调整过分页数量、或者改过永久链接格式,public/ 里会留下一批孤儿页面——它们的 canonical 指向旧域,但依然会被部署上去。

1
npx hexo clean && npx hexo g

第 6 步:全站核对

构建完,直接在产物里搜旧域名:

1
grep -rl "blog.demo.fan" public/ | wc -l

要的结果是 0。 如果不为 0,看是哪些文件:

1
grep -rl "blog.demo.fan" public/ | head -20

通常是三类:漏改的 front-matter、某个手写页面、或者主题模板里藏的默认值。

迁移后的收尾

新域名上线只是开始,还要处理旧域名的尾巴:

给旧域做 301 跳转。 旧域名在搜索引擎里可能已经有索引和少量外链,直接弃用会浪费掉。用 Cloudflare Redirect Rule 之类的规则把 旧域/* 永久跳转到 新域/$1。不要用 302——302 不传递权重。

在 Google Search Console 里提交新域名。 新域名需要单独验证、单独提交 sitemap。如果旧域名也验证过,可以在 GSC 里用「地址更改」工具声明迁移,加速权重转移。

旧域的 sitemap 要撤掉。 否则搜索引擎会持续从旧域发现内容,而你希望流量都到新域。

顺序原则收口

整个迁移过程中真正会出错的地方,都不是技术难点,而是顺序:

  1. 新域能访问 之前,不要改 config.url
  2. 硬编码的脚本 不修,每天的新内容都会把旧域写回来
  3. hexo clean 不跑,孤儿页面会带着旧 canonical 一起上线

三条都是「看起来做了、其实没做干净」的类型。验证方式统一为一句 grep——产物里旧域名出现次数必须是 0。


这次迁移的完整执行记录在 记录一次博客主域名迁移的全过程,迁移脚本本身在仓库的 tools/migrate_domain.py,幂等,可以反复跑。