旧的博客托管在 Vercel 上,速度和便利性都不错,尤其对我这种一丁点钱都不愿付的用户来说。
我在迁移之后没有立马关掉 Vercel 服务,因为旧博客上还有许多笔记内容没搬过来,而且以前在各处留下了笔记链接,万一还有人查看,就会碰上令人沮丧的 404。
比 404 page 更沮丧的是满屏白色的 404 page。
从 Vercel 文档中,我找到了一种方法,可以通过 vercel.json 文件定义站点的重定向逻辑。我只要把原有的博客链接重定向到对应的新链接,而还未迁移的笔记就直接重定向到本站首页,这样人们就不会迷路了。
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"redirects": [
{ "source": "/me", "destination": "/profile.html", "permanent": false }
]
}
为此,我需要获得所有旧 URL 和对应的新 URL。
旧站点使用 Material for MkDocs 的内置博客插件,它有某种 URL 生成逻辑,我没有心情去摸清楚所有的规则再从文章标题重新生成。
我的思路是:既然旧站点还活着,那就可以发送 HTTP 请求,获得博客归档页面 HTML,从中解析出每条文章的链接。实际上,根本不需要任何爬虫框架,cURL 和 ripgrep 就足够了:
$ curl -s https://13m0n4de.vercel.app/blog/archive/{2024,2025,2026}.html \
| rg '"toclink" href="\.\./(\d+/\d+/\d+/.*)"' -o -r 'https://13m0n4de.vercel.app/blog/$1'
/blog/2024/12/06/ricing-my-arch-linux.html
/blog/2024/11/09/coffee-and-random-thoughts-hackergame-2024-.html
/blog/2024/06/16/hackerrank-in-racket-part-3.html
/blog/2024/06/13/hackerrank-in-racket-part-2.html
/blog/2024/06/11/hackerrank-in-racket-part-1.html
/blog/2024/05/23/wireworld-simulator-using-the-raylib-part2.html
/blog/2024/05/20/wireworld-simulator-using-the-raylib-part1.html
/blog/2024/05/12/bad-apple-on-lemoncore.html
/blog/2024/04/07/search-engines-in-2024-will-spaces-still-matter.html
/blog/2024/02/25/i-need-a-done-list-not-a-todo-list.html
/blog/2025/11/06/using-djot-instead-of-markdown-in-mkdocs.html
/blog/2025/10/23/functional-pipeline-programming-in-python.html
/blog/2025/07/20/markdown-to-pdf-with-pandoc-and-typst.html
/blog/2025/05/28/refactor-my-quiz-app.html
/blog/2025/04/01/malware-analysis-powerghost.html
/blog/2025/01/30/cve-roulette-1-pngcheck.html
/blog/2026/02/28/saya-lang-loop-break-value.html
/blog/2026/01/23/hello-saya-lang.html
新博客的 URL 等同于 https://13m0n4de.pages.dev/posts/ + 文件名称,博客迁移时没修改过文件名,所以列出文件名,用 sed 替换再替换就可以得到新的 URL:
$ ls posts/ | sed -E 's/\.(md|dj)$//' | sed 's#^#https://13m0n4de.pages.dev/posts/#' https://13m0n4de.pages.dev/posts/bad_apple_on_lemon_core https://13m0n4de.pages.dev/posts/cve_roulette_pngcheck https://13m0n4de.pages.dev/posts/exploring_the_hare_part1 https://13m0n4de.pages.dev/posts/functional_pipeline_programming_in_python https://13m0n4de.pages.dev/posts/hackergame2024 https://13m0n4de.pages.dev/posts/hackerrank_in_racket_part1 https://13m0n4de.pages.dev/posts/hackerrank_in_racket_part2 https://13m0n4de.pages.dev/posts/hackerrank_in_racket_part3 https://13m0n4de.pages.dev/posts/hello_saya_lang https://13m0n4de.pages.dev/posts/i_need_a_done_list_not_a_todo_list https://13m0n4de.pages.dev/posts/including_c_files_over_https https://13m0n4de.pages.dev/posts/malware_analysis_powerghost https://13m0n4de.pages.dev/posts/markdown_to_pdf_with_pandoc_and_typst https://13m0n4de.pages.dev/posts/refactor_my_quiz_app https://13m0n4de.pages.dev/posts/replacing_markdown_with_djot_in_mkdocs https://13m0n4de.pages.dev/posts/ricing_my_arch_linux https://13m0n4de.pages.dev/posts/saya_lang_loop_break_value https://13m0n4de.pages.dev/posts/search_engines_in_2024_will_spaces_still_matter https://13m0n4de.pages.dev/posts/wireworld_simulator_using_the_raylib_part1 https://13m0n4de.pages.dev/posts/wireworld_simulator_using_the_raylib_part2
甚至,还能把它们管道到 httpx 批量验证有效性:
$ ls posts/ | sed -E 's/\.(md|dj)$//' | sed 's#^#https://13m0n4de.pages.dev/posts/#' | httpx -sc https://13m0n4de.pages.dev/posts/hackerrank_in_racket_part1 [200] https://13m0n4de.pages.dev/posts/refactor_my_quiz_app [200] https://13m0n4de.pages.dev/posts/wireworld_simulator_using_the_raylib_part1 [200] https://13m0n4de.pages.dev/posts/hackergame2024 [200] https://13m0n4de.pages.dev/posts/search_engines_in_2024_will_spaces_still_matter [200] https://13m0n4de.pages.dev/posts/cve_roulette_pngcheck [200] https://13m0n4de.pages.dev/posts/hackerrank_in_racket_part3 [200] https://13m0n4de.pages.dev/posts/saya_lang_loop_break_value [200] https://13m0n4de.pages.dev/posts/i_need_a_done_list_not_a_todo_list [200] https://13m0n4de.pages.dev/posts/ricing_my_arch_linux [200] https://13m0n4de.pages.dev/posts/bad_apple_on_lemon_core [200] https://13m0n4de.pages.dev/posts/markdown_to_pdf_with_pandoc_and_typst [200] https://13m0n4de.pages.dev/posts/hackerrank_in_racket_part2 [200] https://13m0n4de.pages.dev/posts/wireworld_simulator_using_the_raylib_part2 [200] https://13m0n4de.pages.dev/posts/functional_pipeline_programming_in_python [200] https://13m0n4de.pages.dev/posts/hello_saya_lang [200] https://13m0n4de.pages.dev/posts/exploring_the_hare_part1 [200] https://13m0n4de.pages.dev/posts/replacing_markdown_with_djot_in_mkdocs [200] https://13m0n4de.pages.dev/posts/malware_analysis_powerghost [200] https://13m0n4de.pages.dev/posts/including_c_files_over_https [200]
有了两者的 URL,最后就是将它们对应起来。比如说,与 hackerrank-in-racket-part-1.html 最为相似的是 hackerrank_in_racket_part1,所以你应该去到这里,以肉眼判断的话。
我知道我知道……看上去就只是连字符和下划线区别而已,直接应用某些替换规则就能进行字符串比较了。但是,还会有更复杂的情况,直到某个例子逼着我重新写出 URL 生成逻辑,这就又绕回去了。
我想的办法是计算相似性,算法不重要(它们是比较“规整”的数据,各自区别也都足够大),只要足够自动化、放过我视疲劳的双眼就行了。
如果坚持命令行管道,那么 fzf 算是个办法,但命令最后肯定不是串联的,会复杂到需要写一个小脚本。
而如果非要写脚本,直接写出一个 Python 脚本生成 vercel.json 内容岂不更好:
import difflib import json with open("old.txt") as o, open("new.txt") as n: old = o.readlines() new = n.readlines() redirects = [] for url in old: m = difflib.get_close_matches(url, new, n=1, cutoff=0.3) redirects.append( {"source": url.rstrip(), "destination": m[0].rstrip(), "permanent": True} ) redirects.append( {"source": "/(.*)", "destination": "https://13m0n4de.pages.dev/", "permanent": True} ) vercel = {"$schema": "https://openapi.vercel.sh/vercel.json", "redirects": redirects} print(json.dumps(vercel))
或者,还可以试一下纯列表推导式风格:
print( __import__("json").dumps( { "$schema": "https://openapi.vercel.sh/vercel.json", "redirects": [ { "source": url.rstrip(), "destination": __import__("difflib") .get_close_matches(url, new, n=1, cutoff=0.3) .pop() .rstrip(), "permanent": True, } for old in [open("old.txt")] for new in [open("new.txt").readlines()] for url in old ] + [ { "source": "/(.*)", "destination": "https://13m0n4de.pages.dev/", "permanent": True, } ], } ) )
好吧,也就一般酷,有人给我捧哏就会显得更酷一些。
最后的最后将 vercel.json 放在 docs/ 里,重新发布一次旧网站,Vercel 的重定向规则就生效了。
也许您从旧网站跳转而来,点开了这篇文章,现在您知道发生什么事了。