写在前面(这次和以往不太一样)

  1. 这篇不是搭建教程,是一次老站的体检和维修记录。如果你的博客也闷头跑了三四年没管过,大概率能对号入座。
  2. 全文操作都在博客根目录下进行,下文统一用 [BlogRoot] 指代,牵涉到改源码的地方我都写到了文件和行号。
  3. 这次全程是 DeepSeek Harness 陪我做的:我出需求、做判断、拍板要不要动;它去翻文件、探测 CDN 存活、读源码、改代码、跑构建验证。所以这篇也可以当成一份「AI 到底能帮上多少忙」的实录 —— 结论是先卖个关子,看到最后。

博客搭建与魔改系列教程导航🚥🚥🚥

  1. 🥬Hexo博客搭建基础教程(一)
  2. 🍒Hexo博客搭建基础教程(二)
  3. 🥪Hexo博客搭建基础教程(三)
  4. 🍀博客魔改教程总结(一)
  5. 🍚博客魔改教程总结(二)
  6. 🎋博客魔改教程总结(三)
  7. 🥕博客魔改教程总结(四)
  8. 🍊博客魔改教程总结(五)
  9. 🧄博客魔改教程总结(六)
  10. 🎨综合美化模块教程
  11. 🍬通过DeepSeek Harness修复和优化网站 ⇦当前位置🪂

一、起因:红字不是那天才有的

先把一个容易被误会的说法纠正掉:这不是「某天随手按了下 F12,才发现控制台红了一片」的故事。

这些报错我早就知道。

三四年跑下来,站里依赖的那些外部资源是一件一件慢慢死掉的。它们很守规矩,从来不一起罢工:某段时间是某个 CDN 开始抽风,过一阵换成某个图床连不上,再往后是某个公共接口悄悄停了服。死一个,页面上就少一样东西 —— 某个图标不显示、某张图变成裂图、某个小模块空着。文章照样能读,页面照样能开。

我也不是没看见。F12 按过很多次,每次都想「下次一起修」,然后下次又变成了下次。坏掉的东西只要不影响你看文章,就永远排在待办清单的最后一条。

真正让我下决心的是发现死掉的东西已经从边角蔓延到了骨架。那天我本来只是想看看某个 CSS 改动的效果,随手按了下 F12,看到的就是这些:

一眼扫过去,大概三类毛病:

  • 51la 的统计脚本:blocked by CORS policy,接着是 403
  • TianliCDN:net::ERR_CONNECTION_CLOSED 连着三条
  • 字节跳动静态资源库:直接 404

底下还有几条 JS 报错更扎眼:

1
2
3
4
5
Uncaught ReferenceError: $ is not defined
at fomal.js:72:1

Uncaught ReferenceError: LazyLoad is not defined
at lazyLoadImg (main.js:717:35)

说白了就是:该加载的没加载,加载了的又用不上。

一边是「还能用」,一边是「再拖下去就是给未来的自己挖坑」—— 这些红字存不存在,取决于你今天开不开 F12,但损失是实实在在的:样式丢一半、脚本半死不活、统计全废、某个模块永远空着。

所以那天我没有像往常一样把 F12 关掉了事,而是想:要不,一次修完?

刚好那阵子我在重度使用 DeepSeek Harness,于是顺手把这事丢给了它 —— 我描述症状,它去翻文件。

二、第一轮:先把「哪些东西真的死了」搞清楚

修之前得先知道坏了多少。

这里有个坑值得单独说:光看浏览器报错是不够的。浏览器只会告诉你「我这次没拿到」,但不会告诉你「这东西是不是早就没人维护了」。有些 CDN 你本地挂了是因为你自己的网络,有些是真死了。

所以第一件事是挨个探测:把首页要加载的第三方资源全列出来,逐个发请求,看状态码、看响应字节、看首字节时间。

结果比我想的惨:

资源症状结论
cdn1.tianli0.topERR_CONNECTION_CLOSED × 3域名已停服
cdn.staticfile.org能访问,但解析到 202.181.25.22香港节点,大陆访问绕路
cdn.jsdelivr.net104.17.207.5,TTFB 520ms ~ 1.8sCloudflare,时快时慢
cdnjs.cloudflare.comMathJax 从这加载国内基本不通
sdk.51.la403 + 本地 CORS接口变了
lf3-cdn-tos.bytecdntp.com404已下线

看到第三行的时候我愣了一下 —— staticfile 我一直以为是国内的。查了下 IP 归属才发现是香港,难怪有时候打开慢,原来一直在绕。

第一轮的原则定得很简单:

  1. 能换公共库的都换成公共库,优先国内源;
  2. 尽量收敛到同几个域名,这样浏览器能复用连接,还能提前 preconnect;
  3. 实在找不到源的就本地化,塞进 source/js/ 里自托管。

这一轮一共动了 14 处,分布在:

  • [BlogRoot]/_config.butterfly.yml —— element-ui、jQuery、Vue、clipboard、sweetalert2、vanilla-lazyload、instant.page、typed.js、FontAwesome、APlayer、Meting 等
  • [BlogRoot]/source/social/fcircle/index.md —— 友链朋友圈用的两个 JS-Heo 脚本
  • [BlogRoot]/source/_data/link.yml —— 友链里的两张图

其中 winbox 这个库比较特殊,全网的国内镜像都没收它,最后只能把 15KB 的文件下载下来放进 source/js/winbox.bundle.min.js 自己托管。

改配置之前先备份。我这次是复制了一份到 [BlogRoot]/bak/20260930-cdn-fix/,后面的所有改动也都各自留了 .before 备份。魔改这种事,没有备份就是在赌运气。

三、换源路上踩的几个坑

这段单拎出来说,因为国内镜像站的路子和 jsDelivr 不一样,直接照搬 URL 大概率是 404。

3.1 npmmirror 不是每个包都有 .min

registry.npmmirror.com 的路径规则是 /包名/版本/files/包内相对路径。听起来很直观,但:

  • algoliasearch@4.14.3 没有 .min 版本,只有 files/dist/algoliasearch-lite.umd.js
  • @fancyapps/ui@4.0.31 同理,只有 files/dist/fancybox.umd.js 和 files/dist/fancybox.css
  • typed.js@2.0.12 的主文件在 files/lib/typed.min.js,不在 dist/ 下

硬套 .min 后缀的结果是返回一段 JSON:

1
2
3
{
"error": "[NOT_FOUND] File algoliasearch@4.14.3/dist/algoliasearch-lite.umd.min.js"
}

3.2 lib.baomitu 的「假 200」

这个更阴。360 的 lib.baomitu.com 遇到不存在的路径,返回的是 HTTP 200,正文只有 14 个字节:

1
404: Not Found

也就是说,如果你只看状态码,会以为一切正常。

所以判断一个源到底可不可用,必须同时看状态码、字节数和内容,三个都对上才算数。这一条我后来在探测 leaflet 的时候又用上了。

3.3 同一个库,镜像之间路径不一样

MathJax 3.2.2 在两个源上的文件形态就差很多:

源路径大小首字节
npmmirror/mathjax/3.2.2/files/es5/tex-mml-chtml.js1146 KB(未压缩)352 ms
360 baomitu/mathjax/3.2.2/es5/tex-mml-chtml.min.js1114 KB281 ms

只差 40KB,最后我选了 npmmirror。原因不是它更快,而是想统一域名 —— 站上已经有一堆资源在 npmmirror 了,preconnect 建一次连接就能全用上,省掉一次 DNS + TLS。这种账要算总账,不能单看一个文件。

四、「这些源都是国内的么?」——然后有了第二份体检报告

第一轮换完,我盯着配置文件看了半天,突然想到一个问题:我换的这些都是国内的么?国内访问到底快多少?

这问题问得挺好,因为它逼出了一份真正有价值的报告。

做法是把首页要加载的第三方资源全抓出来,统计三件事:域名解析到哪、首字节多少、总共多大。

部分结果:

域名IP 归属实测 TTFB
registry.npmmirror.com1.194.31.54(河南)37 ~ 105 ms
npm.elemecdn.com183.61.231.x(广东)40 ~ 105 ms
cdn.jsdmirror.com113.240.66.249 等43 ms
lib.baomitu.com240e 段(中国电信 IPv6)48 ms
sdk.51.la27.159.90.74 等122 ms
cdn.staticfile.org202.181.25.22(香港)203 ~ 356 ms
cdn.jsdelivr.net104.17.207.5(Cloudflare)520 ms ~ 1.8 s

换源的效果是实打实的:typed.js 从 391ms 掉到 40ms。

但报告里最扎心的是最后一行:

资源TTFB总耗时
www.fomal.cc 首页(208 KB)693 ms911 ms
source.fomal.cc 一张 35 KB 的图1041 ms—

我自己站点的 TTFB,比所有第三方 CDN 都慢。 换源能省的也就几百毫秒,真正的天花板在部署侧。这个后面单独开一篇再聊,Vercel 的国内访问优化又是另一个坑了。

也正是这份报告,让我发现了真正的"体积刺客"。

五、重头戏:905KB 的 Vue + Element-UI,只为了弹几个提示框

报告里有一行数据特别刺眼:

1
2
3
4
5
6
首屏第三方资源 33 个 URL,共 2.72 MB
npmmirror 1.25 MB
staticfile 1.01 MB ← 走香港的
google 0.20 MB
elemecdn 0.16 MB
...

拆开 staticfile 那 1.01MB,是三个东西:

文件大小
element-ui JS(index.js)577 KB
element-ui CSS(index.css)236 KB
Vue 2.6.1492 KB

加起来 905 KB。

而我的站点用 Vue 干了什么呢?我把 fomal.js 从头到尾看了一遍,所有 Vue 用法长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
new Vue({
data: function () {
this.$notify({
title: "复制成功🍬",
message: "内容已复制到剪贴板",
position: "top-left",
offset: 50,
showClose: true,
type: "success",
duration: 3000,
});
},
});

全文只有 14 处这样的代码,全是右下角/左上角冒出来的那个小提示框。 参数形态一模一样,没有 v-if、没有 v-for、没有模板、没有路由、没有一个组件。

905 KB 换来 14 个 Toast。

当时我的表情大概是:???

那为什么一直没删

因为当年抄过来的时候,教程都这么写。Element-UI 的 Notification 确实好用,一句 this.$notify() 就弹出来了,谁管它多大呢。

而且删起来有心理障碍:站点里到处是 .el- 开头的 class,看着就像"离不开它"。

结果一查:

  • themes/butterfly/ 的 layout 里,没有一处 el- class
  • source/js/ 里也没有
  • custom.css 里的 .el-* 规则,几乎全部包在 #twikoo / .twikoo 作用域内

而 Twikoo 1.6.17 的发行版自带一副 Element-UI 样式(我去包里翻过,.el-textarea__inner、.el-button--primary、.el-input__inner、el-loading-spinner 全都在)。

也就是说:评论框的样式是 Twikoo 自己带的,跟全局那份 Element-UI 一点关系都没有。

那就删。

手写一个 7KB 的通知组件

弹窗不能不管,所以抄一份最小的实现出来。新文件 [BlogRoot]/source/js/notify.js,7150 字节,零依赖原生 JS:

1
2
3
4
5
6
7
8
fomalNotify({
title: "复制成功🍬",
message: "内容已复制到剪贴板",
position: "top-left",
offset: 50,
type: "success",
duration: 3000,
});

内部做了这些事:

  • 四种类型 success / warning / info / error,图标是内联 SVG,配色沿用 Element-UI 那套(#67c23a / #e6a23c / #f56c6c / #909399)
  • 六种位置,按 位置 + offset 缓存容器,同一个位置的通知自动堆叠
  • 进出场动画用 requestAnimationFrame 加 .is-enter 类控制,关闭时加 .is-leave 后 320ms 移除节点
  • 鼠标悬停暂停计时,移开继续
  • 容器 isConnected 检查,兼容 pjax 局部刷新

然后把 fomal.js 里那 14 处整个换掉:

1
2
3
4
5
6
- new Vue({
- data: function () {
- this.$notify({ title: "复制成功🍬", message: "...", type: "success" })
- }
- })
+ fomalNotify({ title: "复制成功🍬", message: "...", type: "success" })

再回到 _config.butterfly.yml,把三行注释掉:

1
2
3
- <link rel="stylesheet" href="https://.../element-ui@2.15.6/lib/theme-chalk/index.css">
- <script src="https://.../vue@2.6.14/dist/vue.min.js"></script>
- <script src="https://.../element-ui@2.15.6/lib/index.js"></script>

905 KB → 7 KB。

顺带说一个细节:Element-UI 的 CSS 原来挂在 inject.head 里,是同步阻塞渲染的 —— 也就是说那 236KB 没下完,页面就是白屏。现在也没了。

六、MathJax 背了三年的锅

我写算法题解那阵子一直纳闷:有几篇带公式的文章,公式就是显示不出来,全是红字或者原始 LaTeX。

当时以为是 Markdown 语法写错了,改了又改,没用。

这次顺手查了一下,真相挺无语的。

_config.butterfly.yml 里有这么一段:

1
2
3
mathjax:
enable: true
cdn: https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js

看起来没毛病对吧?问题是 —— 主题压根不读这个字段。

Butterfly 4.3.1 实际的取值路径是:

1
2
3
4
5
theme.asset.mathjax  ←  themes/butterfly/scripts/events/cdn.js 生成
↑
themes/butterfly/plugins.yml 里 mathjax 项
↑
默认 provider = cdnjs

所以最终产物里写的是 https://cdnjs.cloudflare.com/ajax/libs/mathjax/...,而 cdnjs.cloudflare.com 国内基本不通。

182 个页面里有 35 个加载了 MathJax,35 个全挂。

修法反而简单,让配置文件里的字段真正生效就行:

1
+ mathjax: https://registry.npmmirror.com/mathjax/3.2.2/files/es5/tex-mml-chtml.js

写进 _config.butterfly.yml 的 CDN.option 段之后,构建产物里 35 个页面全部指向 npmmirror。

这个坑我记得最清楚 —— 因为它不是"坏了",而是"你以为它开着,其实从来就没开过"。 这类问题最难发现,因为没有任何报错,只是东西不出现。

七、意外收获:hexo-tag-map 把路径写死在了 node_modules 里

修完上面这些,我以为完事了。结果在扫"还有没有漏网的境外域名"时,又揪出来一个。

地图标签用的是 hexo-tag-map 插件,它加载 leaflet 的方式是写死的:

1
2
3
4
// node_modules/hexo-tag-map/index.js 第 14-18 行
const css_text = `<link rel="stylesheet" href="/js/tag-map/leaflet-1.7.1.css">`;
const js_text = `<script data-pjax src="/js/tag-map/leaflet-1.7.1.js"></script>`;
// 还有 ChineseTmsProviders、proj4、proj4leaflet

五个文件全指向 cdn.jsdelivr.net,也就是 Cloudflare。

改 node_modules 是最直接的办法,但下次 npm i 就没了,这种改法属于给自己埋雷。

我试了下国内镜像,都不太顺:

  • npmmirror 对 hexo-tag-map 直接返回 403(这个包被屏蔽了)
  • cdn.jsdmirror.com 三个文件都能拿到,但首字节 839 ~ 1142ms,比 jsDelivr 还慢
  • lib.baomitu.com 只有 leaflet 本体,中文地图源和 proj4 都没有

那就自托管吧 —— 还能比自托管更快的东西不存在,除非你不加载。

1
2
3
4
5
6
source/js/tag-map/
├── leaflet-1.7.1.css (13,920 B)
├── leaflet-1.7.1.js (141,941 B)
├── leaflet.ChineseTmsProviders-1.0.4.js (4,330 B)
├── proj4-2.4.3.js (71,123 B)
└── proj4leaflet-1.0.1.min.js (4,051 B)

文件是从 node_modules/hexo-tag-map/lib/ 原样复制的,只把文件名里的 @ 换成了 -(@ 在 URL 里要转义,麻烦)。

然后写了个 Hexo 插件放 [BlogRoot]/scripts/tag-map-local.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const CDN_PREFIX = "https://cdn.jsdelivr.net/npm/hexo-tag-map/lib/";
const LOCAL_PREFIX = "/js/tag-map/";

const FILE_MAP = {
"leaflet@1.7.1.css": "leaflet-1.7.1.css",
"leaflet@1.7.1.js": "leaflet-1.7.1.js",
// ...
};

hexo.extend.filter.register("after_render:html", function (html) {
if (html.indexOf(CDN_PREFIX) === -1) return html;
for (const src of Object.keys(FILE_MAP)) {
html = html.split(CDN_PREFIX + src).join(LOCAL_PREFIX + FILE_MAP[src]);
}
return html;
});

选 after_render:html 而不是改依赖包,是因为 scripts/ 目录会跟着仓库走,重装依赖也不会丢。

配套还要在 _config.yml 里加一行,让这五个库文件原样复制、别被 Nunjucks 渲染:

1
2
 skip_render:
+ - 'js/tag-map/*' # leaflet / proj4 第三方库原文件,原样复制,不做 Nunjucks 渲染

这里有个小插曲:_config.yml 这个文件是 CRLF 换行的,第一次我用 \n 做字符串匹配死活不中,换成 \r\n 才成功。跨平台改配置文件的时候,换行符真的要留心。

至此,全站产物里 cdn.jsdelivr.net 的引用归零。

八、以为收工了:背景图没了

改完上面那一批,我以为可以收工了。刷新页面 —— banner 一片黑,背景图没了。

8.1 先抓到的不是背景,是 Service Worker

控制台里最扎眼的是这几条:

`@
Uncaught (in promise) TypeError: Failed to convert value to ‘Response’. sw.js:1

Fetch error: http://localhost:4000/css/index.css
TypeError: Failed to convert value to ‘Response’.
at handle (sw.js:197:16)
at sw.js:15:27
`@

Failed to convert value to 'Response' 是个很好认的症状:respondWith() 收到了一个 undefined。

问题出在 themes/butterfly/source/sw.js(现已改名为 themes/fomalhaut/) —— 这是早年从别处抄来的一个「CDN 分流器」,作用是把静态资源转到对象存储去取。要命的是这一段:

@js return fetch(req).catch(error => { console.error('Fetch error:', req.url, error); }); @

catch 回调里少了一个 return。请求成功时看不出来,一旦失败这个函数就返回 undefined,紧接着 event.respondWith(undefined) 直接抛错,这个请求当场作废。

更糟的是它给所有同源请求都套了 respondWith(文件里甚至注册了两个 fetch 监听器,第二个是半成品,里面用了一个从未定义过的变量)。也就是说:那一跳只要失败,CSS、图片、脚本就可能整片加载不出来。

「背景图没了」其实只是它的连带伤害之一。

修法没什么玄学:

  • 只拦截目标域名(www.fomal.cc)的 GET 请求,其余请求直接 return,根本不调用 respondWith;
  • 分流失败先回退 return await fetch(req),再失败才返回 504 兜底;
  • 本地开发环境(localhost / 127.0.0.1)自动注销 Service Worker,免得调试时一直被缓存的版本阴。

Service Worker 真正的坑在于它是「隐形」的:注册过一次就会一直活着。哪怕你把源码删了、把注册代码去掉了,老访客的浏览器里那个旧版本还在拦截请求 —— 你改了文件,他看到的还是老逻辑。

所以改完 sw.js 之后,记得去 Application → Service Workers 里确认新版本已经接管,必要时手动 unregister 再清一下 Cache Storage。

8.2 背景图本来就是个「依赖 JS 才存在」的东西

Service Worker 修好之后再刷新 —— 还是黑的。于是顺着渲染链路一路往下查,最后定位到一行 CSS:

@css #web_bg { background: var(--default-bg); } @

--default-bg 这四个变量(还有 darkmode / mobileday / mobilenight 三个)在整个构建产物里只有使用、没有任何一处定义。唯一定义它们的地方,是 fomal.js 在运行时往这个标签里写内容:

`@html

`@

而它在配置文件里就是一个空标签。

也就是说:背景图从来不是「CSS 里的图」,而是「JS 运行时塞进去的图」。JS 一旦没跑到那一步,背景必然空白 —— 这个设计平时看不出来,出事的时候就是黑屏,而且你怎么刷新都没用。

所以第一步是把默认值直接内联进去,让「JS 全挂」和「背景还在」这两件事脱钩:

`@html

`@

这四张图我挨个测了,全部 200,最慢的一张 278ms。图一直好好地在服务器上躺着,是我们自己没去取。

8.3 真正的元凶:7 个随机壁纸 API 死了 5 个

那 JS 为什么写不出有效的背景?因为美化模块允许自定义背景,地址存在 localStorage.blogbg 里。顺着这个值往下查,挖出两个坑。

坑一:一个拼错的单词。

「恢复默认背景」按钮的代码是这样的:

@js function resetBg() { localStorage.setItem("blogbg", "deafult"); // default 拼成了 deafult resetBg_(); } @

只要点过一次,这个非法值就永久留在浏览器里:之后每次加载,判断 blogbg == 'default' 都是假,于是走自定义分支,把 --default-bg: deafult; 写进 CSS —— 一个非法值,浏览器直接忽略,背景全空。

坑二:设置面板里那些「随机壁纸」,源已经死了一大半。

我把面板里的壁纸 API 挨个测了一遍:

壁纸源实测结果
bing.img.run(必应每日 / 随机)连接失败
api.yimian.xyz8 秒超时
cdn.seovx.com连接失败
api.ixiaowai.cn/gqapi连接失败
source.unsplash.com已停服(503)
api.btstu.cn只有 http,HTTPS 站被混合内容拦掉
picsum.photos能用,但要 2.8 秒

七个里死了五个。 而当年在设置面板里点过这些按钮的人(包括我自己),浏览器里存的就是这些死链 —— 每次打开网站,都在等一张永远不会来的图。

修法是三件套:

  1. 换源:必应每日 / 随机 → bing.biturl.top,二次元图 → loliapi.com/acg,seovx → api.dujin.org/pic,unsplash 和 btstu → t.mwm.moe。实测 195~430ms 出图,全部国内可达。
  2. 写入前先探测:点了新壁纸先发一个 Image 请求试加载,加载不出来就不写进 localStorage,并弹一句「这个壁纸源失效了🥲」—— 至少让人知道为什么没反应,而不是傻等。
  3. 自愈:启动时校验 localStorage 里的值,只要它既不是 default、也不是颜色、也不像 url(...) 或 http 开头,一律按 default 处理并回写。以后就算再出现类似的脏值,用户也不用自己去清缓存。

(最开始那条 $ is not defined 也是这一轮顺带根治的,方法记在 10.3 节。)

九、战果

改完必须验证,不然等于没改。

验证方式是在临时目录里跑一次完整构建,然后扫描产物。为什么不直接在博客目录跑?因为本地的 hexo server 正占着 public/ 和 db.json,会打架。

构建结果:182 个 HTML 页面,7.6 秒,重复跑了三次都稳定。

然后是逐项验收:

检查项结果
唯一的外部 <script src>26 个 → 24 个
cdn.jsdelivr.net 引用0(此前 3 处 leaflet)
cdn.staticfile.org 作为资源加载0(换源前 1.01 MB)
Vue + Element-UI0(换来自研的 7 KB)
MathJax 指向 npmmirror 的页面35 / 35
引入 /js/notify.js 的页面182 / 182
含 preconnect 的页面182 / 182
带 jQuery 本地兜底的页面186 / 186
内联默认背景变量的页面186 / 186

(第一轮统计时全站 182 个页面;背景修复那一轮之后站里多了几篇文章,最后一次完整构建是 186 个 HTML 页面 / 11 秒,零报错。所以表格里的分母会不太一样。)

现在剩下的 24 个外部脚本,全部在这些域名上:

1
2
3
4
5
6
7
cdn.jsdmirror.com      2   JS-Heo 朋友圈
lib.baomitu.com 1 pjax
npm.elemecdn.com 10 echarts地图 / swiper / wowjs / gitcalendar / Meting / pace
registry.npmmirror.com 9 fancybox / algoliasearch / aplayer / clipboard /
instant.page / instantsearch / jquery / sweetalert2 / lazyload
sdk.51.la 2 统计 + 监控
sourcebucket.s3.bitiful.net 1 echarts

境外域名:0。

体积账:

之前之后
首屏第三方资源2.72 MB~1.82 MB
其中 Vue + Element-UI905 KB7 KB

省下来的 898 KB,差不多是原来的三分之一。

十、几个值得单独记下来的坑

这段是给自己(和未来的你)看的。

10.1 别用正则去改 JS 源码,尤其别用非贪婪跨块匹配

我一开始想批量替换那 14 处 new Vue,写了个看着挺聪明的正则:

1
/new Vue\(\s*\{\s*data:\s*function\s*\(\)\s*\{\s*this\.\$notify\((\{[\s\S]*?\})\)\s*;?\s*\}\s*\}\)/g;

匹配到 13 处(预期 14 处),写回文件之后语法直接炸了:

1
SyntaxError: Missing catch or finally after try

原因是 [\s\S]*? 这种非贪婪匹配会跨过相邻代码块的边界,把一个块的收尾花括号吞掉,结构就被破坏了。

后来改成"扫描器 + 括号配对":先预处理出一个掩码,跳过字符串、注释和正则字面量,然后用括号深度去精确定位每一处的起止位置,从后往前替换。

结果是精确命中 14 处,一处不多一处不少。

教训就是:改源码这种活儿,正则是最不靠谱的工具。 要么用 AST,要么老老实实做括号配对 —— 图省事的下场就是回滚重来。

10.2 Hexo 会给 source/ 下的 .js 跑 Nunjucks

我新建 notify.js 的时候,JSDoc 里写了这么一行:

1
2
3
/**
* @param {Object} opt 选项对象
*/

构建直接报错:

1
Nunjucks Error: js/notify.js [Line 83, Column 21] expected variable end

因为我写 JSDoc 时习惯性用了双花括号来标注可选字段,那对括号被当成了 Nunjucks 的变量插值。Hexo 的 source/ 目录下所有文本文件都会过一遍 Nunjucks,包括 .js。

改成 @param {Object} opt 就没事了。

顺便一提,fomal.js 里我故意嵌了 18 处本站的提示框和折叠块标签,靠的就是这个机制 —— 每次构建的时候展开成真正的 HTML。同一个特性,既是坑也是特性。

10.3 $ is not defined:一个 CDN 挂掉,整个文件停摆

回到最开始那两条 JS 报错。

fomal.js:72 那段是在拿访问者的大致位置(在首页欢迎语里显示「你是来自 xx 的朋友」):

@js $.ajax({ type: 'get', url: 'https://apis.map.qq.com/ws/location/v1/ip', data: { key: '...', output: 'jsonp' }, dataType: 'jsonp', success: function (res) { /* ... */ } }); @

问题是它依赖 jQuery。而 jQuery 是从 CDN 加载的,CDN 一死,fomal.js 第 44 行的 $(window).scrollTop() 就抛错,整个文件从这里中断 —— 后面 3800 行代码一行都不会执行,包括写背景图的那段。

这就是「一个外部依赖挂掉,整个文件停摆」的经典样本:同步的顶层代码里出现了一个可能不存在的全局变量,这份脚本就变成了定时炸弹。

第一轮只是把 jQuery 换到了国内镜像,炸弹还在。这次加了一行本地兜底:

`@html

`@

CDN 活着就走 CDN,死了就用站内自托管的备份(89 KB)。自托管一份兜底,是从「依赖别人」到「自己兜得住」最便宜的一步。

(字符串里的 </script> 必须写成 <\/script>,否则 HTML 解析器会以为脚本块提前结束了 —— 顺手记一笔。另外 LazyLoad is not defined 属于同一类毛病,出在主题的 main.js 里,这次没动,留个记号。)

回到最开始那两条 JS 报错。

fomal.js:72 那段是在拿访问者的大致位置(在首页欢迎语里显示"你是来自 xx 的朋友"):

1
2
3
4
5
6
7
8
9
$.ajax({
type: "get",
url: "https://apis.map.qq.com/ws/location/v1/ip",
data: { key: "...", output: "jsonp" },
dataType: "jsonp",
success: function (res) {
/* ... */
},
});

问题是它依赖 jQuery,而 jQuery 是在页面底部才加载的,顺序对不上,于是就有了 $ is not defined。

这次换源把 jQuery 换到了国内镜像,但加载顺序的问题没动。LazyLoad is not defined 也是同一类毛病。

留着下次修吧 —— 反正这篇已经够长了 🤣

十一、顺带排掉的一颗雷:三个 Markdown 渲染器抢一个位置

有一件事我原本没打算管——直到「关于」页自己先坏了。

某天点进去发现,「1.线路信息🚁」之后那一整块变成了没被解析的原始 Markdown:列表全连成一段,链接还带着 [fomal.cc](https://www.fomal.cc/) 的括号,小标题的 ## 两个字面量直接杵在页面上。第一反应是标签没闭合、要么缩进写坏了,回头一看那篇文件一个字都没动过。

真正的原因藏得比较深:这个站同时装了三个 Markdown 渲染器。

1
2
3
@upupming/hexo-renderer-markdown-it-plus
hexo-renderer-kramed
hexo-renderer-markdown-it

它们注册的是同一批扩展名(md、markdown、mkd …),而 node_modules/hexo/lib/extend/renderer.js 里的注册是「后注册者胜」;更麻烦的是 hexo/lib/hexo/load_plugins.js 用 bluebird 的 Promise.map 并发加载插件,谁先加载完基本看运气——同一份仓库连跑两次,插件的完成顺序都不一样。

运气不好、hexo-renderer-kramed 赢的时候,它的 HTML 块规则会把 <div class="about_page"> 之后的整段 Markdown 当成纯文本吞掉,就是我看到的那个样子。判它赢还有个简单指纹:kramed 会给标题补 <a class="headerlink">、给表格套一层 div.table-container。

最麻烦的不是它坏,是它坏了不会自己好。 Hexo 不重渲染已经缓存的文档:

  • Cache.compareFile 只要 cache.modified === mtime 就直接返回 skip(hash 一样也是 skip),touch 源文件完全没用;
  • node_modules/hexo/lib/plugins/processor/post.js:77 碰到 type 是 skip 的文档直接 return;
  • 而 before_generate/render_post.js 里的 renderPostFilter 只认 post.content == null 的文档。

三条叠在一起的结果是:db.json 里那份坏 HTML 会一直赖着,除非整站 clean 重建——而重建又是在赌运气。后来那次全站重建,kramed 又赢了一次:public/personal/about/index.html 里 ## 2.技术栈 是字面量、<h2> 只剩 3 个(正常 5 个),db.json 里有标题的 53 篇文档全部是 kramed 风格。

修法分两步。急救是把 db.json 里那篇的缓存记录和 content 字段手术删掉(15807407 → 15785922 字节),先让页面能看;根治是从 package.json 里删掉那一行:

1
-    "hexo-renderer-kramed": "^0.1.4",

这里有个容易吓到自己的地方:删了依赖要不要 npm uninstall?不用。 Hexo 的 load_plugins 只加载 package.json 里列出的包,node_modules/ 里的残留放着不加载、无害。

删之前当然要做对照——万一 kramed 才是当前生效的那个,删掉等于把 84 篇文章全部重渲染一遍。做法是把 84 篇 .md 用 hexo.post.render() 重新渲染取 sha256:含 kramed 和不含 kramed,指纹完全一样(0ceb6517…),说明它早就不生效了,摘掉对现有输出逐字节零影响。

那份排查记录里还顺手更正了一个自己早先给错的结论:之前得到的「摘掉后只有 5/87 篇对得上」,是测量对象错位——拿 kramed 版本的缓存去比 markdown-it 版本的输出,比的不是「有 kramed」和「无 kramed」。这种自我更正比结论本身更值钱。

留了个尾巴:@upupming/hexo-renderer-markdown-it-plus 和 hexo-renderer-markdown-it 还在并存,只是 6 次观测里 plus 总是先完成、markdown-it 后完成,所以是后者生效。想把竞态彻底消掉,可以把 plus 也摘掉,但要重做一遍同样的对照验证。

副作用一个:kramed 独有的标题自链接 <a class="headerlink"> 不见了。想留可以在 _config.yml 的 markdown.anchors 下面加 permalink: true、permalinkClass: headerlink、permalinkSide: left,再把符号留空。我没接回去——标题 id 两种渲染器本来就一致。

十二、给页脚加了个「旅行者 1 号」

页脚那个计时器(「本站居然运行了 X 天 X 小时 X 分 X 秒」,白天挂「科研摸鱼中」的牌子、晚上换成「下班休息啦」)跑了两年多,这次在旁边加了一句更远的东西:

旅行者 1 号当前距离地球 25,0xx,xxx,xxx 千米,约为 1xx.xxxxxx 个天文单位(信号单程需 XX 小时 XX 分)🚀

这东西不能随便算。「距离 = 速度 × 时间」那种近似公式,放到两百多亿公里外误差能到几百万公里。所以做法是从 JPL Horizons 取真实星历(DE440 地球星历 + Jacobson 2022 的旅行者 1 号解),离线做最小二乘拟合,把系数直接打进 fomal.js,运行时只做多项式求值——不联网、不请求接口,也不会哪天因为第三方接口停服而变成空白(这个教训前面刚吃过)。

模型拆成两块:

  • 旅行者 1 号:线性趋势 + 切比雪夫残差(3 个坐标 × 47 项)
  • 地球:年谐波(含线性慢调制)+ 月球项(3 个坐标 × 26 项)

在日心黄道 J2000 坐标下分别算出两体位置,再取几何距离 |r_V1 − r_earth|。时间也要处理:系数是按 TDB 儒略日拟合的,输入毫秒时间戳得先换算 jd = ms / 86400000 + 2440587.5 + 69.184 / 86400(那里多出来的 69.184 秒,是从 UTC 到 TDB 的近似差)。

有效期 2026-01-01 ~ 2100-01-01,窗口外自动钳位到边界(退化成只保留线性趋势),不会算出离谱的数。精度上,与 Horizons 的几何距离对着比(2026—2100 年两万多个独立采样点)均方根约 4000 千米、最大约 12000 千米(≈ 8×10⁻⁵ 天文单位)——已经远好于 JPL 自己对旅行者 1 号位置的预测不确定度(2030 年约 ±20 万千米)。

还有个细节值得单独说:它输出的是几何距离(此刻真实距离),不是光行时修正后的视距离;旁边那句「信号单程需 X 小时 Y 分」就是拿这个距离除光速再取整。

顺手把欢迎信息也重做了:

  • 站长坐标改成深圳市南山区(113.930413, 22.531901,代码里留了注释「换坐标只改这一行」)
  • 原来那套「按省份/城市各写一句欢迎语」的大 switch 整个删掉了——辽宁省是「我想吃烤鸡架!」,上海市是「众所周知,中国只有两个城市。」,写的时候很开心,但它有一百多行,而且只对国内 IP 有意义。现在改成按访问者的本地时间打招呼:上午好 / 中午好 / 下午好 / 晚上好 / 夜深了,再带上当前时刻
  • IP 显示加了个小处理:在每个冒号后面插一个 <wbr>,让 IPv6 地址能在冒号处断行,不至于把卡片撑破

文案从「欢迎来自 广东省 深圳市 龙华区 的小伙伴,晚上好,夜生活嗨起来!您现在距离站长约 88 公里,当前的IP地址为:…」变成了「来自 广东省 深圳市 龙华区 的访客,现在是 22:41,你目前距站长约 16 公里,IP地址:…」。

公告栏也一起精简了:删掉 zeabur.fomal.cc、fleek.fomal.cc 两条早就停掉的备用线路,以及整块「— chatGPT —」,只留主域名两个 + netlify 备用 + APP 下载。理由很直白——挂着不能用的东西比不挂更糟,它会让人以为还有退路。

十三、主题正式改名叫 Fomalhaut 了

这主题从 Butterfly 4.3.1 改到现在,已经没几行是原来的样子了,但目录还叫 themes/butterfly、配置还叫 _config.butterfly.yml,每次看都觉得别扭。于是改名。

改名听起来是体力活,但它属于那种一处漏了就直接坏的操作——Hexo 找主题配置,是按 _config.<主题目录名>.yml 拼出来的:

1
2
3
4
5
6
themes/butterfly/            →  themes/fomalhaut/
_config.butterfly.yml → _config.fomalhaut.yml
_config.yml theme: butterfly → theme: fomalhaut
themes/fomalhaut/package.json name: hexo-theme-butterfly → hexo-theme-fomalhaut
themes/fomalhaut/scripts/events/init.js 弃用提示改成 _config.fomalhaut.yml
.gitignore 补上 themes/fomalhaut/.git

顺带给主题加了个自报家门的启动横幅:在 Hexo 的 ready 事件里打 5 行 ASCII 艺术字 FOMALHAUT,下面跟一行版本号:

1
主题版本:1.0.0 (Inherited from Butterfly 4.3.1)

主题自己的版本号单独写在一个 fomalhautVersion 常量里(以后改这一行就行),Butterfly 的版本从主题 package.json 的 version 字段读出来。两个小坑:

  1. 版本行里中文要按两个字符宽算才能居中(判断 \u2E80-\uFFEF 这个区段),不然会明显偏左;
  2. 别顺手去改主题 package.json 里的 version —— 主题的 cdn.js 还拿这个版本号拼 jsdelivr / unpkg 的资源地址,改了会把静态资源路径一起改坏。

最后还有两处小打磨:

  • 作者卡片的按钮从「前往小窝」改成「Github主页」,指向 github.com/fomalhaut1998
  • 首页卡片右上角那个小箭头,主题里是直角 clip-path,在 custom.css 里用同链选择器(特异性相同、顺序更靠后)覆盖成圆角:元素固定 20×10px、圆角半径约 2px,曲线按 4 段采样成多边形顶点坐标——border-radius 对 clip-path 不生效,只能这么硬算

改完的感受是:这种「机械但零容错」的活儿,交给 AI 一遍过是最划算的;真正难的是判断「哪些地方必须跟着改」——上面那张清单里,漏掉任何一行都不会报错,只会安静地不生效,或者干脆整个站渲染不出来。

十四、把「刷新时先闪一下」也抹平了

前面背景图修好之后,我注意到一个更细的毛病:每次刷新页面,会先闪一帧默认壁纸(还有默认字体),然后才跳成我自己设的那张。

不算故障,但很扎眼。原因也不复杂:壁纸、卡片透明度、主题色这些美化设置都存在 localStorage 里,而负责读它们、再把值写进 CSS 变量的那 3800 行 fomal.js,是放在页面底部、等 HTML 和 CSS 都处理完才开始跑的。第一帧渲染时,CSS 用的还是 :root 里的默认值——于是先画一帧默认壁纸,再被脚本改掉。

要抹掉这一帧只有一条路:赶在首帧之前把值写进去。于是在 _config.fomalhaut.yml 的 inject.head 里插了一段内联脚本(<script id="beautyBoot">):它同步执行,早于 body 解析、也早于首帧。位置很讲究——必须放在那批 <style id="..."> 标签后面,否则 document.getElementById() 什么都拿不到。

它一共接管八类设置,套路都一样:读 localStorage,然后直接改对应 <style> 标签的 textContent:

  • 自定义壁纸 → defineBg
  • 卡片透明度 → transPercent
  • 背景滤镜(模糊 / 饱和度 / 对比度)→ bgFilterParam
  • 主题色 → themeColor
  • 字体 → --global-font(选「默认字体」时,还要像 fomal.js 那样动态插一段 <style>)
  • 侧边部件 / 侧栏显隐 / 侧栏位置 / 顶栏常驻 / 霓虹灯 / 帧率开关 → rightSide、aside-show、aside-pos、nav-display、menu_shadow、#fps

几个必须对齐的细节:

  1. 壁纸要连 --darkmode-bg 一起写(脚本里那个 SAME = ";--darkmode-bg:" 常量就是干这个的),不然切到夜间模式还是旧图;
  2. 取值规则必须和 fomal.js 的初始化逐字一致:null、空串、字符串 "undefined" / "null" 都算「没设置」,走默认值;
  3. 这段脚本只是提前把同样的值写一遍,不新增任何请求,也不改 fomal.js 的逻辑——两边的结果必须一模一样,否则会出现「刷新后闪回默认值」这种更诡异的现象。

写的时候先在 .tmp-boot-draft.js 里当独立文件调通,再内联进配置。要说这轮修的东西里哪些最不容易被发现,就是这种「一帧」级别的问题:它不影响任何功能,只在每次刷新的头 100 毫秒里出现一次。

十五、美化面板里那根「点不动」的滑条

顺手修的第二个体验问题:卡片透明度那根进度条,只有拖右边那个圆点才有反应,点轨道左边完全没动静。

两个原因叠在一起:

  • input[type=range] 本体只有 5px 高,圆点却有 15px——想点中那 5px 的细线本来就很难;
  • 更麻烦的是它上面盖了一层 .rang_width(position: absolute)用来显示彩色进度,这层把鼠标事件全吃掉了,点下去根本传不到 input。

修法分三层,缺一层都不成立。

一、把可点击区域做大(custom.css):.range 加 padding: 9px 0,视觉高度不变,可点区域从 5px 扩到 23px;顺手加 cursor: pointer、user-select: none,触屏上给 touch-action: pan-y——纵向照样滚页面,只有横向拖动才交给滑条。

二、让进度条别再挡路:.rang_width 的 top 跟着 padding 对齐到 9px,并加 pointer-events: none。它只是装饰,不该接收事件。

三、坐标换算(fomal.js 里新写的 rangeValueFromX()):把鼠标横坐标换成滑条的值。这里有几个容易算错的地方:

  • 要扣掉圆点自身的宽度(usable = rect.width - 15),否则拖到最右边永远到不了 max,最左边也点不出 min;
  • 按 step 取整,再抹掉浮点误差的尾巴;
  • 直接改 value 不会触发 oninput,所以另外补一个 input 事件(new Event("input", { bubbles: true }))派发出去,复用原来那套 setTrans()——这样「点击跳转」和「拖动滑块」走的是同一条逻辑,不会变成两套代码各自维护。

再往上包一层:bindRangeJump() 在滑条所在的框上接 mousedown / touchstart,initRangeJump() 在 document 上只注册一次 pointermove / pointerup(拖出轨道再松手也能正确结束,反复开关小窗也不会重复绑定)。最后在美化小窗创建完之后挂两行:

1
2
initRangeJump();
bindRangeJump(document.getElementById("transSet"));

改主题文件之前,先抽了个独立页面 slider-fix-check.html 出来——把真实的 CSS 和一段真实代码的切片(.tmp-real-slice.js)拼进去,在浏览器里先把手感确认好。这种小交互最怕「改完不知道好没好」,有个能单独打开的自测页,比每次刷新整站去试效率高得多。

十六、两枚新徽章,和一个「不用再管」的朋友圈

页脚那排徽章更新了:

  • 主题徽章从 Theme-Butterfly-6513df.svg 换成自制的 Theme-Fomalhaut-6513df.svg(沿用同一个色号),链接指向改名后的 hexo-theme-Fomalhaut 仓库;
  • 新增一枚 CDN-npmmirror-fff2cc.svg——这轮换源把大部分静态库搬到了 npmmirror,值得在页脚记一笔;
  • 两张 SVG 也存进了 source/assets/badge/ 存档,线上依旧走缤纷云的桶。

比徽章更值得写的是朋友圈(友链动态)那条链路。它原来的数据源是 https://fcircle.fomal.cc/,也就是我自建的 Vercel 爬虫接口。

这次把 source/social/fcircle/index.md 里的 private_api_url 换成了仓库里的静态文件:

1
2
// 数据源:仓库里的静态 data.json(由 GitHub Action 每 6 小时生成,经 jsDelivr 国内 CDN 分发)
private_api_url: 'https://cdn.jsdmirror.com/gh/fomalhaut1998/hexo-circle-of-friends@main/data.json?',

看起来只是换了个地址,其实是把「需要有人维护的服务」换成了「一个文件」:爬虫照旧每 6 小时跑一次,结果直接提交进 GitHub 仓库;前端拿的是 CDN 上的静态 JSON,没有函数、没有运行时、没有要续费的平台。写这篇之前顺手查了下它是活的:HTTP 200、7689 字节(8 KB),statistical_data 和 article_data 都在,26 篇文章。

回头看,这一整晚修的都是同一件事:把依赖第三方、依赖人去盯的东西,换成放在自己仓库里、躺在 CDN 上的东西。 它们唯一的失效条件,就是我自己不提交。

十七、顺手的三件小事

标签统计图的排序一直是错的。 标签页那张「Top 10 标签统计图」由主题的 hexo_echarts.js 这个 helper 生成,它取前 N 名的写法是 sort((x, y) => y[0] - x[0])——而 x[0] 是标签名(字符串),字符串相减得到 NaN,排序函数永远返回 NaN,排了等于没排。改成按文章数降序、同数按名字排:

1
2
3
tagArr.sort(
(x, y) => y[1] - x[1] || String(x[0]).localeCompare(String(y[0]), "zh"),
);

「随便逛逛」在本地预览时会跳到生产站。 那个按钮是随机跳一篇博文,数据来自 /baidusitemap.xml;而 sitemap 里的 <loc> 是绝对地址(域名由 _config.yml 的 url 决定),所以本地 localhost:4000 点一下,会把自己的浏览器直接扔到 www.fomal.cc 去。改成统一转成同源相对路径:

1
2
const u = new URL(el.textContent.trim(), location.origin);
return u.pathname + u.search + u.hash;

顺手加了道保险:抽到的地址如果和当前页面相同,最多重抽 20 次(原来只判断一次)。

帧率显示的采样窗口从 1 秒缩到 0.5 秒(now > 500 + lastTime),刷新频率翻倍。这一处是上一轮改的,我写这篇的时候才发现漏记了——所以「顺手」这话不能全信,改完不写下来,过一天自己都不记得。

十八、统计页的三张图,其实是一张过期了 30 天的 token

上次修完背景图之后,我去 /site/census/ 看了一眼,发现三张图 —— 访问地图、访问趋势、访问来源 —— 全是空白。

「空白」这个状态特别有迷惑性:它不像报错那么显眼,也不像「本来就还没数据」那么理直气壮。点开控制台才知道,census.js 从百度统计接口拿回来的是:

1
{ "error_code": 111, "error_msg": "Access token expired" }

而三个 .catch() 里只写了一行 console.log(error),错误被安安静静地吞掉了。页面上看不出「没数据」和「服务挂了」的区别,这就是它能坏很久而我一直没发现的原因。

百度统计的 access_token 有效期只有 30 天,过期之后要拿 refresh_token 去换新的。这里有个坑值得单独记:refresh_token 是一次性的。我用同一个 token 调了第二次刷新接口,直接返回:

1
error 400 expired_token: refresh token has been used

所以刷新完必须把返回的新 refresh_token 一并写回去。census.js 里现在是三行联动(第 4 行刷新链接里的、第 9 行注释里的、第 10 行的 access_token),下次到期是 10 月 31 日之前 —— 我把这句话也写进注释里了,因为这个坑一年会来十二次,而人一年会忘十二次。

顺手做了两件事,让这类故障下次别再静默。

第一,接口挂了要看得见。 加了两个小函数:

1
2
function censusResult(data) { ... }            // 校验返回体,异常时抛错
function showChartFallback(id, error) { ... } // 在图表容器里显示「统计服务暂不可用 + 原因」

原来那三个 .catch 只打日志,现在会在图表原来的位置上写清楚「统计服务暂不可用」,以及具体是什么原因 —— 从界面就能看出来是服务挂了,而不是自己网卡了。

第二,让地图跟着主题色走。 那张中国地图原来写死了 ['#39c5bb', '#b9ebe4'] 两级色阶,主题色是绿的时候看着还行,换成粉的、紫的就格格不入。但主题色不是常量:它是主题运行时写进 <style id="themeColor"> 的 CSS 变量,取值来自设置面板,所以只能在运行时读:

1
getComputedStyle(document.documentElement).getPropertyValue("--theme-color");

读到 rgb 之后,再用「叠白」「叠黑」两个函数派生出同色系的浅色调和深色调,把悬浮高亮的 gold 换成主题色的深色,悬浮标签的文字颜色按背景亮度在深灰和白色之间自适应。最后加一个 MutationObserver 盯着那个 <style> 标签 —— 你在设置面板里换主题色,地图会跟着一起变,不用刷新页面。

顺带还改了 51la 那排数字。原来只显示 5 格,把「总访问量」漏掉了,order 数组改一行就能加回来:

1
let order = [6, 1, 3, 2, 4, 5]; // 下标 6 = 总访问量

再加千分位。这里有个细节:51la 的 quote.js 是被当纯文本抓回来的,它自带的格式化函数根本不会执行,所以只能自己补一个正则;顺手给 match() 加了 || [] 兜底 —— 抓不到的时候原来是 TypeError,整个统计块直接空白。

(顺便说一句:改完这些,统计页终于从「三张白板」变回了有内容的页面。这类改动没有任何技术难度,纯粹是没人往那儿看 —— 前面屏幕上那些红字是这样,这三张图也是这样。)

十九、一块贡献日历的三次搬家

统计页顶部还有一块 GitHub 贡献日历,它今天挪了三次家 —— 大概是整篇里最能说明「自建服务为什么会烂掉」的例子。

起点是它在转圈。链路是这样的:hexo-filter-gitcalendar 往页面里注入一句 GitCalendarInit(jsonurl, color, user),前端的 gitcalendar.js 拿这个地址去 fetch 一年的贡献数据。

第一次搬家:先找个别人的接口顶着。 原来的数据由自建的 Vercel 函数 gitcalendar.fomal.cc 提供 —— 它的做法是抓 https://github.com/fomalhaut1998 的主页 HTML,再用两条正则把日历抠出来。GitHub 后来把日历改成了异步加载:

旧代码期望实测
数据来源主页内嵌的日历主页 HTML 里 data-date= 出现 0 次
日期正则data-date="(.*?)" data-level属性顺序变成 data-date … id … data-level
计数正则<span class="sr-only">N contribution每天的计数搬进了 <tool-tip>

结果 zip(*sorted_data) 解到一个空列表,抛 ValueError,Vercel 只回一个 96 字节的 FUNCTION_INVOCATION_FAILED。抓 HTML 的接口本质上是在别人家的装修上打洞,人家一改版就塌。

临时方案很省事 —— _config.yml 里加一行 jsonurl,先指向别人的公开接口:

1
jsonurl: "https://gh-calendar.rschristian.dev/user/fomalhaut1998"

第二次搬家:把自己的接口修好。 老代码改了两版才跑通。v1 换了 requests 库还是 500,而且返回的是 Vercel 的通用错误页 —— 说明异常发生在进入 try 之前,也就是模块导入阶段就崩了(requirements.txt 里 pin 的是 2020 年的版本)。所以 v2 干脆做了两件事:

  1. 全部改用标准库 urllib,不再有任何第三方依赖;
  2. 所有分支收敛到一个出口,任何异常都变成 JSON,不再出现 Vercel 的通用 500 页。

还加了一个自检入口 ?__debug,返回运行时 Python 版本、抓取状态、抓到的字节数、关键正则的命中次数,以及页面开头 200 字。排查远程接口最痛苦的是「你看不见里面」,那就先让自己能看见。

第三次搬家:连前端资源也拿回来。 修接口的时候才发现,不只是数据在别人手里 —— gitcalendar.css 和 gitcalendar.js 也挂在 npm.elemecdn.com 上。于是两个文件搬回本站(14564 与 3045 字节),顺手给 js 打了个补丁:原版只在成功分支里摘掉加载动画 #git_loading,

1
2
3
}).catch(function(error) {
console.log(error) // 接口一挂,转圈永远不停
});

补丁在 catch 里补上收尾,并在日历容器里写一句「📅 贡献日历暂不可用 / 数据来源:GitHub · 稍后刷新重试」。这里还有一个隐藏依赖:#git_container { display: none } 这条样式写在 #git_loading 内部,所以「摘掉转圈」和「让日历显示出来」是同一个动作,顺序不能颠倒。

终点(这次的答案):不要服务,只要一个文件。 前面两版修的都是「抓 HTML」,GitHub 下次改版还会坏。所以最后走了一条和朋友圈(hexo-circle-of-friends)完全一样的路:

1
2
3
GitHub GraphQL API → GitHub Action(每 6 小时)→ gitcalendar-data 仓库里的 data.json
↓ jsDelivr / jsdmirror
访客浏览器里的 gitcalendar.js 直接 fetch 这个文件

没有 Vercel 函数、没有第三方接口、没有会欠费或过期的项目 —— 唯一能让它失效的事情是 GitHub 自己挂了。几个值得记下来的细节:

  • 数据单独放一个仓库。如果把这个每 6 小时提交一次的任务放在博客仓库里,每次提交都会触发一次整站重新部署;
  • 生成脚本的形状校验是硬性的:周数不是 53、最后一周为空、GraphQL 报错 —— 任一情况直接退出,并且不覆写 data.json。宁可数据旧 6 小时,也不要页面空白;
  • 实测:11943 字节、53 周、369 天,与 raw.githubusercontent.com 上的原文逐字节一致;gitcalendar.js 里硬编码取的 git_data[47]/[48]/[51]/[52] 在 53 周结构下全部合法;
  • 代价是当天的贡献最多滞后 6 小时。原来的 apiurl 留着没删,删掉 jsonurl 那行就能切回自己的接口。

最后是一个反复踩到的坑:_config.yml 不在 hexo server 的监听范围里。node_modules/hexo/lib/hexo/index.js:345-346 只监听 source 和 theme,所以改完配置必须重启,否则页面里注入的还是旧地址(实测改完刷新,源码里仍然是 GitCalendarInit("https://gitcalendar.fomal.cc/api?fomalhaut1998"...))。

二十、滚动条,和一只挡路的猫

两件视觉上的小事,都是今天才发现的。

滚动条上的斜条纹其实一直没出现。 很早以前我给滚动条写过一套样式:主题色滑块、45 度斜条纹、圆角。但它从来没显示过 —— 因为后来我又给 Firefox 加了标准属性:

1
2
scrollbar-width: thin;
scrollbar-color: var(--theme-color) #f1f1f1;

问题是 Chrome / Edge 121+ 也认这两个标准属性了,而一旦它们不是 auto,浏览器就会完全忽略 ::-webkit-scrollbar 伪元素样式 —— 不是被覆盖,是整个不生效。所以修法反直觉:在支持伪元素的浏览器里,把标准属性退回 auto:

1
2
3
4
5
6
@supports selector(::-webkit-scrollbar) {
* {
scrollbar-color: auto !important;
scrollbar-width: auto !important;
}
}

Firefox 不认识 selector(::-webkit-scrollbar),会跳过这一段,继续用它认识的标准双色滚动条 —— 两边就都能看了。

条纹本身也重做了一版「发廊转筒」:

1
2
3
4
5
6
7
8
::-webkit-scrollbar-thumb {
background-color: var(--theme-color);
background-image: repeating-linear-gradient(
45deg,
rgba(255, 255, 255, 0.45) 0 9px,
rgba(0, 0, 0, 0.2) 9px 18px
);
}

条纹宽度试了三档:3px 太密,像被马赛克糊过;7px 仍然偏密;9px 是松弛、但还没糊成一块色板的那个档位。

然后是那只猫。 右下角有只报时猫,鼠标移上去会冒一个气泡显示当前时间。它平时不显眼,直到你发现它把底下的东西全挡住了 —— 侧边栏的按钮、导航图标,指针移过去既不亮也点不动,因为它是绝对定位浮在页面右侧的一层。

修法很简单,让它只当「视觉挂件」:

1
2
3
4
.neko {
pointer-events: none; /* 不吃鼠标事件,底下的链接照常能点 */
color: #333 !important; /* 白色气泡上用 #333,跟主题色反而怪 */
}

代价立刻出现:pointer-events: none 的元素收不到 :hover,报时气泡再也不出来了。于是把「悬停」这件事从 CSS 搬到 JS 自己做:

1
2
3
4
5
6
7
8
9
10
var r = el.getBoundingClientRect();
var inside =
clientX >= r.left &&
clientX <= r.right &&
clientY >= r.top &&
clientY <= r.bottom;
if (inside) {
nekoClockTick();
$neko.addClass("hoverOn");
}

三个细节:

  • mousemove 触发频率很高,套一层 requestAnimationFrame 合流 —— 一帧最多判定一次;
  • 指针移出整个窗口之后不会再有 mousemove,补一个 window.mouseout + e.relatedTarget 为空的兜底,否则气泡会一直挂在那儿;
  • 定时器在 window 上留了 __nekoClockTimer 标记,脚本被重复执行也不会叠出好几个。

这两件事的共同点是:它们都不报错。 一个样式被默默忽略、一只猫默默挡住按钮,控制台里没有一行红字,你只会觉得「这个按钮怎么点不动」。所以回头看,修网站最费时间的从来不是修,而是发现。

二十一、节日提醒:从「必须点一下确定」换成左上角一张卡片

先说一个此前一直被我无视的东西:博客的节日提醒。

旧版是一套 SweetAlert2 居中弹窗 —— 到了元旦、春节、国庆这种日子,页面正中间弹出一个框,挡住内容,必须点一下「确定」才能继续看文章。老实讲,作为一个偶尔路过自己博客的人,我都被它烦过。所以这次干脆重写。

旧的写法是二十多个 if。 节日判断全塞在 source/js/fomal.js 里:一个节日一个 if,里面写死「今年是哪一年、今天是不是那一天」,最后调一次 Swal.fire(...)。这种写法有两个麻烦:

  • 清明、冬至这种跟节气走的节日,日期每年都不一样,而旧代码是按年份硬编码的 —— 意思是过一年你就得进去改一次数。人不会记得改。
  • 想加一个节日,得在将近四千行的 fomal.js 里找到对的位置,复制一坨 if 再改文案。

新的写法是一张表。 现在多了一个 source/js/festival.js(14199 字节 / 433 行),里面只有一张 FESTIVALS 表,30 条节日各占一块:

1
2
3
4
5
6
7
{
id: 'mid-autumn',
title: '中秋节',
lunar: [['八月', '十五']], // 农历八月十五
message: '中秋快乐\n愿你与牵挂的人共此一轮月。',
type: 'success'
}

判断条件支持四种写法,可以叠加,满足任意一种就弹:

写法例子说明
solar[[10, 1], [10, 2], [10, 3]]公历,国庆一次管三天
term[4, 7]节气:4 月的第 7 个节气 = 清明,按年自动算
lunar[['八月', '十五']]农历,写法跟页面上显示的农历一致
week[5, 0, 2]5 月第 2 个星期日 —— 母亲节是这么写的

文案里有几个占位符:{year} 是当前年份,{since:1949} 会算成「距 1949 年多少年」,所以国庆那条写的是「祝祖国{since:1949}岁生日快乐!」,不用每年改;愚人节那种需要随机挑一句的,用 pick: ['…', '…']。

弹的箱子也换了。 节日卡片现在走的是我上一轮写的那个通知组件(source/js/notify.js),落在左上角,十秒后自己收起,想提前关就点右上角的叉:

1
2
3
4
5
6
fomalNotify({
position: "top-left",
offset: 50,
duration: 10000,
showClose: true,
});

一天只弹一张卡(记在 sessionStorage 的 fomal-festival-shown 里;隐身模式下 sessionStorage 可能不可用,代码里 catch 掉就算了,不值得为它报个错)。

顺便,这张卡还能不等节日就预览:

  • 控制台输入 fomalFestival.list 看全部节日;
  • fomalFestival.preview('national') 立刻弹出国庆那张卡(不占用当天的去重名额);
  • 或者直接在地址后面加 #festival=mid-autumn(换成表里的 id)。

农历换算拆成了单独一个文件。 节气与农历那套算法原来也在 fomal.js 里,这次抽成了 source/js/lunar.js(24401 字节 / 1089 行,覆盖 1900–2100 年),对外就三个函数:solar2lunar()(不传参就是今天)、lunar2solar()、getTerm(year, n)(第 n 个节气,1 是小寒、7 是清明、24 是冬至)。

两个细节值得记下来:

  1. 顺序不能错。 festival.js 依赖 lunar.js,注入时它必须在前 —— _config.fomalhaut.yml:1263 是 lunar.js,:1265 才是 festival.js。
  2. lunar.js 故意没写成 IIFE、也没开严格模式。 那几个函数必须挂在全局上给 festival.js 用;文件头也写明了:里面的算法是逐行照搬过来的,要加新逻辑请另开文件,别动它。

这一进一出,主脚本瘦了一圈。 source/js/fomal.js 从 3961 行降到 2799 行 —— 少了一千一百多行。搬出去的部分变成了两个各管一事的文件:lunar.js(24 KB)+ festival.js(14 KB)。

顺手删掉的还有两样东西。 一是四条公祭日提醒(九一八、卢沟桥、南京大屠杀、世界慰安妇纪念日),二是一整套「纪念日到点把整站变灰」的代码(fomal.js 里原来有 4 处 grayscale 处理)。这两处是我自己要求删的:网站是个人博客,不是宣传栏,该记的事我自己会记得,不需要代码到点提醒我。删干净之后,grayscale 在 fomal.js 里归零。

同时补了八个日常节日(情人节、母亲节、父亲节、儿童节、建军节、感恩节、平安夜、腊八节),原来那些祝福语的文案也顺手改稳了一点。表里还留着两条私货:cat-birthday 和 fomal-birthday —— 一只猫的生日,和站长的生日。

最后,SweetAlert2 那条外链脚本也一起卸了:现在整个仓库搜不到一处 sweetalert。

二十二、首页改成 3 × 3,以及一种把置顶写在正文里的写法

这轮还有两件事都落在首页上。

第一件:一页 12 篇改成 9 篇。

起因是首页看着有点「糊」。我这边首页是三列布局(themes/fomalhaut/source/css/_index_card_style/multicard.styl:146-150:窗口宽 ≥1069px 时卡片宽度 32.3%,也就是一行三张),而 _config.yml:84 的 index_generator.per_page 写的是 12 —— 12 除以 3 正好四行,一屏密密麻麻塞满。

改成 9 就是三列三行,一屏刚好摆满,底下的留白出来了,翻页从 5 页变成 6 页。不算大改,但第一眼的观感确实不一样了。

顺手把侧栏那张「最新文章」卡片也撤了(_config.fomalhaut.yml:967 的 aside.card_recent_post.enable 改成 false):首页已经全是文章列表,侧栏再来一份「最新 5 篇」纯属重复。模板里是 themes/fomalhaut/layout/includes/widget/card_recent_post.pug:1 的那个 if 控制的,改完这个 DOM 整个不会生成。

验证还是老老实实构建一遍:hexo clean && hexo generate 退出码 0、263 个文件;public/index.html 里真实文章卡片 9 个(.recent-post-item 一共 11 个,另外 2 个是轮播和分类磁贴的整行容器,不算),出现 /page/6/,全站找不到 card-recent-post 这个 DOM。

第二件:置顶换了个写法。

这事有点好笑。上一轮为了解决「新文章排在第 13 位、首页根本看不见」,我把 12 篇文章的置顶全删了,让首页恢复纯时间倒序。结果现在又需要置顶了 —— 但这次我不想再往 Front-Matter 里塞 sticky: 数字:那玩意儿肉眼看不见,一篇文章到底有没有被置顶,你得点开文件才知道。

所以现在可以在正文里任意位置写一行 HTML 注释:

1
2
3
4
<!-- sticky -->
置顶,等价于 Front-Matter 里写 sticky: 1
<!-- sticky: 2 -->
强置顶,角标颜色更深
  • 标记会在渲染时被自动删掉,不会漏进正文、摘要、RSS 和搜索结果;
  • Front-Matter 里的 sticky 优先,正文标记只在没写的时候生效;
  • 排序还是 node_modules/hexo-generator-index/lib/generator.js:10 那一行 sort((a, b) => (b.sticky || 0) - (a.sticky || 0)),不用装任何置顶插件。

实现是新增的 scripts/sticky-post.js(2753 字节),注册在 after_post_render 上、priority = 5。这个数字是故意的:Hexo 的 excerpt 过滤器用的是默认 priority 10,所以这个小脚本一定先跑,标记会在任何摘要切分之前就被剥掉 —— 否则摘要的头一行就是一句 HTML 注释,很煞风景。剥的时候四个地方都过一遍(data.raw、content、excerpt、more),顺手把可能留下的空段落(<p></p>)清掉。

首页卡片的左上角现在会挂一个置顶角标:themes/fomalhaut/layout/includes/mixins/post-ui.pug:143 的 postStickyBadge 混入负责渲染,只有首页才画(is_home() && article.sticky > 0),sticky >= 2 会多一个 is-top 类,颜色更深更红。样式在 themes/fomalhaut/source/css/_custom/custom.css:3519-3604:26px 的胶囊、橙红渐变底、13px 的图标,另加一道斜向高光每 3.4 秒划过一次 —— 没什么技术原因,就是让人第一眼能看见。暗色模式下阴影加深一点,prefers-reduced-motion 下高光动画直接不显示。

有个细节记一下:角标只能贴在卡片内部的左上角。因为 .recent-post-item 本身是 overflow: hidden,想做成越界的小缎带会被直接裁掉。

现在有两篇用了这个标记:这篇,和那篇《Markdown 语法与外挂标签写法汇总》。

二十三、美化面板里那面错排的图墙,和那个不像控件的取色框

美化面板(右下角那个糖果按钮)里「四、背景设置」有一面图墙,壁纸一张挨一张,点一下就换。这面墙在窄屏下一直是错排的。

问题长这样: 一行只放得下一张图,右边留一大片空白;而在 579px 这种「一行刚好能放两三张」的宽度下,最后一行只剩两张时会被 justify-content: space-between 推到最左和最右,中间空出一大块。

原因在两条 CSS 上:

  • themes/fomalhaut/source/css/_custom/custom.css:1114:.bgbox { display: flex; flex-wrap: wrap; justify-content: space-between; }
  • custom.css:1120:.pimgbox, .imgbox, .box { height: 100px; width: 166px; margin: 10px; }(≤768px 时宽度改成 135px、高度 73px)

固定宽度 + space-between:一行装不下两张就只能放一张;末行不满时两端对齐,中间必然出现空洞。

改法就是换成 grid。 从「一行能塞几张算几张」改成「列宽固定、从左往右排满一行再换行」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* 桌面 */
.bgbox {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 20px;
align-items: start;
}
.bgbox > .pimgbox,
.bgbox > .imgbox,
.bgbox > .box {
width: 100%;
margin: 0;
}

/* ≤768px:列窄一点、间距小一点 */
.bgbox {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
}
.bgbox > .imgbox,
.bgbox > .box {
height: auto;
aspect-ratio: 135 / 73;
}

auto-fill + minmax() 的意思是「按容器宽度自己算能放几列」,所以不会再出现手写的宽度和缝隙对不上。实测(真实构建出来的页面,注入探针量的):

视口宽度改之前改之后
3208 行 × 1 张(135×73)4 行 × 2 张(114×62)
3908 行 × 1 张4 行 × 2 张(147×80)
4142 张/行,行内空隙 37px2 张/行(159×86),间距 10
5793 张/行,末行两端对齐空 194px2 行 × 4 张(114×61)
7684 张/行,行内空隙 35px5 张/行(125×67),末行左对齐
12803 张/行,末行空 350px空洞消失(列宽自适应)

第二个是那个取色框。 「四、背景设置 → 5. 纯色」里的取色框一直是浏览器原生外观:比旁边的色块高 10px(110 对 100)、方角、多一圈 1px 边框,一眼就能看出「这不是同一个世界的控件」。改法是:

  • appearance: none 去掉原生外观,尺寸对齐同级色块(宽 100%、高 100px、圆角 10px);
  • 底下垫一层彩虹渐变 conic-gradient(...),再把原生的色块用 ::-webkit-color-swatch { opacity: 0 } 藏掉;
  • 「自定义颜色」四个字从写死的 left/top 改成水平居中(left: 0; right: 0; top: 50%; transform: translateY(-50%))。

点击还是弹系统取色器,只是外观换成了自己的。手机端再补一条 .bgbox > #define_colors { height: auto; aspect-ratio: 135 / 73 },跟同级色块一样高。

最后一条教训:_custom/ 下的文件不吃 hexo generate。 主题把 source/css/_custom/custom.css 当私有文件(下划线开头),改完必须 hexo clean && hexo generate 才会重新编译。我第一次改完只跑了 generate,浏览器里死活没变化,一度以为是缓存。

二十四、欢迎信息的空蓝框、页脚的第四年、手机上的滚动条

欢迎信息先占个位。 侧栏那张欢迎卡片要等腾讯地图的接口回来才知道你在哪,接口慢的时候它就是一个空蓝框。现在:

  • 脚本一执行就先写一行「欢迎信息正在加载中…」进去;
  • showWelcome() 开头加了守卫:没拿到 ipLoacation.result.location 就先显示加载提示然后 return(顺手修掉了控制台里那个未捕获的 TypeError);
  • pjax:complete 之后再挂一次,页面切换回来也有占位;
  • 十秒还没等到数据,就换成「欢迎信息加载失败,请刷新重试🥺」。

实测:0.6 / 1.6 / 5 秒都停在「加载中」,11.5 秒变成失败提示;接口正常时 0.6 秒就换成真正的欢迎语。

页脚那个运行时长开始算「年」了。 以前显示「本站居然运行了 1514 天 17 小时 21 分 05 秒」—— 一千多天到底是几年,得自己算。现在按满周年计算:

  • 建站时间 2022-08-09,到 2026-08-09 记 4 年,剩下的天数从周年日重新数;
  • 具体是 anniv = new Date(grt)、years = now.getFullYear() - grt.getFullYear(),如果周年日还没过就把 years-- 并重算 anniv,再 dnum = Math.floor(rest / 86400000);
  • 秒从 Math.round 换成了 Math.floor —— 原来的写法在最后一秒会算出「60 秒」,看着挺怪的。

现在显示的是「本站居然运行了 4 年 53 天 17 小时 21 分 05 秒」。

手机端补了一条自绘滚动条。 上一轮把电脑端的滚动条改成主题色斜条纹之后,手机端一直没跟上 —— 因为手机浏览器(以及 Chromium 的 overlay 滚动条模式)根本不认 ::-webkit-scrollbar,只用系统滚动条,那条规则在手机上等于没写。

所以 ≤768px 干脆把系统滚动条藏起来(scrollbar-width: none + html::-webkit-scrollbar { width: 0; height: 0 }),用一个自己画的替代:custom.css:69-116 里的 #mscrollbar / #mscrollbar-thumb,由 source/js/fomal.js:2740-2798 驱动 ——

  • 固定在右边缘,宽 8px,滑块高度按 vh * vh / total 算、最小 30px;
  • 随滚动进度 translateY 移动,滚动时淡入,停下来 0.9 秒后淡出(HIDE_DELAY = 900);
  • 条纹和颜色跟电脑端那条一样(var(--theme-color) + 45° 的 9px/18px 重复渐变);
  • 监听 scroll(passive: true)、resize、DOMContentLoaded、pjax:complete,换页之后它还在。

桌面端一行都没动 —— 窗口超过 768px 时自绘条不会加 mscrollbar-on 类,也就不会出现两条滚动条。

二十五、节日那天,除了卡片还放了一场礼炮

卡片是「告诉你今天是什么日子」,礼炮是「替今天高兴一下」。 节日提醒从居中弹窗改成左上角卡片之后,喜庆的那几天还是有点安静,于是又加了一层:命中「喜庆节日」时,卡片弹出的同时整屏放一遍烟花。

规则写得很克制:

  • 节日表 source/js/festival.js 里只有 celebrate: true 的条目才放,一共 16 个 —— 元旦、情人节、劳动节、520 情人节、儿童节、建党节、建军节、国庆节、平安夜、圣诞节、站长生日,加上春节、元宵、端午、七夕、中秋这五个农历节日;表里一共 29 条,喜庆的占一半多一点。
  • 平常日子一个字节都不下载:festival.js:376-395 的 loadFx() 命中时才 document.createElement('script') 插入 /js/celebrate.js(async,onload 之后才调 fomalCelebrate({ colors: f.colors })),fxLoading 防止重复插入;新脚本 6946 字节。
  • source/js/celebrate.js 的参数都写在文件开头:8 发礼炮(ROCKETS = 8)、每发 104 个粒子(SPLASH = 104)、整段动画硬上限 7600ms(TOTAL = 7600),到点自己收工并把 canvas 从 DOM 里摘掉。
  • canvas 铺满全屏、pointer-events: none、z-index: 9998 —— 比通知卡片的 9999 低一层,所以卡片上的字不会被烟花糊住,也不会挡住任何点击。
  • 像素比最高按 2 渲染、粒子上限约 500(低端机不卡);系统开了「减少动态效果」(prefers-reduced-motion: reduce)时直接不播放。

顺带按要求下线了「小猫咪生日」那条提醒 —— 生日从此只有站长一个。

二十六、把「标签 / 分类 / 文章统计」并成一张「文章统计」

理由很朴素:三张页面说的是同一件事(本站写了些什么、各标签各分类各有多少篇),却分在「文章」和「网站」两个下拉里,点进去三张图长得还差不多。

合并的方式不是删页面,而是给主题加了一个通用跳转页:

  • source/tags/index.md 的 type 从 tags 改成 echarts、title 改成「文章统计」,它成为唯一的落点(/tags/);
  • source/categories/index.md 与 source/site/echarts/index.md 只留 front-matter:type: redirect + redirect_to: /tags/ + comments: false + sitemap: false(不让搜索引擎记两条重复地址);
  • 新增 themes/fomalhaut/layout/includes/page/redirect.pug(488 字节):meta http-equiv="refresh" 加 location.replace() 双保险,下面留一行「该页面已合并到「文章统计」,正在自动跳转……」和手动链接(万一脚本被挡);
  • themes/fomalhaut/layout/page.pug:8-18 的 case page.type 里补一个 when 'redirect' 分支,以后任何页面想搬家,只需要写两行 front-matter;
  • includes/page/echarts.pug 在原三张图后面补上「标签云」和「分类列表」两块(复用原标签页、分类页的正文),统计页自己就是完整的一页;
  • _config.fomalhaut.yml 里「文章」下拉删掉「标签」「分类」,换成一条「文章统计: /tags/ || icon-shujutongji1」;「网站」下拉里的「文章统计: /site/echarts/」删掉。

顺手把归档页顶部那张「文章发布统计」图删了。 归档页的主角是时间线,页顶再压一张柱状图很挤;themes/fomalhaut/layout/archive.pug 从 510 字节缩到 310 字节(删掉 echarts.min.js 的引入、#posts-echart 容器和 posts_echarts(...) 调用),但 posts_echarts 这个 helper 没删 —— /tags/ 那页还在用它。

二十七、三张统计图的标题挪到图表外面

同一批图还有个小问题:标题是 ECharts 自己画的(title 选项),于是图例、标题、图形挤在同一块 canvas 里,窄屏下标题容易被图例压住。

改法是让助手函数支持一个新参数:

  • themes/fomalhaut/scripts/helpers/hexo_echarts.js 三个 helper(posts_echarts、tag_ranking_bar、categories_pic)里各加一行 const showTitle = options.titlePosition !== 'outside',把 title.show 接到这个变量上;
  • includes/page/echarts.pug 里三张图各配一个 <center><font size="4" style="font-weight:bold">标题</font></center>,调用时传 titlePosition: 'outside';
  • 不传这个参数的老调用行为完全不变。

二十八、菜单动手术:二级菜单居中、四个入口下线

二级菜单居中。 主题原来的做法是按栏目写死偏移 —— custom.css 里躺着 6 条 nth-child(2)~(7) .menus_item_child { left: -149px ~ -105px },每个栏目一条,宽度一变就得重算。「标签/分类」并成「文章统计」之后子菜单变窄,一眼就看得出对不齐。现在只留一条规则:

1
#nav .menus_items .menus_item .menus_item_child { left: 50%; right: auto; transform: translateX(-50%); }

坑在动画上:主题 _global/function.styl 里的 @keyframes sub_menus 只做 translateY,而 head.styl:304 用的是 animation: sub_menus .3s .1s ease both —— both 会把动画结束态一直保持着,于是静态的 translateX(-50%) 被覆盖,菜单又跑偏。所以我在 custom.css 里同名覆盖了一份 @keyframes sub_menus,把 -50% 一起写进关键帧(translate(-50%, 10px) → translate(-50%, 0)),进场动画和下落的观感都还在。实测二级菜单中心与父项盒中心差 −4px(父项左侧 8px 内边距造成,肉眼看不出来);宽度是自适应的 —— 父项从 186px 变到 254px 时子菜单从 623px 跟到 691px,中心始终是 1060.5px。

四个入口下线。 这次删掉的是「游戏」「动画」两个菜单项,以及整个「个人」父项(「唠叨」「恋爱小屋」),「关于」挪到「网站」栏。删的是菜单入口而不是页面:source/personal/bb/、source/personal/love/ 两个目录删了,但 /life/games/、/box/animation/ 这些页面文件都还在,直链打开依然正常 —— 有些老文章的正文里还链着它们。

「关于」就更有意思了:页面文件一直在 source/personal/about/ 下,如果为了让 URL 好看而搬去 /about/,页脚、右键菜单和好几篇旧文章里的链接就全断了,所以路径没动,只是把它挂进了「网站」下拉。改完 hexo clean && hexo generate 是 262 files in 3.19s。

二十九、列表页的翻页也换成首页那种方块

首页的翻页是圆角小方块卡片,归档页和标签/分类的列表页却还是主题默认的「细边线数字」,两处风格对不上。这次没动主题源码,而是把一段样式注进了 _config.fomalhaut.yml 的 inject.head(<style id="listPagination">):

  • 选择器写成 .layout > div:not(.recent-posts) .pagination > *,正好放过首页(首页的 .recent-posts 已经在主题里排好了),只管归档、标签、分类这些列表页;
  • 每个页码 display: inline-block、width / height / line-height: 2.5em、margin: 0 6px,圆角 8px,底色 var(--card-bg)、阴影 var(--card-box-shadow),悬停换成 var(--btn-hover-color);
  • 第一版被说「字太扁」,于是提高选择器优先级、把行高写死成 2.5em,第二版才对齐。

三十、打赏二维码:换源、居中、缩小

三件事一起做:

  • 换源:_config.fomalhaut.yml(第 476、479 行)与关于页 source/personal/about/index.md 里两个收款码都换成 https://sourcebucket.s3.bitiful.net/img/vx_code.webp 和 …/zhifubao_code.webp(关于页那个微信 tab 的缩放从 60% 调到 50%);
  • 弹窗居中:reward.styl 里 .reward-button 加 position: relative,.reward-main 从 left: -55%; width: 150% 改成 left: 50%; translate: -50% 0; width: max-content;
  • 缩小:二维码高度 280px → 140px,整张卡片约 328×197 CSS px。

这里踩了两个纯 CSS 的坑。第一个:用 transform: translateX(-50%) 居中不行 —— 主题给弹窗挂了 animation: donate_effcet .3s .1s ease both,那个关键帧只做 translateY,fill-mode: both 会把横向位移一起吃掉。第二个:改用 left: 0; right: 0; margin: auto 加 width: max-content 也不行 —— 绝对定位下 max-content 会按 width: auto 解析,auto 外边距算出来是 0。最后用的是独立属性 translate:它和 transform 是两条不同的属性(合成顺序是 translate → rotate → scale → transform),动画动它的 transform,居中动它的 translate,互不干扰。实测弹窗中心与按钮中心偏差 0 设备像素。

三十一、美化面板的初始尺寸不再靠猜

侧栏那个「美化」面板以前的初始尺寸是 resize(窗口宽 × 0.6, 70%) —— 于是窗口越宽它越宽:1920 宽的屏上弹出 1152×700 的面板,右边一大片空白,第一节还没显示完。

现在改成按内容量(source/js/fomal.js 新增 winboxNeedSize()):

  • 宽度 = 首按钮左缩进 + 前 5 个 .swf 按钮的宽度与外边距 − 末位右边距 + 容器左右内边距 + 面板装饰宽 + 10px 余量。为什么是 5 个?因为「字体设置」第一行正好是 5 个按钮,能排下不折行,宽度就够了;
  • 高度 = 第一节最后一行(#asidePosSet 所在那一行)的底边 − 面板顶 + 下内边距 + 6px;
  • 上下限 620×420,再用视口的 95% × 92% 夹一道;量不到(元素还没渲染)就回退到 780×540。

配套改了 winResize():桌面分支做两趟量 —— 先把宽度定下来,再量一次高度(宽度一变,行是否折行才确定,高度才是最终值),最后 move('center', 'center');toggleWinbox() 里 createWinbox() 之后补一次 winResize()(createWinbox 是先 resize 再写 innerHTML 的,写完内容尺寸才准)。

实测:1423×736 下从 854×515 变成 802×534;1920×1000 下从 1152×700 变成 802×534 —— 不再随窗口变宽;800×744 下是 752×681(被上限夹住)。手机端分支(≤768px,95% 宽 / 90% 高)一行没动。

三十二、给 139 个友链做了一次体检:81 个搬进「失联友链」

友链页越攒越多,「茫茫人海」那一组里有 118 个站点,早就不知道哪些还活着。

这次做了一件笨但有用的事:把 0~3 这四个分类里记录在册的 139 个在线友链全部探测一遍,判定顺序是 HTTPS 请求(跟随跳转)→ HTTP 回退 → 直连 TLS → 公共 DNS(DoH)复核 → 首轮失败的再重试一次。结论是 81 个已经打不开,全部搬进「4.失联友链🎈」,那一组从 10 条变成 95 条:

分类改前改后移出
0.推荐网站🍔413
1.技术支持🍟1394
2.宝藏网站🍉301614
3.茫茫人海🍗1185464
4.失联友链🎈1095+81

失联的原因也分了类:55 个是域名解析都没了(NXDOMAIN,用公共 DNS 复核结果一致),其余是域名被抢注 / 劫持成垃圾站、TLS 证书过期或域名不匹配、连接直接超时。过程留在四个文件里:probe-pass1.json(首轮 200 判定与页面标题)、probe-dns-doh.json(53 个 DNS 死站)、probe-nondns-pass2.json(超时与 TLS 类的二次复核,比如「海底捞里没有鱼」是 UND_ERR_CONNECT_TIMEOUT、「鹤川博客」是 ERR_TLS_CERT_ALTNAME_INVALID)、move-plan.json(每一条的搬迁原因:dns 还是 tls:…)。

搬家的写法很克制:只加两行 —— theme_color: "#ebebeb" 和 offline: true,原来的头像、简介、截图都留着 —— 因为站是站长的,不该因为一年打不开就替人删掉,归到最后一组、头像变成灰度,等站长修好了再搬回来。改动只落在 source/_data/link.yml,主题源码一行没碰。

页脚也跟着换血。 页脚那 8 个位置里,有 4 个(Ethan.Tzy / 小染Blog / 初柒工坊云 / Leonus)都在这份失联名单里,索性换成了「广告位招租」的占位图(ad.webp),保留的 4 个是 Fomalhaut🥝 / Butterfly / Akilarの糖果屋 / 安知鱼 —— 8 格的排版没变,只是不再挂打不开的链接。

三十三、侧栏多了日历和倒计时两张卡

侧栏原来有一张「通讯录」卡,下面是公告栏。这次在公告栏上面插了两张自绘卡片(themes/fomalhaut/layout/includes/widget/index.pug 里把 card_top_self 从公告栏下方挪到上方,文章页和非文章页两个分支都改了):

  • 日历卡:「日历加载中…」占位之后换成「第 39 周 周四 / 01 / 2026 年 10 月 第 274 天 / 丙午马年 八月廿一」,下面跟一张当月迷你月历(今天高亮,表头是「日一二三四五六」);
  • 倒计时卡:「距离春节 / 128 / 2027-02-06」,再加本年、本月、本周三条进度条(含「还剩 N 天」)。

两张卡都是纯前端算的:source/js/aside-calendar.js(13795 字节)加 source/css/aside-calendar.css(6370 字节),卡片定义挂在 source/_data/widget.yml 里,每 60 秒重算一次 —— 跨零点会自动换日、换月历。周数按 ISO 口径(当年第一个周四所在的那一周算第 1 周),农历那行走 lunar.js 的 solar2lunar() 和 calendarFormatter.lunar2solar(),哪天农历库没加载进来,也只是少一行字,不会整张卡报错。

这里有个我记了很久的坑:不能拿 innerHTML 直接覆盖卡片容器。 widget.yml 里的 HTML 是这样写的:

1
2
3
<div class="cal-skeleton">日历加载中…</div>
<link rel="stylesheet" href="/css/aside-calendar.css">
<script src="/js/aside-calendar.js"></script>

innerHTML = ... 一执行,<link> 就从 DOM 里被摘掉了,样式表随之失效 —— 之前怎么改都不生效,就是栽在这儿。所以脚本里用 mount() 只替换挂载节点(优先 .cal-skeleton,其次 [data-cal-mount],都没有才追加一个新盒子),容器本身永远不动。调试时也能直接调:fomalAsideCalendar.compute() / .setNow(new Date(2026, 9, 1, 21, 0, 0)) / .renderAll();source/cal-test/index.html 是留给它的预览页。

另外按要求把侧栏的「通讯录」(友链)卡隐藏了:_config.fomalhaut.yml 里 aside.card_friend_link.enable: false。要注意 hexo server 只在启动时读主题配置,运行期改了不会重新渲染,所以 aside-calendar.css 末尾补了一条兜底 #aside-content .card-friend-link { display: none } —— 重启之后卡片本身不再输出,这条规则就自动变成空转。

三十四、202 张图,一个文件

画廊的「一些好看的图片」这个页面,之前是用很蠢的办法分页的:photo/ 目录下 p2.md 到 p17.md 一共 17 个文件,每个文件里手写 12 张图、末尾再手写「上一页 / 下一页」的链接;壁纸页也一样,风景、萌宠、手机三个入口各是一个文件。加一张图要翻到对应那一页,改页码要连着改好几个文件。

现在改成:一个相册只有一个 markdown 文件,图片按顺序一行一张往下贴,分页交给构建期生成。front-matter 里写一行 gallery_per_page: 12 就够了,hexo generate 的时候 scripts/gallery-pager.js 把这个页面按 12 张一页切开,生成 index.html、p2.html、p3.html……;翻页条直接用主题自带的 paginator helper(prev_text / next_text / mid_size 与首页列表页逐字一致),不手写一个 <a>。

几个细节值得记一笔:

  • 旧链接不 404。 p2.html~p17.html 这些路径名不变,只是内容从「你手写的那一页」变成「按顺序数的第 N 页」,站外引用的老链接照样能打开。
  • 想按主题分段,加一行注释就行。 在交界处写 <!-- pagebreak -->,生成器就地断页。
  • scripts/ 目录不被 hexo server 监听。 新增或修改 gallery-pager.js 之后必须重启 hexo server,否则页面会退化成「所有图片挤在一页、没有翻页条」—— 不报错、不空白,只是静悄悄没生效。

photo 现在是 202 张图一个文件、17 页;壁纸 54 张、5 页。

三十五、画廊大厅的两张卡片居中

画廊首页就两张卡片(封面专区 / 背景专区),一直靠在左边,右边空一大块。查下来靠左不是配置项,而是主题样式写死的:

1
#article-container figure.gallery-group { float: left; width: calc(50% - 8px) }

float: left 把两张卡片按 50% 宽度往左推,行尾自然空一半。主题源码能改,但主题一升级就丢,所以放在 _custom/custom.css 里覆盖:容器改成 flex + justify-content: center,卡片 float: none,宽度顺手从 50% 收到 calc(33% - 11px),窄屏再退回两列。

三十六、「休闲」栏目下线

三个要求是一起做的:删掉休闲下的影院子页面、把休闲里的「八音盒」改名「影音」并挪到八宝箱第一个、然后整个休闲栏目拿掉。

改动落在 _config.fomalhaut.yml 的菜单表里:删掉「休闲」父项与「影院」子项;「八音盒」改名「影音」、/life/music/ 路径不动、整体上移到八宝箱首位;source/life/music/index.md 的 title 跟着改;source/life/movies/ 整目录删除(原来那页 193 行)。改完还要手工清一次 public/life/movies/ —— 源目录删了,产物还在。

三十七、归档页不要侧栏,宽屏页面收窄

两件跟宽度有关的事。

一是归档页的右侧栏:_config.fomalhaut.yml 第 947 行 aside: archive: false,归档页不再输出侧栏,正文占满。归档页本来就是按年翻列表,侧栏那些卡片基本用不上。

二是给非首页的独立页面加了个宽度上限:

1
<style id="pageWidthLimit">.layout:has(#page),.layout:has(#archive),.layout:has(#tag),.layout:has(#category){max-width:1200px}</style>

:has() 正好能表达「页面里含 #page(独立页)/ #archive / #tag / #category 时收窄到 1200px」,首页和文章页不受影响;窄屏下 max-width 本来就不起作用,自动铺满。之前 27 寸屏上这些页面能拉到 1900px 以上,一行字 200 多字符宽,看着很累。

三十八、友链卡片的写死高度

友链页的卡片高度原来是写死的 —— 写死那会儿整页宽度是 1400px,后来页面收窄到 1200px,卡片变窄而高度不变,于是每张卡片都显得又高又瘦、图片上下留白。

现在把高度交给图片自己撑:.site-card:not(.mini-link) { height: auto !important } + .site-card .img { height: auto !important; aspect-ratio: 16 / 10 }。16:10 是原来那批截图的固有比例,宽度怎么变高度自动跟着走,一行 5 / 4 / 3 / 2 张四个断点都不用再调数字。顺手把悬停缩放也定了:.site-card:hover .img { scale: 1.02 !important }。

三十九、美化面板多了「三、字体设置」

美化面板加了一节「三、字体设置」,分两组:常规字体 7 款(霞鹜文楷 Screen / 霞鹜文楷 / 霞鹜新晰黑 / 思源宋体 / 筑紫 A 丸 2.0 / 汉仪唐美人 / 甜甜圈海报)加一个「系统默认」,代码块字体 4 款(Monaco / Consolas / Maple Mono / Hack)。

最值得说的是为什么代码字体要单独立一个变量:主题只给 #article-container pre/code 设了等宽字体,文章区之外的行内 code(侧栏、卡片、通知里的那些)根本不受影响。所以新加了 --code-font,并在 custom.css 里补一条覆盖面更广的规则:

1
pre, code, kbd, samp { font-family: var(--code-font, Monaco_1), var(--global-font), "Microsoft YaHei", … }

面板里点一下,JS 只做 document.documentElement.style.setProperty('--code-font', name),全站代码字体当场切换,选择记在 localStorage。

另一个坑是系统默认字体在 Windows 上其实是「四个字都不同」:原来那档的字体栈只写了 -apple-system,macOS 认,Windows 不认,于是 font-family: var(--global-font) 的元素(左上角站点名、首页大标题、副标题、作者名)直接掉到浏览器默认字体上 —— 我把 Chrome 的标准字体设成霞鹜文楷,那几个标题就变成楷体了。现在补成完整系统栈(-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Lato, Roboto, 'PingFang SC', 'Microsoft JhengHei', 'Microsoft YaHei', sans-serif),顺手把混进正文栈的 Consolas 拿掉。

字体名、@font-face、面板按钮 id(swf_ / swfc_ 加字体名)三处是一一对应的,改名要一起改。这次重命名了三个(汉仪唐美人、甜甜圈海报、筑紫 A 丸 2.0),下架了两个(MiSans、优设好身体);存量访客 localStorage 里的旧值会在打开面板时被白名单清掉,回落到默认。

四十、摸鱼牌开始认法定节假日

页脚那块「摸鱼牌」,以前按周末判断,现在按国务院的放假通知判断:休息日显示「放假啦,光明正大地摸鱼~」,工作日显示「上班偷偷摸鱼,别被发现~」。

数据用的是 holiday-cn 那份(只记录法定放假日与调休上班日,其余按周末算):2024~2026 直接内置在 fomal.js 的 HOLIDAY_TABLE 里,离线也能算对;2027 年以后自动从 jsDelivr 拉一份当年的 JSON,按年缓存在 localStorage;拉不到就退回「周末即休息日」,不让页脚因为一个接口挂掉。

副作用是调休终于正常了:假期前那个调休上班的周末会显示上班,假期里的周末显示放假 —— 之前那种「今天是周六所以显示放假,实际却要去上班」的错位没有了。

四十一、侧栏日历:农历、节气、日出日落

侧栏那两张卡这两天又长了一圈。

日历卡除了月份和当天,现在还有:ISO 周数(第 39 周)、农历日期(丙午马年 八月廿一)、当月迷你月历(今天高亮),以及两条小字 —— 下一个节气(「N 天后是霜降」,当天就显示「今天就是霜降」)和当天的日出日落时刻(☀️06:32 / 🌙18:07)。

日出日落是按访客位置现算的:优先用定位拿到的经纬度,拿不到就按访客时区推一个经度,再兜底到站长的纬度,用一套标准天文公式(儒略日 + 太阳赤纬 + 时角)算,两极那种算不出来的情况直接不显示。

倒计时卡加了本周 / 本月 / 本年的进度条与剩余天数,目标(现在是春节)支持按农历或公历指定。

还有一个默认行为要说明:只要访客没手动切过深浅色,这两张卡就会按真实日出日落自动切日夜模式;访客自己点过暗黑模式按钮之后就再也不会动它 —— 尊重手动选择优先。

实现上踩的坑也记一下:渲染结果必须塞进卡片里的一个挂载节点,不能 innerHTML 整个卡片。因为 widget.yml 里的 html 带着 <link rel="stylesheet"> 和 <script>,它们都是 .item-content 的子节点,一旦被覆盖掉,<link> 从 DOM 里移除、样式表按规范失效 —— 症状就是「CSS 完全不生效」。

四十二、天文馆:旅行者号现在飞到了哪里

八宝箱新增一个「天文馆」,里面是一张全屏的交互式太阳系星图:八颗行星的实时相对位置,加上两艘已经飞出日球层的探测器。鼠标移到任意天体上看数据,拖下方的时间条能看它们 80 多年的运动,切到「立体」还能把整个太阳系转起来。

页面是独立的 voyager.html(自带样式和脚本,在 _config.yml 里 skip_render 掉),用 iframe 嵌进文章页。两个小机关:

  • iframe 自己长高。 图表高度是动态的,子页面用 postMessage 发 voyager-height、父页面收到就改 iframe.style.height;反过来父页面在 DOMContentLoaded、load、以及 1.5 秒后各发一次 voyager-ping 主动问一次 —— 只做一边,慢网络下就会留一截滚动条。
  • 配色跟着站点主题走。 底色不是写死的,而是 color-mix(in srgb, var(--theme-color) 5%, #0d0d0d) 从站点主题色派生 —— 换主题色,这一页的整体色调跟着变。

页面上「发射时 470 W / 每年衰减约 4 W」、旅行者 1 号 3.567 AU/年、2 号 3.246 AU/年,以及「40.5 AU 外回头拍的暗淡蓝点」,都是算距离用的常量,不是随手写的文案。

四十三、给博客安了一个 AI 页面助手

右侧按钮列多了一个小鲸鱼,点开是「页面助手」:能读当前这一页的正文,回答跟这一页有关的问题;问「这个页面讲了什么」,它会先一句话概括、再列 3-5 条要点。

调用的是 DeepSeek 的接口,流式返回,字是边生成边出来的。几个设计上的取舍:

  • Key 不进仓库。 配置里 api_key 留空,改读站点根目录的 .ai-chat-key(已加 .gitignore)。
  • 上下文按页分桶。 对话历史存在 localStorage,但键里带页面路径 —— 文章页聊的和关于页聊的互不干扰,各自记 12 轮;本页正文最多 32000 字进上下文(超长文章取开头 60% + 结尾 35%,中间省略的部分只把小节标题给模型,让它直说「这段我没看到」而不是猜),面板底部实时显示「上下文 ≈ N 字 · 本页 N 字(全文 N,取了开头和结尾) + 对话 N 条」。
  • 面板可以拖着改大小。 右下角手柄缩放,尺寸记在 localStorage,不满意一键复位;手机上自动收成窄面板。
  • 超时保护。 「首字迟迟不来」和「中途卡住不再吐字」都算超时(每收到一段就重新计时),提示「N 秒没收到回复,稍后重试」;接口报错也有对应的中文提示,不把原始错误甩给访客。
  • 不依赖任何前端库。 按钮、面板、样式全部运行时创建,不用 jQuery、不用 Vue —— 上次刚把 905KB 的 Element-UI 请出去,没必要为了一个聊天框再请回来。

四十四、又是两个「静默失效」的 bug

这轮又逮到两个不报错、但确实已经坏了的功能。

一是首页「小冰磁贴」点进去跳到外站。 首页顶部那一排短链接(🍡 小Fの算法学习笔记 等)和末尾的「查看更多…」,点下去跳的是 www.fomal.cc,也就是生产站自己 —— 本地预览时一跳就出去了。原因是插件 hexo-magnet-fomal 拼 HTML 时把 config.url 硬拼在 href 前面,而它没有任何「链接前缀」配置项;直接改 node_modules 又会在下次装依赖时丢掉。最后在站点根 scripts/ 里加了个补丁:用 after_render:html 过滤器,只把 class="magnet_link" / "magnet_link_more" 的链接就地改成站内相对路径 —— canonical、og:url、sitemap、分享链接这些该绝对的地方一律不碰。

二是首页轮播「有时候图片出不来」。 具体是站内跳转回首页(pjax 往返)之后,轮播绕回那一下图片区域是空的,直接刷新首页反而正常 —— 所以是「有时候」。链条捋下来是三方时序问题:主题开了懒加载,图片 src 被换成占位图、真图放在 data-lazy-src,靠 main.js 里一个 LazyLoad 实例在登记的时候填回去;pjax 完成时主题先重新执行一遍轮播脚本(新建 Swiper),紧接着同步调了一次 lazyLoadInstance.update() —— 而 Swiper 的循环克隆 swiper-slide-duplicate 是在构造之后才插进 DOM 的,克隆永远晚于那次 update,于是循环到克隆位就没图。修法是在页面加载完成、pjax 完成、以及 MutationObserver 观察到轮播 DOM 变化之后,各补一次 update()。刷新不复现的原因也清楚了:正常加载时 defer 脚本先跑,LazyLoad 构造时克隆已经在 DOM 里。

四十五、APP 下载链接换源,右键模式按钮有了状态

两件小事收尾。

公告栏里「APP 下载」的链接原来指向缤纷云的对象存储,换成了 unpkg 上的 fomalhaut1998_picgodemo@latest/Fomalhaut.apk —— 应用包跟着 npm 包走版本,两边不用手动对齐,顺手把链接两侧的 🍧 去掉了。

侧栏「右键模式」按钮以前点一下会弹通知告诉你切成了哪种模式,但按钮本身看不出当前状态。现在给它加了状态显示:开启时跟随主题色,关闭时变成灰色底加一条 45° 斜杠,title 也会写清楚当前是站内右键还是系统右键。状态源和 fomal.js 里那份 localStorage 是同一个键,包装了 changeMouseMode() 在切换时同步刷新,pjax 换页后会重新应用一次。

四十六、统计页:三张图不再用别人的蓝色

网站统计页(/site/census/)是拼起来的:上方 51LA 的实时访客与总访问量、中间 GitHub 贡献日历、下面百度统计的三张图(访客地域分布 / 访问趋势 / 来源构成)。这一轮把这一页整个翻新了一遍,起因是两个毛病。

一是样式被当成了页面摘要。 原来整页的样式全写在这个 markdown 文件里,而 Hexo 会把页面开头那一段当摘要塞进 <meta name="description"> —— 搜索引擎和分享卡片拿到的是一坨 CSS。现在样式整段挪进 source/css/census.css(383 行),页面只留结构:外层 div.census,每个板块一个 section.census-sec,标题用 .census-sec__head / __icon / __text / __title 拼出来。

二是三张图用的是别人的蓝色。 地图主色是写死的 'rgba(128,255,165)'、趋势线 'rgba(1,191,236)'、无数据省份底色 'rgb(230,232,234)' —— 美化面板换主题色,这三张图一动不动。现在加了 censusRgbToHsl()(读 --theme-color,支持 rgb() 和 #hex 两种写法)配 censusHsl(h,s,l):把主题色转到 HSL,钳住饱和度(30–92)和明度(30–78),再生成一串同色相、不同深浅的色阶,地图四档(主色 / 浅色调低值 / 深色调悬浮 / 无数据省份底色)都从这串色里取。深色模式另走一套判断(censusIsDark() 看根元素的 data-theme)。

census.js 从 449 行长到 579 行,多出来的基本都是配色与两套主题的分支。

四十七、/tags/ 的标题变成了胶囊,图表跟着主题色走

文章统计页(/tags/)那三张 echarts 是主题的 helper 生成的:标题直接输出成 <center><font size="4">标题📃</font></center>,配色写死在主题脚本里(themes/fomalhaut/scripts/helpers/hexo_echarts.js 里的 #425aef)。

改的原则还是不动主题:新增 source/css/stats.css(269 行)与 source/js/stats.js(430 行)。样式全部挂在 #page:has(#posts-echart) 底下 —— 整个站只有这一页有那个 id,所以规则不会漏到别的页面去;浏览器不支持 :has() 时整块失配、页面退回原样,属于渐进增强而不是渐进破坏。

JS 做两件事:把 <center> 那个标题拆成「图标胶囊 + 标题 + 说明」,让排版和站内其他页面统一;再取主题内联脚本里暴露的全局 postsChart / tagsChart / categoriesChart,按 --theme-color 重新 setOption() —— canvas 背景置空(让卡片自己的底色透出来)、网格线和图例跟明暗模式走。触发时机三个:首屏、pjax:complete、以及主题色或明暗变化时。页面上找不到 #posts-echart 就直接返回,不做任何事。

四十八、朋友圈:59 行变 528 行,只为一面卡墙

朋友圈页原本是主题自带的列表样式,这次换成了网格卡片(内部叫「方案 A · 网格卡」):59 行 → 528 行。

规矩和上一轮一致:全部落在页内内联的 <style> / <script> 里,主题源码一行没改。配色不用写死的颜色,一律 color-mix(in srgb, var(--theme-color) X%, transparent) 从主题色派生,也刻意不覆盖朋友圈引擎自己的 --heo-* 变量 —— 引擎升级时这套样式不至于把它的布局打散。

两个细节是不得不动 JS 的:圆形头像在引擎输出里带着一圈「灰色残缺弧」(图片本身被裁过),用 clip-path 裁成圆把残边压掉;引擎输出里没有「来源域名」这个字段,所以底部补了一小段脚本注入「域名 + favicon」角标,顺手把顶部那几个统计数字填上。纯 CSS 拿不到的东西,才允许动用脚本。

三个候选方案都留了对比页(bak/2026-10-03-朋友圈改版/朋友圈改版预览-三版对比.html)。

四十九、友人帐:数量角标是 CSS 数出来的

友人帐那一页这次从 79 行写到了 302 行,改版的重点是卡片和配色,但我觉得最有意思的是那三处数字。

页面上要显示三种计数:每个分类标题上的「N 位」、每个分组那一排卡片的总数、以及页脚那行「— 共 N 位朋友 · M 个分类 —」。这三处没有一行 JS,也没有构建期统计,全是 CSS 计数器数出来的:counter-reset: fklall fklcat 开在容器上,每个分类 counter-increment: fklcat,每张卡片 counter-increment: fklall fklcard,然后 content: counter(fklcard) " 位"、页脚 content: "— 共 " counter(fklall) " 位朋友 · " counter(fklcat) " 个分类 —"。好处很直接:构建完就是死的静态文本,pjax 换页不用重算,也不用担心 JS 出错时数字消失。代价是计数器的重置点要写在正确的位置上 —— 每个托盘的 counter-reset 必须挂在那组 site-card-group 上。

配色是一个变量:--fkl-accent: var(--theme-color, #39c5bb),卡片、悬停、角标都从它派生。

顺手修掉两个旧毛病:悬停时是「青字配蓝底」(对比度很差),改成白字主题色药丸;页脚的「推荐友链」原来不足四个、里面还夹着我自己的卡片,现在补足 4+4 并把「0.推荐网站🍔」整组从我自己的友链数据里删掉(source/_data/link.yml 1131 → 1121 行)。

五十、八宝箱导航:四版迭代,78 个站点

八宝箱(网址导航)这一页从 98 行长到 381 行,最后是六个分类、78 个站点,原来的三个分类(算法之家 / 实用工具 / 上帝之眼)重新拆成了 AI 助手与评测 / 博客托管 / 图床服务 / 知识社区 / 实用工具 / 仰望星空。

卡片从「大截图」换成「圆形头像 + 标题 + 两行描述」的小卡 —— 和友人帐的小卡同源,两页看起来是一套东西。原来的站点大截图不删,只给 display: none:配合主题的 lazyload,它连下载都不会发生,等于白省流量。

图标全部自托管。 78 个图标(.png / .ico / .svg / .jpg)一起进了 source/box/nav/icons/。外链 favicon 一是慢、二是经常被墙,本地化之后这一页的打开速度是整个站里最快的之一。

四个版本的过程也都留着以备回看:v1「算法之家版」353 行 → v2 427 行 → v3 373 行 → v4 380 行(定稿)。改到第三版才想明白:真正要改的不是「再加一类站点」,而是先把卡片的信息密度降下来。

五十一、公告卡调了八版

侧栏那张公告卡这一轮前后改了八个版本,最后定型是「主线路 / 备用线路 / 手机 App」三段式:线路做成可点的胶囊(hover 时填主题色、白字),右侧用一行淡色小字标部署平台(Vercel / Netlify / Cloudflare / Render),手机 App 那行是一个下载按钮 + 内联 svg 图标 + 「3.3 MB」的说明。

八个版本大致分两类:前两版在改结构 —— v2 还是老写法(<center> + class="anno_content" + <font color>),v3 和 v4 换成了 a-row / a-chip 的胶囊结构,但标签还叫「线路一 / 备用」,并且多带一行「已运行 N 天 · 文章 N 篇 · 线路 4 条」的元信息(后来觉得这一行和侧栏资讯卡重复,砍掉了);后面五版都在改 CSS —— 换行、收窄以配合评论区样式、加宽、侧栏 360px、侧栏 380px,最后让它填满。

配色的核心是「浅色压深、深色提亮」:主题色在浅底上对比度不够,所以浅色模式下把它压到 62% 的明度(#25706b),深色模式下提亮到 78%(#7fe3da)。

最后那个宽度问题交给容器查询:卡片更窄时(窗口变小、或者侧栏收窄)自动降一档,不用再去猜一个固定像素值。

五十二、侧栏那张个人卡:玻璃、马克笔,和一个会看时间的状态

侧栏第一张卡(#aside-content > .card-widget.card-info,只作用于这一张,公告栏、最新文章、网站资讯那些卡片一律不受影响)这次做成了「磨砂玻璃 + 马克笔名字」。

玻璃是在卡内加一层 ::before:backdrop-filter: blur(11px) saturate(1.45) 加一个上浅下深的渐变,再用 mask-image: linear-gradient(180deg, transparent 0, #000 22%) 让最上面那一段变透明 —— 头像那部分于是露出卡片自己的背景图,像是压在玻璃底下。卡里所有内容都提到 z-index: 1 之上。

名字用黄色马克笔高亮加一点微倾。位置这里踩了个坑:一开始用 inline-block,结果侧栏一宽,名字就被挤到头像右侧同一行、看着像贴在头像右下角。换成 display: table; max-width: 100%; margin: .14em auto 0 —— 块级收缩盒,宽度还是跟着文字走,但一定另起一行、水平居中在头像正下方。深色模式下原来的白字压黄条对比度不够,改成墨字压黄条。

状态是头像右下角那颗 28×28 的小圆点。它原来永远写死一句「🎯 专注学习中」,现在会看时间了:睡觉中 zzz 😴(23:00–07:00)、放假摸鱼中 🏖️(周末 + 法定节假日)、上班摸鱼中 🐟(工作日 07:00–18:00)、下班玩耍中 🎮(工作日 18:00–23:00)。四个状态按顺序判断、命中即停;判定表用的是和页脚摸鱼牌同一份 2026 年放假与调休数据,年份不在表里就退回「周末即休息日」。鼠标移上去时小圆点从 28px 展到 105px 把文字露出来,pjax 换页后重新应用,并且每 60 秒校准一次。

夜间模式这一块来回比过六种名字配色和两档玻璃模糊(bak/card-redesign-20261003/ 里 18 张对照图),最后挑的就是上面这套。

五十三、贡献日历的小方块终于圆了

统计页那块 GitHub 贡献日历,每一格原来是尖角的 fillRect。这次把方块改成 30% 圆角。

不能用 ctx.roundRect(部分浏览器还没有),所以写了个 git_fillroundrect(ctx,x,y,w,h,r) 用 arcTo 画路径兜底;半径取边长的 30%,超过半边长时自动退化成圆/胶囊,不会画出畸形的格子。调用处只换了函数名,颜色、坐标、悬停逻辑一行没动。gitcalendar.js 337 → 351 行。

五十四、不蒜子换成 vercount,以及那个转不完的遮罩

这两件事都落在「访客计数」这条链上。

换源:文章页脚那行访问量原来由不蒜子提供(busuanzi.ibruce.info),多地访问不稳定,现在换成 vercount(events.vercount.one):preconnect 换掉、脚本源换掉、文章页的 PV 位置补一个「—」占位。顺带把加载期的转圈图标删了 —— 数还没来的时候显示「—」比转一个圈更诚实。之前为了让不蒜子不阻塞渲染而做的「脚本搬到底部异步加载」补丁,换源之后也就不需要了,一并撤掉。

遮罩 bug 是用户先发现的:进 /tags/ 再点别的页面,页面会整份刷新,而且加载遮罩一直转。根因在主题的 pjax 配置里 —— pjaxSelectors 写死了三个只存在于 /tags/ 的选择器(#tag-echarts / #posts-echart / #categories-echarts)。pjax 每次换页都要比对新旧页面的选择器数量,数量不等就抛错:DOM doesn’t look the same on new loaded page: '#tag-echarts' - new 0, old 1。这个异常被上层捕获后只触发 pjax:error、不触发 pjax:complete,而关遮罩的 preloader.endLoading() 恰好挂在 pjax:complete 的最后一句 —— 于是错误分支里页面被强制整份跳转,遮罩却没人关,一直转。

修法是运行期把这三个选择器从 selectors 里摘掉(那张图仍然由 '#body-wrap' 那一项重新执行,不会漏图),再用访问器接住 window.Pjax 的每次赋值、把滤镜包一层,防止主题在换页后把列表还原回去。兜底三处:pjax:send 后 6 秒还没 complete、pjax:error、从 bfcache 恢复,都强制 endLoading()。回滚只要删掉这个文件和配置里那一行。

五十五、Twikoo 评论区跟着主题色走了

评论用的是 Twikoo 2.x(registry.npmmirror.com 上的 twikoo.all.min.js,评论卡片宽度与头像那部分本来就在 custom.css 里单独调过)。原样能跑,但和站内卡片的语言对不上,所以新增 source/css/twikoo.css(656 行)做了一层美化。

两个原则。一是只写 .tk-* 类名 —— Twikoo 1.x 那批 .el-* 类完全不动,以后升级 Twikoo 也不会被这套样式拖住。二是配色不写死:主色就是 --tk-accent: var(--theme-color);按钮这种实心场景需要更深的底,用 hsl(from var(--tk-accent) h s 30%) 把明度直接压到 30% —— 因为满饱和度的主题色(默认 #39c5bb)在白底上对比度只有 2.1,按钮文字根本看不清。每个用 color-mix() 的变量前面都留一行 rgba(...) 回退值,老浏览器不至于整块失效。

五十六、把打赏关掉,页脚补上「听点音乐」

打赏暂停。 微信和支付宝两张收款二维码从「关于」页撤下,文章底部的「投喂作者」按钮也一起关掉(配置里 reward 关)。关于页原来那段「投喂方式」的 tab 换成一段说明:投喂栏目 2022-12-7 开启,此前的所得都用在建站上,历史投喂名单继续保留在下面。

页脚多了「听点音乐」。 页脚那几行链接里,最后一行只放了「网站统计」一个,右半边空着 —— 因为 custom.css 里 .ft-links li a 写的是 width: 50%。补上「听点音乐」指向 /life/music/,两列刚好填满。

为什么用注入脚本(source/js/footer-music.js,40 行)而不是改主题:页脚那几行的链接是写死在 footer.pug 里的,主题的 footer 配置只有 owner / custom_text / copyright 三项,inject 也没有「注入到页脚内部」的位置。所以走脚本,带 data-ft-music 做幂等,pjax 换页后重补一次。

八音盒那页顺手补了内容:歌单来源(网易云「温柔治愈:所幸世上有光 总会相逢」,71 首)和四条热评(3227 / 6275 / 4.7 万 / 1.1 万赞)—— 只有一个播放器的页面太素了。

五十七、版权卡里的链接不再是写死的域名

症状:文章底部版权卡里的「文章链接」点下去会跳到 www.fomal.cc。主站上没问题,但从镜像线路或 localhost 打开时,它会把访客带回主域。

根因在主题模板 post-copyright.pug:链接取自 page.copyright_url 或 page.permalink,再交给 hexo 的 url_for —— 而 url_for 对 http(s):// 开头的地址是原样放行的,所以写死的绝对地址一路穿到页面上;主题的 post_copyright 配置只有 enable / author / license 那几项,没有「链接用相对还是绝对」的开关。

改法是在渲染期只改版权卡里那一个 <a>:scripts/post-copyright-local-link.js 把 href 换成站内相对路径 /posts/xxx.html,显示文字也一起换;浏览器里再由 source/js/inject/pc-local-link.js 补上当前访问域名。canonical / og:url / sitemap / 分享按钮 / 图片 CDN 这些「本来就该绝对」的地方一律不碰。顺手还修了浅色主题下版权卡的配色。

五十八、Service Worker 的第二版:从「只换源」到「真的快」

上一版 SW 的本意是给缤纷云的图加缓存,实际上只干了「换源」一件事,有两个 bug:

  • 版本号读的是 db.read('blog_version'),但 KV 里从来没有人写过这个键 —— 每次读出来都是空,缓存判断永远走不到,等于白装上;
  • 拼缓存 URL 的 fullpath() 把查询串丢了,于是缤纷云的图片处理参数(?w=400&fmt=webp&q=73)全部失效,缓存下来的是原图。

新版(177 行)把这两件事都补上了:站点版本改成 index.html 的内容哈希写进 KV,版本一变就清空资源缓存;查询串原样带到对象存储。另外只介入白名单域名 —— TARGET_HOSTS = ['www.fomal.cc','fomal.cc','www.fomal.cn','fomal.cn'],netlify / cloudflare / render / github.io 那几个备用线刻意排除在外:它们本来就是「万一主域挂了」时的退路,不该被主域的缓存逻辑影响。

抓取策略:HTML 回源优先(拿不到再吃缓存),其余资源缓存优先、超过 TTL 后台静默校正一次,任何异常都回退到浏览器原始请求。SW 最忌讳的行为是「用户点不动页面」,所以每个分支的兜底都是「放行」。

五十九、一天之内拆掉三座屎山

这一天的后半程没再动界面,都在拆三个堆了很久的东西:source/js/fomal.js(13 万字节)、_config.fomalhaut.yml(2037 行)、custom.css(4124 行)。

原则先说清楚:能走配置就不改主题源码(themes/ 下的 pug / styl / js 一个都没动)、每个阶段先做可校验的备份、所有新增和改动的代码都带中文块注释。分四步:

P0 止血。 翻代码时发现,全站的 console.log / warn / error 早就被整体置空了 —— 当年大概是嫌控制台吵,于是这三年的脚本报错都被静音了。现在只静音 log,error 和 warn 永远保留,?debug=1 或在本地时 log 也照常输出。同时移走三个被一起发布的 fomal.js.bak-*(369KB 死 JS),并修掉 gulpfile.js 的 compress 任务没有 return 的问题(terser 还没写完,gulp 就退出了)。

P1 拆 fomal.js。 2973 行单体 → 105 行主引导 + 10 个模块/数据文件:data/voyager1.js(114 行,旅行者号距离模型)、data/holidays.js(72 行,法定休息日 + 来年 CDN 拉取)、modules/console-art.js(100 行)、modules/shell.js(278 行)、modules/effects.js(253 行)、modules/reading.js(109 行)、modules/nav.js(217 行)、modules/cursor.js(670 行)、modules/footer-time.js(64 行)、modules/settings.js(1253 行,最大的一块)。不套 IIFE —— 主题模板里到处是内联 onclick,这些函数必须留在全局。验证:逐文件 vm.Script 编译、全局重名扫描、构建 368 个文件、浏览器四个页面零异常且 21 个全局入口全在。

P2 把配置里的内联代码抽干。 inject 段里塞了 26 段内联 style / script(约 647 行),抽成 source/css/site-inject.css(10 个样式块合并)和 source/js/inject/ 下 6 个脚本,配置只留 <link> / <script src>,配置文件 2037 → 1482 行。抽的时候逐字比对、YAML 解析、构建 375 个文件,再用浏览器的注入探针验证:滚动锚点 70px、公告卡 70 条规则、侧栏 360px、翻页按钮圆角,全部命中。

P3 整理 custom.css。 4124 → 3936 行(净删 341 行代码,另加 152 行文件头索引)。删的是真死代码:侧栏卡片动态渐变整段、一条 order: 3px solid(border 的笔误,浏览器本来就丢弃它)、没有人引用的 @keyframes、已经下线功能的整段(TianliGPT 189 行)、与无前缀版逐字相同的几个前缀 @keyframes,以及 42 条可以证明被更晚规则覆盖的前缀声明。判据是「只有较早那块声明的每个属性,都被同选择器的更晚块声明过,才可删」—— 11 组重复选择器里只有 1 条满足。

这一步的验证方式值得单独记:把改前改后的 CSS 分别挂进无头 Chrome,遍历 document.styleSheets 导出「媒体上下文 + 选择器 + 属性 = 值」的快照逐条比对 —— 559 条 → 558 条,唯一差异就是那条 order:。这种清理最怕「看起来删干净了」,而层层覆盖的样式单看文件根本数不清,只有让浏览器把最终生效的那份摊开给你看才算数。

文件头还写了 31 条分节目录、25 个媒体查询 / 19 个 @keyframes / 11 个 @font-face 的索引、6 条改动约定,以及 6 条「故意不改、只记录」的已知问题 —— 比如 .settings input[type="checkbox"] 的 transition 漏了时长(本意大约 .2s),因为不动行为所以只留条记录。

六十、AI 助手的 Key 挪到了服务端

前端那个 AI 页面助手,Key 原来放在站点根目录的 .ai-chat-key 里(不进 git)。只有站长自己在本机跑起来才用得上 —— 一旦想让访客也能用,Key 就不能再进浏览器。

于是新增了 api/chat/completions.js(171 行,部署在 Vercel 的 Serverless 函数)和 vercel.json:真 Key 只存服务端环境变量 DEEPSEEK_API_KEY,前端 api_base 改成同源 /api。顺带做了三件必要的门:来源白名单(默认 fomal.cc / www.fomal.cc,*.vercel.app 预览自动放行)、每 IP 每小时限流(默认 120 次)、以及 max_tokens 上限 8192、messages 最多 24 条、请求体上限 400KB。

用不用这套代理由构建期决定:scripts/ai-chat-inject.js 发现环境里有 DEEPSEEK_API_KEY,就把前端配置改成走 /api,否则保持本机直连 + 读 .ai-chat-key。同一个仓库、两种运行方式,不改配置。

接口也能自检:GET 一下会返回 {"ok":true,"mode":"proxy","hasKey":true} 这一类的信息,部署对了没有一目了然。

六十一、最后说两句

回头看这次维修,我大概有这么几个感受。

第一,老站的问题往往是"你以为它在工作"。 MathJax 那个字段挂在配置文件里三年,主题从来没读过;Element-UI 拖着 905KB,实际只用了个 Toast;背景图那 4 个 CSS 变量干脆没有人定义过。这类问题不会报错,只会让你觉得"网站好像有点慢,但也能用"。

而且它们不是某天一起坏掉的,是一件一件慢慢失效的 —— 今天死个 CDN,下周死个图床,隔几个月死个接口。每次只坏一点点,每次都不影响你读文章,于是就永远轮不到修。等哪天真的攒够了,你会以为是"网站突然出问题了",其实是三年的账单一起到期。

第二,判断性能不能只看单点。 换源之前我以为 CDN 就是瓶颈,结果一测发现我自己首页的 TTFB(693ms)比任何一个 CDN 都慢。省下来的那几百毫秒,可能还不如把 Vercel 的接入优化一下。做事要算总账。

第三,AI 帮了大忙,但坑还是得自己踩。 这次 DeepSeek Harness 干了绝大部分体力活:翻遍 fomal.js 3800 行找到全部 14 处 Vue、逐个探测几十个 CDN 的存活和速度、写通知组件、跑构建验证、统计产物里的资源分布。这些活儿如果我自己干,大概是熬两个晚上。

但有几个判断它替不了我:

  • 905KB 的 Vue 到底删不删 —— 删错了整个站的弹窗全没了,这个风险得我拍板
  • 文章正文里出现 cdn.staticfile.org 到底是"资源没换干净"还是"文章历史记录"—— 它一开始也搞混了,把那些字样当成漏网之鱼,后来才想明白要看标签属性而不是全文匹配
  • 最后要不要合并提交、什么时候部署

它会犯错,也会自我纠正;它给的结论要验证,但验证的成本比从头做低太多了。

不过,上面这些其实都还是第一轮。 9 月 30 号那晚算是急救 —— 把真正死了的东西找出来、换掉、验证。后面两天多则是我自己主动想改的:首页从 4×3 变回 3×3、图墙从 flex 换成 grid、菜单砍掉四个入口、相册从 17 个手写页面收成一个文件、侧栏添了日历和倒计时、还给博客塞了个 AI 页面助手。修坏掉的东西只是止损,改这些才是我当初搭这个站的原因。

第四,很多问题不是读代码读出来的,是量出来的。 图墙那段最典型:我一开始只知道「有时候一行排不满」,盯着 CSS 也看不出所以然。最后是把页面在 320 / 390 / 414 / 579 / 768 / 1280 六个宽度下各截一张图,一张一张数格子,才看清固定宽度的 flex 会在 579px 这种宽度上剩出 194px 的空档。这类问题只有截图和数字能证明,源码里找不到答案。

第五,静默失效也长在自己写的代码里。 换源那轮死的是外部依赖,这一轮的坑是自己埋的:首页轮播里 lazyLoadInstance.update() 比 Swiper 克隆节点早了一步,于是循环到克隆位就没图;用 innerHTML 整体覆盖卡片容器,把 <link> 从 DOM 里摘掉,样式表跟着失效;首页磁贴的链接被插件写死成绝对地址,点一下就跳出站。它们全都不报错,表现只是「有时候」「偶尔」「这里怎么没生效」。 两条教训收下:带 <style> / <link> 的容器永远别整体覆盖,只替换挂载节点;懒加载遇上会自己克隆节点的组件,update() 的时机要往后放。

第六,也给自己留了退路。 这三天一共留下 57 组 bak/ 备份目录,每组里是改动前的文件加一页回滚说明。改坏了不用翻 git 历史猜,把 before__ 开头的文件覆盖回去就行 —— 对我这种经常在凌晨两点改 CSS 的人,这比什么技巧都重要。

第七,顺手把这几天的数字记一下: 砍掉 905KB 的 Vue + Element-UI,换成 7KB 的自研通知;139 个友链条目体检,81 个失联搬进「失联友链」;相册从 17 个手写页面收成 1 个文件;美化面板从不能选字体变成 11 款(常规 7 + 代码 4);侧栏多了两张卡;博客多了个 46KB、零依赖的 AI 助手,Key 存在 .ai-chat-key 里不进 git。全部改动的文件清单在文末,一共 154 条 —— 这篇记录本身,也从当初的十来节写到了 61 节。

第八,最贵的 bug 是不报告的那种。 拆 fomal.js 的时候才发现,全站的 console.log / warn / error 早就被整体置空了 —— 当年大概只是嫌控制台吵,结果是这三年里任何脚本挂掉,浏览器都安静得像什么都没发生。现在只静音 log,error 和 warn 永远保留,?debug=1 或者本地打开时 log 也照常输出。省掉几行噪音的代价,是三年看不见的错误。

第九,连记录自己都会写错日期。 这一天留下的备份目录里,有一批名字写的是 20261004、20261005,其实全都是 10 月 3 号建的 —— 另一个会话以为自己活在两天后。文件的修改时间不会骗人,名字会。所以前面那句「它给的结论要验证」,也包括验证它自己起的文件名。

要说还有什么意外,那就是连这份记录本身都出过 bug:旧时光那页交给 AI 顺手维护,它写漏了一个时间轴的闭合标记,结果那一整天的记录渲染成一堆没有日期的条目、编号从 1 重新开始 —— 是我自己打开页面觉得不对,才发现标记少了一个。所以前面那句话得再说一遍:它给的结论要验证,包括它替你写出来的文档。

所以这次的经验大概就是:把"翻文件、查资料、跑验证"交给它,把"要不要动、动到什么程度"留给自己。


就这样,网站又续了几年命 🍬

如果这篇对你有帮助,或者你也发现自家站里藏着个 900KB 的巨物,欢迎在评论区聊聊 —— 说不定你的比我更离谱。

本文涉及的文件清单(都在 [BlogRoot] 下):

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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
_config.fomalhaut.yml                     26 处改动(换源 / preconnect / 背景内联默认值 / jQuery 兜底)
_config.yml skip_render 加 1 行
source/js/fomal.js 14 处 Vue 替换 + 背景机制重写
source/js/notify.js 新增 7150 字节
source/js/jquery.min.js 新增 89501 字节(jQuery 兜底)
source/js/winbox.bundle.min.js 新增(无国内源的本地化)
source/js/tag-map/ 新增 5 个文件
scripts/tag-map-local.js 新增 1369 字节
themes/fomalhaut/source/sw.js 重写 98 行
themes/fomalhaut/layout/includes/layout.pug 本地开发自动注销 SW
source/social/fcircle/index.md 3 处换源
source/_data/link.yml 2 处换源
bak/20260930-cdn-fix/ 第一轮备份
bak/20260930-optimize/ 第二轮备份
bak/20260930-swfix/ 第三轮备份(Service Worker)
bak/20260930-bgfix/ 第四轮备份(背景图)

(以下为 2026-09-30 深夜追加的一轮)
package.json 删掉 hexo-renderer-kramed(渲染器竞态根因)
source/js/fomal.js 新增旅行者 1 号距离模型 + 欢迎信息改版
_config.fomalhaut.yml 作者卡片按钮 / 公告栏精简(主题改名后的配置文件名)
themes/fomalhaut/source/css/_custom/custom.css 首页卡片箭头改圆角
themes/fomalhaut/scripts/events/welcome.js 主题启动横幅
themes/fomalhaut/scripts/events/init.js 改名后的弃用提示
themes/fomalhaut/package.json name 改为 hexo-theme-fomalhaut
_config.yml theme: fomalhaut
bak/renderer-check/ 渲染器故障排查记录与备份
bak/20260930-voyager/ 旅行者 1 号模型备份
bak/20260930-authorcard/ 作者卡片备份
bak/20260930-arrow/ 箭头圆角备份(含预览图)
bak/20260930-banner/ 启动横幅备份
bak/20260930-rename/ 主题改名备份
(以下为 2026-10-01 凌晨追加的一轮)
themes/fomalhaut/scripts/helpers/hexo_echarts.js 标签统计图排序修复
themes/fomalhaut/layout/includes/footer.pug 页脚徽章更新(Theme-Fomalhaut / CDN-npmmirror)
source/assets/badge/ 新增 2 枚自制徽章 SVG
source/social/fcircle/index.md 朋友圈数据源改为仓库静态 data.json
source/js/fomal.js 滑条增强 + 「随便逛逛」同源修复
_config.fomalhaut.yml 新增首屏预置脚本 beautyBoot
themes/fomalhaut/source/css/_custom/custom.css 滑条可点击区 5px → 23px
bak/20260930-echarts/ 标签统计图脚本备份
bak/20261001-article/ 本轮文章与旧时光备份
(以下为 2026-10-01 下午追加的一轮)
source/js/census.js 百度统计 token 更新 + 三图兜底 + 地图跟随主题色
source/site/census/index.md 51la 增加「总访问量」+ 千分位 + 抓取兜底
source/js/gitcalendar.js 自托管并打补丁(14564 字节)
source/css/gitcalendar.css 自托管(3045 字节,与原版逐字节一致)
_config.yml 贡献日历改走静态 data.json(第 239 行)+ css/js 指向本站(第 252-253 行)
themes/fomalhaut/source/css/_custom/custom.css 滚动条条纹 + 标准属性回退 + 猫咪点击穿透
source/js/fomal.js 猫咪:报时防重入 + 指针命中判定
bak/20261001-baidu-token/ bak/20261001-chart-fallback/ bak/20261001-theme-color/ 统计页三处备份
bak/20261001-statistic-order/ bak/20261001-statistic-numfmt/ 51la 统计备份
bak/20261001-gitcalendar-jsonurl/ -own-api/ -selfhost/ -planb/ -planb-live/ 贡献日历四次改动备份
(以下为 2026-10-01 傍晚追加的一轮)
source/js/festival.js 新增 14199 字节(30 条节日表 + 左上角卡片)
source/js/lunar.js 新增 24401 字节(农历 / 二十四节气,从 fomal.js 搬出)
source/js/fomal.js 节日代码搬走(3961 → 2799 行)+ 运行时长按年 + 手机端自绘滚动条
source/js/notify.js 7369 字节(补左上角位置,节日卡片要用)
_config.yml index_generator.per_page 12 → 9(第 84 行)
_config.fomalhaut.yml 侧栏「最新文章」下线(第 967 行)+ lunar.js / festival.js 注入(第 1263-1265 行)
scripts/sticky-post.js 新增 2753 字节(正文标记置顶)
themes/fomalhaut/layout/includes/mixins/post-ui.pug postStickyBadge 置顶角标(第 143 行)
themes/fomalhaut/source/css/_custom/custom.css 置顶角标 + 图墙 grid 化 + 取色框 + 手机端自绘滚动条
source/_posts/2022-08-09-Markdown语法与外挂标签写法汇总.md 正文加置顶标记
bak/20261001-homepage-3rows/ bak/20261001-bgbox-grid/ -desktop/ bak/20261001-define-colors/
bak/20261001-welcome-loading/ bak/20261001-uptime-years/ bak/20261001-sticky-post/ 本轮七组备份

(以下为 2026-10-01 深夜追加的一轮)
source/js/celebrate.js 新增 6946 字节(全屏礼炮,命中喜庆节日才按需加载)
source/js/festival.js 16 条节日加 celebrate 标记 + 礼炮按需加载(第 376-395 行)
source/tags/index.md type 改 echarts、标题改「文章统计」(三页合一后的落点)
source/categories/index.md source/site/echarts/index.md 改为跳转页(type: redirect + redirect_to: /tags/)
themes/fomalhaut/layout/includes/page/redirect.pug 新增 488 字节(通用跳转页)
themes/fomalhaut/layout/page.pug 新增 when 'redirect' 分支(第 17-18 行)
themes/fomalhaut/layout/includes/page/echarts.pug 补「标签云」「分类列表」两块 + 标题移到图表外
themes/fomalhaut/layout/archive.pug 删掉页顶「文章发布统计」图(510 → 310 字节)
themes/fomalhaut/scripts/helpers/hexo_echarts.js titlePosition: 'outside'(三个 helper)
_config.fomalhaut.yml 菜单精简 + 文章统计入口 + 列表页翻页样式(inject.head)+ 通讯录卡关闭
themes/fomalhaut/source/css/_custom/custom.css 二级菜单居中(删 6 条写死偏移 + 覆盖 sub_menus 关键帧)
themes/fomalhaut/source/css/_layout/reward.styl 打赏弹窗居中 + 二维码 280 → 140px
source/personal/about/index.md 关于页两个收款码同步换源
source/js/fomal.js 新增 winboxNeedSize():美化面板尺寸按内容量计算
source/_data/link.yml 81 个失联站点移入「失联友链」(加 offline 标记)
themes/fomalhaut/layout/includes/footer.pug 4 个失效友链位换成「广告位招租」
source/_data/widget.yml 侧栏新增日历卡 / 倒计时卡
source/js/aside-calendar.js 新增 13795 字节(日历 + 倒计时 + 农历 / ISO 周)
source/css/aside-calendar.css 新增 6370 字节(含隐藏通讯录卡的兜底规则)
themes/fomalhaut/layout/includes/widget/index.pug card_top_self 挪到公告栏上方
source/cal-test/index.html 日历卡预览页
source/personal/bb/ source/personal/love/ 整目录删除(唠叨 / 恋爱小屋)
bak/20261001-merge-tags-categories/ … bak/20261002-footer-ad-slots/ 本轮 15 组备份

(以下为 2026-10-03 凌晨追加的一轮)
scripts/gallery-pager.js 新增 6076 字节(相册按 gallery_per_page 自动分页)
source/box/gallery/photo/index.md 202 张图合并进一个文件(删 p2~p17 共 16 个页面)
source/box/gallery/wallpaper/index.md 54 张图合并进一个文件(删 p2~p4 共 3 个页面)
source/box/gallery/index.md 画廊大厅两卡片居中(规则落在 custom.css)
source/life/music/index.md 「八音盒」改名「影音」;source/life/movies/ 整目录删除
_config.fomalhaut.yml 菜单(休闲下线 / 新增天文馆)+ 归档页去侧栏(第 947 行)+ 非首页宽 1200px(第 1246 行)+ 右键模式状态(第 1248-1260 行)+ 字体栈 + ai_chat 段 + APP 下载换源
_config.yml skip_render 加 2 行(js/ai-chat.js、box/astronomy/voyager.html)
source/js/fomal.js 新增法定休假表 + 摸鱼牌按休息日切换 + 字体设置(7+4 款)
source/_data/widget.yml 日历卡 / 倒计时卡带上版本号
source/js/aside-calendar.js 13795 → 29039 字节(农历 / 节气 / 日出日落 / IP 定位 / 自动日夜 / 进度条)
source/css/aside-calendar.css 6848 → 8072 字节
source/box/astronomy/index.md 新增(iframe 引入 voyager.html,postMessage 自适应高度)
source/box/astronomy/voyager.html 新增 72906 字节(交互式太阳系星图,独立页面)
source/box/astronomy/img/ 新增 3 张图(探测器 / 金唱片 / 暗淡蓝点)
scripts/magnet-local-links.js 新增 4916 字节(首页磁贴链接本地化)
scripts/swiper-lazyload-fix.js 新增 3761 字节(修 pjax 往返后轮播克隆图空白)
scripts/ai-chat-inject.js 新增 3239 字节(站点级注入器)
source/js/ai-chat.js 新增 46498 字节(AI 页面助手前端本体)
.ai-chat-key 新增(DeepSeek Key,已加 .gitignore)
themes/fomalhaut/source/css/_custom/custom.css 画廊居中 + 字体 @font-face 重整 + 友链卡片 16:10 + 代码字体规则
themes/fomalhaut/layout/includes/header/nav.pug 导航三个按钮改内联 SVG symbol
source/personal/about/index.md 内容有变动(无备份可逐行对照,未在正文展开)
bak/2026-10-02-page-width/ bak/2026-10-02-site-card-height/ bak/20261002-app-download-link/
bak/20261002-gallery-autopager/ bak/20261002-gallery-center/ bak/20261002-remove-life-column/
bak/20261003-archive-no-aside/ bak/20261002-linkyml-offline-move/ 本轮八组备份
(以下为 2026-10-03 白天到深夜追加的一轮)
_config.fomalhaut.yml 26 段内联样式 / 脚本抽出(2037 → 1482 行)+ stats.css / twikoo.css 与 author-status / stats / footer-music / pjax-guard / webinfo-card / pc-local-link 注入 + 打赏关闭
_config.yml index per_page 9 与 skip_render 两行(js/ai-chat.js、box/astronomy/voyager.html)
source/js/fomal.js 2973 → 105 行(拆成 modules/ 8 个 + data/ 2 个)
source/js/modules/ 新增 shell / effects / reading / nav / cursor / footer-time / settings / console-art
source/js/data/ 新增 voyager1.js(旅行者号距离模型)、holidays.js(法定休息日)
source/js/inject/ 新增 beauty-boot / right-menu-state-boot / scroll-gap-fix / webinfo-card / pc-local-link / ft-ad-extra
source/css/site-inject.css 新增 275 行(原配置里 26 段内联样式合并成 10 块)
source/js/ai-chat.js AI 页面助手前端本体(1044 行)
scripts/ai-chat-inject.js 构建期注入器(有服务端 Key 就走 /api)
api/chat/completions.js 新增 171 行(Vercel Serverless 代理:来源白名单 / 限流 / 上限)
vercel.json 新增(函数 maxDuration 60、memory 512)
source/js/author-status.js 新增 87 行(睡觉 / 放假 / 上班 / 下班四态)
source/js/pjax-guard.js 新增 143 行(摘掉统计页专属选择器 + 遮罩兜底)
source/js/footer-music.js 新增 40 行(页脚「听点音乐」)
source/js/stats.js 新增 430 行(/tags/ 标题胶囊 + 图表跟随主题色)
source/css/stats.css 新增 269 行(作用域 #page:has(#posts-echart))
source/js/census.js 449 → 579 行(配色由主题色派生 / 明暗两套)
source/site/census/index.md 88 → 122 行(样式外移,结构改 section)
source/css/census.css 新增 383 行
source/css/twikoo.css 新增 656 行(Twikoo 2.x 评论美化)
source/js/gitcalendar.js 337 → 351 行(贡献日历方块 30% 圆角)
source/social/fcircle/index.md 59 → 528 行(朋友圈网格卡)
source/social/link/index.md 79 → 302 行(友人帐:数字全是 CSS 计数器)
source/_data/link.yml 删掉「0.推荐网站」整组(含自己的卡片)
source/box/nav/index.md 98 → 381 行(六类 78 站,四版迭代)
source/box/nav/icons/ 新增 78 个本地图标
source/life/music/index.md 补歌单来源与四条热评
source/personal/about/index.md 撤下打赏二维码,改成「投喂通道暂时关闭」
scripts/post-copyright-local-link.js 新增 123 行(版权卡链接本地化)
source/js/inject/pc-local-link.js 浏览器里补当前访问域名
themes/fomalhaut/layout/includes/widget/card_webinfo.pug 42 → 139 行(KPI / 时间条 / 年度柱状)
themes/fomalhaut/layout/includes/head/preconnect.pug 不蒜子 → vercount
themes/fomalhaut/layout/includes/additional-js.pug 不蒜子 → vercount
themes/fomalhaut/layout/includes/header/post-info.pug 152 → 150 行(占位「—」,去掉转圈)
themes/fomalhaut/source/sw.js 177 行(第二版:版本哈希 + 查询串原样 + 域名白名单)
themes/fomalhaut/source/css/_custom/custom.css 4124 → 3936 行(P3 清理 + 152 行文件头地图)
gulpfile.js compress 任务补 return
bak/2026-10-03-屎山重构/ P0–P3 文档与改动前快照(105 文件)
bak/2026-10-03-八宝箱导航改版/ bak/2026-10-03-友人帐改版/ bak/2026-10-03-朋友圈改版/ 三页改版记录
bak/20261003-webinfo/ bak/card-redesign-20261003/ 小站资讯卡与作者卡预览图
bak/20261004-census-redesign/ bak/20261005-stats-redesign/ 统计两页预览
bak/20261005-close-reward/ bak/20261005-footer-music/ bak/post-copyright-local-link/ 打赏 / 页脚 / 版权卡
bak/announcement-20261003-v3-wrap/ -v4-trim/ -20261004-twikoo-ui/ -v5-wider/ … -v8-fill/ 公告卡八版
bak/20261003-gitcalendar-rounded/ bak/20261003-toc-fix/ bak/aplayer-volume-check/ 本轮其余备份