曾经我遇到过这样一件事情:网站在经历重构后,搜索引擎上的索引页面和网站的路径不一样。比方说,我希望新的网站使用 /friends/ 作为友链路径,但索引在搜索引擎上的旧网站链接却是 /links/,它们有个共同点就是都叫“友链”,但访客点进来后却一头雾水。还有一件事:网站从 Vercel 换到 Cloudflare Pages 后,因为本地生成文章的时区和搜索引擎上的不一样,所以路径也不一样。比方说,我有一篇文章从 4 月 9 日变成了 4 月 10 日,但之前的路径还是 4 月 9 日。

路径纠错实际上是一个很狭窄、但是关键时刻又急需的功能。有了它,就可以解决以上的问题,做到不浪费搜索引擎的 SEO 索引。流程是这样的:当你访问进入错误的路径时,浏览器会保持那个 URL,同时页面被替换为我们预设好的 /404.html;如果检测到当前是 404 页面,而不是主动访问的 /404 又或者 /404.html 的 404,则触发路径纠错功能;获取访客当前访问错误的地址,通过浏览器自带的语义分析方法,比对网站的 sitemap.txt 列表,选择可能性最高的那个路径:

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
(async function () {
const container = document.querySelector('.error-content');
if (!container) return;

const currentPath = window.location.pathname;
if (currentPath === '/404' || currentPath === '/404.html') return;

await tryRoute(currentPath);
})();

async function tryRoute(targetPath) {
try {
const response = await fetch('/sitemap.txt');
if (!response.ok) return;

const text = await response.text();
const urls = text.split('\n')
.map(line => line.trim())
.filter(Boolean);

if (urls.length === 0) return;

const sitemapPaths = urls.map(url => {
try {
return new URL(url).pathname;
} catch {
return url;
}
});

const bestPath = findBestMatch(targetPath, sitemapPaths);
if (!bestPath) return;

const bestIndex = sitemapPaths.indexOf(bestPath);
let bestUrl = urls[bestIndex];

if (bestUrl.endsWith('/index.html')) {
bestUrl = bestUrl.slice(0, -'index.html'.length);
}

btf.snackbarShow('未找到页面,五秒后跳转至正确路径……');

setTimeout(() => {
window.location.href = bestUrl;
}, 5000);
} catch (e) {}
}

window.error_location = async function (wrongPath) {
if (!wrongPath || typeof wrongPath !== 'string') return;
await tryRoute(wrongPath);
};

function findBestMatch(target, candidates) {
const targetSegs = decodeURIComponent(target)
.toLowerCase()
.split('/')
.filter(Boolean);

let bestScore = Infinity;
let bestCandidate = candidates[0];

for (const candidate of candidates) {
const candSegs = decodeURIComponent(candidate)
.toLowerCase()
.split('/')
.filter(Boolean);

const minLen = Math.min(targetSegs.length, candSegs.length);
let totalDist = 0;
let totalLen = 0;

for (let i = 0; i < minLen; i++) {
const d = levenshtein(targetSegs[i], candSegs[i]);
totalDist += d;
totalLen += Math.max(targetSegs[i].length, candSegs[i].length);
}

const extraSegs = Math.abs(targetSegs.length - candSegs.length);
const penalty = extraSegs * 3;
totalDist += penalty;
totalLen += penalty;

const score = totalLen > 0 ? totalDist / totalLen : 0;

if (score < bestScore) {
bestScore = score;
bestCandidate = candidate;
}
}

return bestCandidate;
}

function levenshtein(a, b) {
const m = a.length;
const n = b.length;

if (m === 0) return n;
if (n === 0) return m;

let prev = Array.from({ length: n + 1 }, (_, j) => j);
let curr = new Array(n + 1);

for (let i = 1; i <= m; i++) {
curr[0] = i;
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(
curr[j - 1] + 1,
prev[j] + 1,
prev[j - 1] + cost
);
}
[prev, curr] = [curr, prev];
}

return prev[n];
}

以上就是最终的代码,我说一下我是怎么想到的,以及其中要避开的坑。一开始我只是在 DeepSeek TUI 里要求只需实现上述功能即可,但是我很快发现,在本地浏览模式,我无法触发路径 404 功能。索性我就让 DeepSeek 写了一个调试函数 error_location(),参数为字符串,专门用于调试功能。第一次调试就出了问题,当我的期望是通过字符串 /tg 进入 /tags/ 时,结果返回却是 /tags/梦/。后来我让 AI 加了路径层级检测,才解决了这个问题。

随之而来的又是一个新问题:Hexo 的 _config.yml 默认设置了路径统一为不需要 /index.html 的版本,但有意思的是,hexo-generator-sitemap 在生成网站地图的时候,会生成带 index.html 版本的。这意味着如果我们的寻址方式是通过 sitemap.txt 进行的,则需要在路径里判断是否减去 index.html,否则访客会跳转到一个和预期页面差不多,但是没有评论、数据、记录的页面,因为 Waline 评论系统默认记录的是路径,而非文章标题。

需要注意的是,Butterfly 的 404 页面的容器为 .error-content 而非 #error-content。如果写错了样式匹配,则脚本不会生效。脚本生效之后,访客如果通过搜索引擎访问到错误的页面,会被前端算法自动比对,在五秒之内为访客匹配到正确页面——得益于浏览器的语义匹配功能。举一个相对离谱一点的例子,如果搜索引擎上的路经为 /links/,访客点进来会被索引到 /friends/;如果搜索引擎上的路径为 4 月 9 号,点进来会被索引到 4 月 10 号。

以至于如果你改过标题,这个脚本都能选取相对正确的标题路径,为访客进行跳转,可谓是相当贴心了。上文说到 sitemap.txt 中的文章路径为 /index.html 版本的,而这个 404 跳转请求的脚本仅仅对 404 页面生效,那么我们就需要一个单独的脚本,来完成这件事情。我们来探讨一下算法:当访客从搜索引擎点击、进入这篇文章之后,脚本会检查浏览器路径中是否有 index.html;如存在,则使用 fetch() 检查是否有无 index.html 路径,有则跳转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(async function () {
const currentPath = window.location.pathname;

if (!currentPath.endsWith('/index.html')) return;

const cleanPath = currentPath.slice(0, -'index.html'.length) || '/';

try {
const response = await fetch(cleanPath, { method: 'HEAD' });
if (response.ok) {
window.location.replace(cleanPath);
}
} catch (e) {}
})();

值得一提的是,这两个 JS 脚本不需要单独为其适配 PJAX(如果你开启了 PJAX 的话),因为它们的运行情况被预设在访问网站的时候,此时的浏览器会加载并运行 JS 脚本(当然,你肯定不会为了偷懒,把可点击的导航栏路径,改成这种错误路径)。接下来,我就需要把这些 JS 脚本,全部引入 _config.butterfly.ymlinject 块段,确保这些脚本的功能可以正常运行(我想起来以前我没有导入这些文件,导致代码写完了还抱怨 AI 给的代码不能运行):

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
inject:
head:
# - <link rel="stylesheet" href="/xxx.css">
- <link rel="stylesheet" href="/assets/css/transparent.css">
- <link rel="stylesheet" href="/assets/css/fonts.css">
- <link rel="stylesheet" href="/assets/css/svg.css">
- <link rel="stylesheet" href="/assets/css/pagination.css">
- <link rel="stylesheet" href="/assets/css/social.css">
- <link rel="stylesheet" href="/assets/css/align.css">
- <link rel="stylesheet" href="/assets/css/snackbar.css">
- <link rel="stylesheet" href="/assets/css/meta.css">
- <link rel="stylesheet" href="/assets/css/spacing.css">
- <link rel="stylesheet" href="/assets/css/paragraph.css">
- <link rel="stylesheet" href="/assets/css/proportion.css">
bottom:
# - <script src="xxxx"></script>
- <script src="/assets/js/stroll.js"></script>
- <script src="/assets/js/blindbox.js"></script>
- <script src="/assets/js/mourn.js"></script>
- <script src="/assets/js/reminder.js"></script>
- <script src="/assets/js/friends.js"></script>
- <script src="/assets/js/avatar.js"></script>
- <script src="/assets/js/music.js"></script>
- <script src="/assets/js/height.js"></script>
- <script src="/assets/js/routing.js"></script>
- <script src="/assets/js/fix.js"></script>