<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>peroe 的博客</title><description>juluo</description><link>https://blog.peroe.cn/</link><language>zh_CN</language><item><title>27 篇文章的站内搜索：81 KB 索引与一次 304</title><link>https://blog.peroe.cn/posts/blog-site-search/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-site-search/</guid><description>静态站没有后端，站内搜索只能在构建期做索引、在浏览器里做匹配。这篇记录现在这套方案的实现与实测（索引 81 KB、首搜 422 ms），顺手修掉「每次搜索都重新下载索引」的问题，并说清它在多少篇文章之后会撑不住。</description><pubDate>Sat, 24 Oct 2026 15:40:00 GMT</pubDate><content:encoded>&lt;p&gt;静态站没有后端，所以站内搜索必须想在前面：&lt;strong&gt;索引在构建期生成，匹配在浏览器里做&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这个博客的方案很朴素：一个 &lt;code&gt;search.json&lt;/code&gt; + 前端子串匹配。27 篇文章跑下来够用，但拆开量一遍才发现，里面藏着一个&quot;每次搜索都重新下载索引&quot;的问题。&lt;/p&gt;
&lt;h2&gt;方案长什么样&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-site-search/02-index.webp&quot; alt=&quot;搜索索引的真身：26 条记录，字段是 title / description / content / link / published&quot; /&gt;&lt;/p&gt;
&lt;p&gt;构建期把每篇文章的标题、描述、正文和链接写成一个大 JSON：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[
  { &quot;title&quot;: &quot;…&quot;, &quot;description&quot;: &quot;…&quot;, &quot;content&quot;: &quot;…&quot;, &quot;link&quot;: &quot;/posts/…&quot;, &quot;published&quot;: &quot;…&quot; }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前端打开搜索面板时 &lt;code&gt;fetch(&quot;/search.json&quot;)&lt;/code&gt;，然后在内存里对每个字段做 &lt;code&gt;indexOf&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for (const post of posts) {
  for (const field of [post.title, post.description, post.content]) {
    const index = fieldLower.indexOf(keywordLower);
    if (index !== -1) { /* 截取命中位置前后 40 字的摘要 */ }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-site-search/01-how.webp&quot; alt=&quot;实现与实测：索引 81 KB、首搜 422 ms、条件请求 304&quot; /&gt;&lt;/p&gt;
&lt;p&gt;实测（本地 Chrome，首访）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;值&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;索引体积&lt;/td&gt;
&lt;td&gt;193 KB（UTF-8）/ &lt;strong&gt;81 KB（字符数）&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;条目&lt;/td&gt;
&lt;td&gt;26 篇（加密文章不进索引）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;首次 fetch&lt;/td&gt;
&lt;td&gt;422 ms（含下载）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;再次 fetch（默认缓存）&lt;/td&gt;
&lt;td&gt;226 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;修掉的那个问题：&lt;code&gt;no-store&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;打开搜索面板的代码原本是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;await fetch(&quot;/search.json&quot;, { cache: &quot;no-store&quot; });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;no-store&lt;/code&gt; 的意思是&lt;strong&gt;永远不要用缓存&lt;/strong&gt;——于是每次打开搜索都重新下载 81 KB。对桌面宽带无所谓，对移动网络就是每次多几十 KB。&lt;/p&gt;
&lt;p&gt;服务端给这个文件的是 &lt;code&gt;cache-control: public, max-age=0, must-revalidate&lt;/code&gt; + ETag，也就是&lt;strong&gt;允许缓存但每次要验证&lt;/strong&gt;。所以只要别写 &lt;code&gt;no-store&lt;/code&gt;，浏览器就会带 &lt;code&gt;If-None-Match&lt;/code&gt; 走条件请求：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /search.json
If-None-Match: W/&quot;39bf20e5ce2f5db47093048c0770cc81&quot;
→ HTTP 304 Not Modified（几百字节）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改成 &lt;code&gt;cache: &quot;default&quot;&lt;/code&gt; 之后，第二次以后的搜索只花一次往返，不再传那 81 KB。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;踩坑提示：&lt;code&gt;force-cache&lt;/code&gt; 在这里&lt;strong&gt;不能&lt;/strong&gt;用——索引文件名没有内容哈希，强缓存会读到发文章之前的旧索引。&lt;code&gt;default&lt;/code&gt; + 服务端 ETag 才是正确组合。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;这套方案的天花板&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-site-search/03-limits.webp&quot; alt=&quot;线性扫描与中文不分词带来的两个限制&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 线性扫描的代价随篇数增长。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;26 篇时遍历 26 次，用户感觉不到。但索引是&lt;strong&gt;全量下载&lt;/strong&gt;的——200 篇时大概 600 KB，每次搜索（哪怕只是第一次）都要传完才能搜。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 中文没有分词，只有子串匹配。&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;「性能优化」→ 能命中（四个字连写）&lt;/li&gt;
&lt;li&gt;「性能」「优化」→ 也能命中（子串）&lt;/li&gt;
&lt;li&gt;「性能 优化」（中间带空格）→ &lt;strong&gt;命中不到&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这不算 bug，但要知道它的边界：这套搜索本质是「整段文本包含关键词」，不是「按词检索」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;什么时候该换&lt;/strong&gt;：文章超过 100 篇，或者需要词权重、高亮、拼音搜索。那时候该上 Pagefind（构建期生成倒排索引 + 分片加载）或 MiniSearch（前端建倒排索引）。&lt;strong&gt;现在 27 篇、81 KB、首搜 422 ms，不值得换。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;一个取舍：索引里要不要放全文&lt;/h2&gt;
&lt;p&gt;现在 &lt;code&gt;content&lt;/code&gt; 字段放了正文，所以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;好处：能搜到正文里的细节（比如某篇文章里提到的 &lt;code&gt;cache-control&lt;/code&gt;）；&lt;/li&gt;
&lt;li&gt;代价：索引随文章数线性变胖。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;折中方案是只索引标题 + 描述 + 前 N 字。但那样会丢掉「搜正文」的能力，而技术博客里最常搜的恰恰是术语——所以我保留了全文，把「索引会不会太大」留给篇数增长时再决定。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;站内搜索不是&quot;接个库&quot;就完事，三个决定要先想清楚：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;索引在哪生成&lt;/strong&gt;：静态站只能在构建期（浏览器里建索引太慢）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;怎么匹配&lt;/strong&gt;：子串匹配足够简单，但要知道它不懂分词；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;怎么缓存&lt;/strong&gt;：索引是大文件，&lt;code&gt;no-store&lt;/code&gt; 会让每次搜索都重下——用 &lt;code&gt;default&lt;/code&gt; + ETag 走 304。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;三条都记下来之后，这套 60 行的方案就能一直用到文章上百篇。&lt;/p&gt;
&lt;p&gt;顺带一提，这套「构建期生成索引、浏览器里匹配」的思路，和&lt;a href=&quot;/posts/blog-build-size/&quot;&gt;构建产物与访客成本&lt;/a&gt;里讲的按需加载是同一个道理：能不进关键路径的，就别进。&lt;/p&gt;
</content:encoded></item><item><title>构建产物 16 MB，访客每次只下 28 KB</title><link>https://blog.peroe.cn/posts/blog-build-size/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-build-size/</guid><description>统计 dist 目录发现 16 MB、313 个文件，其中 1.6 MB 的 Mermaid 图表引擎从来没被下载过。这篇把「部署体积」与「访客成本」分清：什么进了关键路径、什么按需加载、什么可以被缓存一年。</description><pubDate>Sat, 24 Oct 2026 15:20:00 GMT</pubDate><content:encoded>&lt;p&gt;跑完性能体检后我一直有两套数字，但从来没把它们放在一起看：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;部署体积&lt;/strong&gt;：&lt;code&gt;dist&lt;/code&gt; 目录 16.04 MB、313 个文件；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;访客成本&lt;/strong&gt;：首页实际传输 &lt;strong&gt;27.9 KB&lt;/strong&gt;（brotli 压缩后）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;差 570 倍。这篇就把这个差距拆开——&lt;strong&gt;哪些体积是真的，哪些是错觉&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;dist 里到底装了什么&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-build-size/01-dist.webp&quot; alt=&quot;dist 构成：HTML 37%、WebP 31%、JS 21%&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;文件数&lt;/th&gt;
&lt;th&gt;体积&lt;/th&gt;
&lt;th&gt;占比&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTML&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;5.95 MB&lt;/td&gt;
&lt;td&gt;37.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP 配图&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;4.91 MB&lt;/td&gt;
&lt;td&gt;30.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;88&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.31 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20.6%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;字体&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;1.02 MB&lt;/td&gt;
&lt;td&gt;6.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XML（sitemap / rss）&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0.37 MB&lt;/td&gt;
&lt;td&gt;2.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON / CSS / SVG&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0.47 MB&lt;/td&gt;
&lt;td&gt;4.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;最扎眼的是 JS 那 88 个文件、3.31 MB。而最大的六个文件里有四个是&lt;strong&gt;图表引擎&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;585.3 KB  _astro/mermaid.core.js
481.0 KB  _astro/wardley.js
431.4 KB  _astro/cytoscape.esm.js
253.4 KB  _astro/katex.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Mermaid 全家桶 1.6 MB。&lt;/strong&gt; 而这个站目前&lt;strong&gt;一张 Mermaid 图表都没有&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;但访客从没下载过这 1.6 MB&lt;/h2&gt;
&lt;p&gt;因为它是&lt;strong&gt;动态 import&lt;/strong&gt; 的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let mermaidLoader: Promise&amp;lt;any&amp;gt; | null = null;
const loadMermaid = async () =&amp;gt; {
  if (!mermaidLoader) {
    mermaidLoader = import(&quot;mermaid&quot;).then((mod) =&amp;gt; mod.default);
  }
  return mermaidLoader;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只有当页面里真的出现 &lt;code&gt;```mermaid&lt;/code&gt; 代码块时才会调用它。于是这 1.6 MB 变成了&lt;strong&gt;永远不会被请求的部署体积&lt;/strong&gt;——它在 &lt;code&gt;dist/&lt;/code&gt; 里占位，但不进任何人的关键路径。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-build-size/03-wire.webp&quot; alt=&quot;部署体积 16 MB，单次访问 28 KB&quot; /&gt;&lt;/p&gt;
&lt;p&gt;实测传输量：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;页面&lt;/th&gt;
&lt;th&gt;传输（brotli）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;首页 &lt;code&gt;/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27.9 KB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文章页 &lt;code&gt;/posts/blog-view-counter/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47.5 KB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;三个让差距变大的机制&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. 压缩：130 KB → 28 KB。&lt;/strong&gt;
HTML 平均每页 104 KB（未压缩），Cloudflare 默认开 brotli，压到 25~30 KB。这是最容易忽略的一半——&lt;strong&gt;看 &lt;code&gt;dist&lt;/code&gt; 的体积永远会高估 4~5 倍&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 按需加载：1.6 MB 永不下载。&lt;/strong&gt;
除了 Mermaid，KaTeX（数学公式）也是同一个模式。这类&quot;可能用得上&quot;的依赖放动态 import，代价从「每个访客」变成「零」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 长缓存：第二次访问不再请求。&lt;/strong&gt;
构建产物文件名带内容哈希，&lt;code&gt;/_astro/&lt;/code&gt; 下全部是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cache-control: public, max-age=31536000, immutable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;（这条是&lt;a href=&quot;/posts/blog-cache-layers/&quot;&gt;三层缓存那篇&lt;/a&gt;改的，之前所有资源都是 &lt;code&gt;max-age=0&lt;/code&gt;。）&lt;/p&gt;
&lt;p&gt;三个机制叠起来，才有了「16 MB 部署、28 KB 首访、几乎零次访」。&lt;/p&gt;
&lt;h2&gt;顺手修掉的一个小浪费&lt;/h2&gt;
&lt;p&gt;拆 HTML 时发现每页 40 KB 是&lt;strong&gt;内联脚本&lt;/strong&gt;（44 个 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;），其中最大的那块里塞着我写的解释性注释——&lt;code&gt;is:inline&lt;/code&gt; 的脚本会被原样输出，&lt;strong&gt;注释也算字节&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;改法是把长注释搬到文档、脚本里只留一行结论：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;改前&lt;/th&gt;
&lt;th&gt;改后&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;首页 HTML&lt;/td&gt;
&lt;td&gt;134 KB&lt;/td&gt;
&lt;td&gt;132.9 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内联脚本合计&lt;/td&gt;
&lt;td&gt;40.0 KB&lt;/td&gt;
&lt;td&gt;39.5 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;省下 509 字节/页。数字很小，但它说明一件事：&lt;strong&gt;&lt;code&gt;is:inline&lt;/code&gt; 的脚本要当成&quot;发给用户的源码&quot;来写&lt;/strong&gt;，注释不是免费的。&lt;/p&gt;
&lt;p&gt;（真正的大头在别处：40 KB 内联脚本里，最大的一块 6 KB 是 Cookie 横幅的引导代码——这也是它成为 LCP 元素的原因之一，见&lt;a href=&quot;/posts/blog-banner-lcp/&quot;&gt;和横幅抢 LCP&lt;/a&gt;。）&lt;/p&gt;
&lt;h2&gt;该看哪个数字&lt;/h2&gt;
&lt;p&gt;我的结论是：&lt;strong&gt;看三个，而不是一个&lt;/strong&gt;。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;数字&lt;/th&gt;
&lt;th&gt;看什么&lt;/th&gt;
&lt;th&gt;什么时候看&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;部署体积&lt;/td&gt;
&lt;td&gt;部署限制（Cloudflare 单 Worker 2 万文件 / 单文件 25 MiB）&lt;/td&gt;
&lt;td&gt;加依赖时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;首访传输&lt;/td&gt;
&lt;td&gt;真实的首屏成本&lt;/td&gt;
&lt;td&gt;优化性能时&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;二次访问&lt;/td&gt;
&lt;td&gt;缓存是否生效&lt;/td&gt;
&lt;td&gt;改缓存策略时&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;只看第一个会去删没用的依赖（比如把 Mermaid 删了），但收益是 0——因为它本来就没被下载。只看第二个会忽略部署限制。&lt;strong&gt;三者一起看，才知道该动哪里。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-build-size/02-page.webp&quot; alt=&quot;每个页面的静态产物都是同一套模板，压缩后 25~30 KB&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;部署体积 ≠ 访客成本&lt;/strong&gt;，中间有压缩、按需加载、缓存三道闸；&lt;/li&gt;
&lt;li&gt;动态 import 的依赖要算「永远不下载」，不要为了 &lt;code&gt;dist&lt;/code&gt; 好看去删它；&lt;/li&gt;
&lt;li&gt;内联脚本属于源码分发，注释也算字节；&lt;/li&gt;
&lt;li&gt;判断优化优先级时，先量一次「首页压缩后到底多少 KB」——那才是用户付出的代价。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>可访问性体检：三个真问题与三次假警报</title><link>https://blog.peroe.cn/posts/blog-a11y-audit/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-a11y-audit/</guid><description>用脚本给自己的静态博客做了一次可访问性体检：重复 id、搜索框没有无障碍名称、键盘焦点环在深色背景上几乎看不见。同时记录体检脚本自己报过的三次假警报——它们比漏报更危险。</description><pubDate>Sat, 24 Oct 2026 15:00:00 GMT</pubDate><content:encoded>&lt;p&gt;SEO 体检做久了会形成一个偏见：&lt;strong&gt;只关心搜索引擎看不看得懂&lt;/strong&gt;。但同一批标记，屏幕阅读器和键盘用户也在用——比如 h1 结构、图片 &lt;code&gt;alt&lt;/code&gt;、表单的无障碍名称。&lt;/p&gt;
&lt;p&gt;这轮我给博客做了一次可访问性体检，查出三个真问题，也踩到三次「体检脚本自己骗人」。&lt;/p&gt;
&lt;h2&gt;六项检查，跑一遍就知道&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-a11y-audit/01-audit.webp&quot; alt=&quot;体检脚本的输出：改前有重复 id、三个搜索框没有无障碍名称&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;检查&lt;/th&gt;
&lt;th&gt;判据&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lang&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;html lang&amp;gt;&lt;/code&gt; 存在且正确&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;重复 id&lt;/td&gt;
&lt;td&gt;同一页面内 &lt;code&gt;id&lt;/code&gt; 不重复（重复 id 是非法 HTML，锚点与 &lt;code&gt;aria-labelledby&lt;/code&gt; 都会失准）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;图片 alt&lt;/td&gt;
&lt;td&gt;没有缺 &lt;code&gt;alt&lt;/code&gt; 的 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;无障碍名称&lt;/td&gt;
&lt;td&gt;每个可聚焦元素都要有可见文本 / &lt;code&gt;aria-label&lt;/code&gt; / &lt;code&gt;title&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;对比度&lt;/td&gt;
&lt;td&gt;文字与背景的对比度 ≥ 4.5:1（大字 ≥ 3:1）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;减少动效&lt;/td&gt;
&lt;td&gt;CSS 里有 &lt;code&gt;prefers-reduced-motion&lt;/code&gt; 分支&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;实现都是十几行 DOM 查询，不需要装任何依赖。&lt;/p&gt;
&lt;h2&gt;三个真问题&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. 每个页面都有两个 &lt;code&gt;id=&quot;sidebar&quot;&lt;/code&gt;。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;外层网格容器（&lt;code&gt;MainGridLayout&lt;/code&gt;）和侧栏组件内部（&lt;code&gt;SideBar&lt;/code&gt;）用了同一个 id。重复 id 不会报错、页面也照常显示，但：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;锚点跳转（&lt;code&gt;#sidebar&lt;/code&gt;）只会跳到第一个；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aria-labelledby&lt;/code&gt; 指向它时，读屏可能读到错的那个。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;修法是把内层改成 &lt;code&gt;id=&quot;sidebar-inner&quot;&lt;/code&gt;——改之前先确认没有任何 CSS/JS 依赖 &lt;code&gt;#sidebar&lt;/code&gt;（我扫了一遍，确实没有）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 三个搜索框都没有无障碍名称。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它们只有 &lt;code&gt;placeholder=&quot;搜索&quot;&lt;/code&gt;。而 placeholder 有两个问题：&lt;strong&gt;输入内容后会消失&lt;/strong&gt;，而且它不保证被当作可访问名称。读屏用户 Tab 到输入框时，听到的是一个没有标签的文本框。&lt;/p&gt;
&lt;p&gt;修法是补 &lt;code&gt;aria-label=&quot;搜索文章&quot;&lt;/code&gt;——三个 &lt;code&gt;input&lt;/code&gt;（桌面版、移动版、搜索面板内）都要补。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 键盘焦点环在深色背景上几乎看不见。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这条最隐蔽，因为我第一版体检把它判成了「通过」：&lt;code&gt;outline: auto 1px&lt;/code&gt; —— 轮廓确实存在。但截图一看：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;第二个 Tab 焦点: 归档 | outline: auto 1px rgb(16, 16, 16)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;rgb(16,16,16)&lt;/code&gt; 是近黑色&lt;/strong&gt;，压在本站 &lt;code&gt;#0f1219&lt;/code&gt; 的深色背景上，等于没有。&lt;/p&gt;
&lt;p&gt;修法是统一焦点样式，只在键盘导航时出现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
    outline: 2px solid var(--primary);
    outline-offset: 2px;
    border-radius: 4px;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改完再量，轮廓变成 &lt;code&gt;solid 2px oklch(0.7 0.15 255)&lt;/code&gt;——主题蓝色，Tab 走一遍能清楚地看到光标在哪。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-a11y-audit/02-focus.webp&quot; alt=&quot;Tab 两次后的焦点：主题色描边清晰可见&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;三次假警报（比真问题更值得记）&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-a11y-audit/03-falsealarms.webp&quot; alt=&quot;体检脚本自己报过的三次假警报&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;① 性能脚本报 LCP 1.9 秒。&lt;/strong&gt; 真因是启动参数里带了 &lt;code&gt;--host-resolver-rules=MAP * ~NOTFOUND&lt;/code&gt;（当初为了绕开本机代理），把所有第三方域名都解析成不存在——测出来的是「没有第三方的平行世界」。真实现场是 12 秒。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;② SEO 脚本报 10 条「canonical 指向别处」。&lt;/strong&gt; 真因是中文标签页的 canonical 是 percent-encoded 形式，两者等价，但脚本逐字符比较。改成比较前 &lt;code&gt;unquote()&lt;/code&gt; 两边。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;③ a11y 脚本报对比度 &lt;code&gt;ratio 1.12&lt;/code&gt;（几乎全白 on 全白）。&lt;/strong&gt; 真因是脚本没把文字颜色的 alpha 与背景合成：&lt;code&gt;text-50&lt;/code&gt; 这类半透明文字、&lt;code&gt;sr-only&lt;/code&gt; 这类收成 1px 的视觉隐藏元素全被算进了分母。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么假警报比漏报更危险&lt;/strong&gt;：漏报只是没发现问题；假警报会让人去改本来没问题的东西——我差点为了「对比度不达标」去调深色主题的文字色，实际那两处一处是视觉隐藏的 h1、一处是半透明计数。&lt;/p&gt;
&lt;p&gt;所以写体检脚本时，我给自己定了两条规矩：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;每条判据都要能解释&lt;/strong&gt;：「为什么这个元素不达标」要能打印出具体值（颜色、比例、坐标），而不是只给一个结论；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先怀疑脚本，再改代码&lt;/strong&gt;：同一个「问题」出现在全站每个页面时，八成是判据写错了，而不是模板全错。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;现在的基线&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;lang            zh-CN
重复 id         0
图片缺 alt      0
无障碍名称      全部有（3 个搜索框已补 aria-label）
对比度          0 处不达标（大字 ≥3:1、正文 ≥4.5:1）
键盘焦点        主题色 2px 描边，Tab 全程可见
减少动效        prefers-reduced-motion 已支持
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;可访问性体检的收益不只是「对残障用户友好」：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;重复 id / 缺 alt / 无名称的按钮，&lt;strong&gt;对爬虫也是语义噪声&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;可见焦点环让键盘用户能用——而这个站自己就是写技术的，读者里有一半会按 Tab；&lt;/li&gt;
&lt;li&gt;更重要的是，它逼我把「我以为通过了」变成「量出来的结果」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;三行命令就能跑一遍，我把它和 SEO 体检放在同一个目录里，发文章前一起跑。&lt;/p&gt;
&lt;p&gt;体检的具体判据与九类历史问题整理在&lt;a href=&quot;/posts/blog-seo-checklist/&quot;&gt;站内 SEO 体检清单&lt;/a&gt;里，两套体检可以一起跑。&lt;/p&gt;
</content:encoded></item><item><title>24 篇文章的站内 SEO：一套可复跑的体检清单</title><link>https://blog.peroe.cn/posts/blog-seo-checklist/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-seo-checklist/</guid><description>不靠站长后台、不装插件，写一个脚本扫构建产物就能查出大部分站内 SEO 问题。这篇是 24 篇文章、54 个页面跑下来整理出的八项检查、九类真实问题与修法，以及三条「体检本身也会骗你」的教训。</description><pubDate>Sat, 24 Oct 2026 14:40:00 GMT</pubDate><content:encoded>&lt;p&gt;站内 SEO 的问题大多不需要登录任何后台就能发现：&lt;strong&gt;构建产物里的 HTML 就是事实&lt;/strong&gt;。标题多长、描述是不是重复、h1 有几个、canonical 指向哪、结构化数据能不能解析——全都在 &lt;code&gt;dist/&lt;/code&gt; 里。&lt;/p&gt;
&lt;p&gt;我给这个博客写了个体检脚本，24 轮下来跑了上百次。这篇把检查项、真实问题与修法整理成一份可以照抄的清单。&lt;/p&gt;
&lt;h2&gt;八项检查，扫的就是产物&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-seo-checklist/01-audit.webp&quot; alt=&quot;体检脚本的输出：逐页列出标题、描述、h1/h2 数量、图片、字数、内链与结构化数据&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;检查&lt;/th&gt;
&lt;th&gt;判据&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;title&lt;/td&gt;
&lt;td&gt;12~48 字（含站点后缀），且非空&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;description&lt;/td&gt;
&lt;td&gt;60~160 字，&lt;strong&gt;每页唯一&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;canonical&lt;/td&gt;
&lt;td&gt;存在、绝对地址、指向自身（中文路径要按语义比，下面会讲）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;h1&lt;/td&gt;
&lt;td&gt;恰好 1 个，且非空&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;存在（分享卡片靠它）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;结构化数据&lt;/td&gt;
&lt;td&gt;能被 &lt;code&gt;JSON.parse&lt;/code&gt; 解析&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;正文厚度&lt;/td&gt;
&lt;td&gt;少于 300 字标「偏薄」&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内链&lt;/td&gt;
&lt;td&gt;统计站内出链数，配合另一个脚本算入链与孤岛&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;实现只有一百多行：用 &lt;code&gt;glob&lt;/code&gt; 遍历 &lt;code&gt;dist/**/*.html&lt;/code&gt;，正则取 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; 里的标签，用一个小型 HTML 解析器数 &lt;code&gt;h1&lt;/code&gt;/&lt;code&gt;h2&lt;/code&gt;/&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;，再对 JSON-LD 做一次 &lt;code&gt;JSON.parse&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;关键前提：先删 &lt;code&gt;dist/&lt;/code&gt;&lt;/strong&gt;。Astro 构建不会清理上一次的产物，我因此扫到过一个线上早已 404 的幽灵页面（&lt;code&gt;/t/&lt;/code&gt;），还据此下了一堆错误结论。&lt;/p&gt;
&lt;h2&gt;九类真实问题（按影响排序）&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-seo-checklist/03-fixes.webp&quot; alt=&quot;24 轮下来修掉的问题清单&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;问题&lt;/th&gt;
&lt;th&gt;规模&lt;/th&gt;
&lt;th&gt;修法&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;全站 h1 数量 = 0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;28 个页面&lt;/td&gt;
&lt;td&gt;布局加 &lt;code&gt;heading&lt;/code&gt; 属性渲染 &lt;code&gt;&amp;lt;h1 class=&quot;sr-only&quot;&amp;gt;&lt;/code&gt;；文章页的 &lt;code&gt;div#post-title&lt;/code&gt; 改成 &lt;code&gt;h1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;28 个页面缺 &lt;code&gt;og:image&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;全部列表页&lt;/td&gt;
&lt;td&gt;生成 &lt;code&gt;/og-default.png&lt;/code&gt; 兜底，布局里统一输出 &lt;code&gt;og:image&lt;/code&gt; + &lt;code&gt;width/height&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;描述重复或过短&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;十几个页面&lt;/td&gt;
&lt;td&gt;每页独立描述；最薄的两个工具页从 292 字补到 1950 字&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;sitemap 的 XSD 顺序错&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2 条 sitemap&lt;/td&gt;
&lt;td&gt;必须 &lt;code&gt;loc → lastmod → changefreq → priority&lt;/code&gt;，否则 Google 报「无法读取此站点地图」&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;404 页输出两个 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 页&lt;/td&gt;
&lt;td&gt;不要再把 &lt;code&gt;MainGridLayout&lt;/code&gt; 嵌进 &lt;code&gt;Layout&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RSS 里置顶文排第一&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;全站 feed&lt;/td&gt;
&lt;td&gt;改成按 &lt;code&gt;published&lt;/code&gt; 倒序（列表页仍保持置顶优先）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;内链太稀、孤岛多&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;12 篇零入链&lt;/td&gt;
&lt;td&gt;手工补上下文链接 + 「相关文章」自动生成 + 标签聚合页&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;11 个标签页缺失&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20 个标签&lt;/td&gt;
&lt;td&gt;只给 ≥2 篇文章的标签建页（单篇标签是薄内容）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LCP 元素是 Cookie 横幅&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;全站&lt;/td&gt;
&lt;td&gt;提前加载 + 压缩体积（12.1s → 7.4s，未完）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;前两项最典型：&lt;strong&gt;问题不在某篇文章，而在模板&lt;/strong&gt;。h1 = 0 意味着所有页面的标题层级都是坏的，搜索引擎只能自己猜哪个是主题。&lt;/p&gt;
&lt;h2&gt;现在的基线&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;扫描页面    54 个（24 篇文章 + 11 个标签页 + 静态页 + 分页）
可收录     43 条（sitemap）
体检问题   0 项（只有 /2/ /3/ /404 缺 JSON-LD —— 分页与错误页本来就不需要）
内链       41 条手工 + 67 条自动相关文章
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-seo-checklist/02-sitemap.webp&quot; alt=&quot;sitemap 的真实内容：43 条 URL，元素顺序符合官方 XSD&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;三条「体检本身也会骗你」的教训&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. 脚本的隐含条件要写下来。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我的性能脚本带了一行 &lt;code&gt;--host-resolver-rules=MAP * ~NOTFOUND&lt;/code&gt;（当初为了绕开本机代理），副作用是把所有第三方域名都解析失败——于是长期报出「LCP 1.9 秒」，而真实现场是 12 秒。&lt;strong&gt;任何&quot;隐含条件&quot;都要在脚本头部写清楚&lt;/strong&gt;，否则半年后的你会被自己骗。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. URL 要按语义比，不能按字符比。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;中文标签页 &lt;code&gt;/tags/统计/&lt;/code&gt; 的 canonical 是 percent-encoded 形式，逐字符比较会报 10 条假问题。比较前 &lt;code&gt;unquote()&lt;/code&gt; 两边。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 同一套规则要在多处保持一致。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;标签页的建页条件是「≥2 篇文章」，这个条件同时出现在三处：&lt;code&gt;getStaticPaths&lt;/code&gt;（决定生成哪些页）、&lt;code&gt;PostMeta&lt;/code&gt;（决定哪些标签可点）、&lt;code&gt;sitemap&lt;/code&gt;（决定收录哪些 URL）。&lt;strong&gt;任何一处漏掉，结果不是 404 就是孤儿页。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;怎么复跑&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;rm -rf dist &amp;amp;&amp;amp; pnpm astro build --force     # 干净构建
python seo-audit.py                        # 八项检查
python link-audit.py                       # 入链/出链/孤岛
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个脚本都在我的本地工具目录里、不进仓库——它们不含任何站点私有信息，但也没必要让仓库多两个 Python 文件。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;站内 SEO 没什么神秘：&lt;strong&gt;把「什么样的页面算合格」写成可执行的判断，然后每次都跑一遍&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;真正花时间的是修——尤其是那些&quot;问题在模板里、影响全部页面&quot;的项。但正因为它们在模板里，修一次就永久生效：h1、&lt;code&gt;og:image&lt;/code&gt;、canonical 这三件事修完之后，后面每发一篇文章都自动合规。&lt;/p&gt;
&lt;p&gt;体检之后更麻烦的是性能：LCP 元素居然是 Cookie 横幅，两次尝试的过程写在&lt;a href=&quot;/posts/blog-banner-lcp/&quot;&gt;和 Cookie 横幅抢 LCP&lt;/a&gt;里。&lt;/p&gt;
&lt;p&gt;体检不止有 SEO 一种：键盘焦点、对比度、无障碍名称也值得查一遍，过程见&lt;a href=&quot;/posts/blog-a11y-audit/&quot;&gt;可访问性体检&lt;/a&gt;。&lt;/p&gt;
</content:encoded></item><item><title>静态站的标签体系：挖出 11 个可收录页面</title><link>https://blog.peroe.cn/posts/blog-tags-system/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-tags-system/</guid><description>23 篇文章、20 个标签，但页面上一个标签都看不到——前端组件把 tags 静默忽略了。这篇记录怎么把标签变成 11 个可收录页面（10 个标签落地页 + 索引），以及中文标签在 Astro 里踩到的三个坑。</description><pubDate>Sat, 24 Oct 2026 14:20:00 GMT</pubDate><content:encoded>&lt;p&gt;做体检时随手查了一下「标签页在不在」，结果发现两件事：&lt;strong&gt;没有标签页&lt;/strong&gt;，而且&lt;strong&gt;文章页上也看不到标签&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;后者更意外：文章页明明写了 &lt;code&gt;&amp;lt;PostMetadata tags={entry.data.tags} /&amp;gt;&lt;/code&gt;，但那个组件里根本没有 &lt;code&gt;tags&lt;/code&gt; 字段——传进去的参数被静默忽略了。于是 20 个标签只活在 frontmatter 里，对读者和搜索引擎都不存在。&lt;/p&gt;
&lt;h2&gt;先数一遍：哪些标签值得建页&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-tags-system/01-stats.webp&quot; alt=&quot;23 篇文章的标签分布：20 个标签、67 次引用&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;建站 18 篇 · Astro 11 篇 · 性能 8 篇 · 安全 6 篇
运维 3 篇 · Cloudflare 3 篇 · 统计 2 篇 · 观测 2 篇 · 工程实践 2 篇 · 隐私 2 篇
以下各 1 篇：缓存 / 图片 / SEO / 搜索 / 公告 / HTTPS / 网络 / 排障 / 测量 / 成本
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;规则很简单：&lt;strong&gt;只给 ≥2 篇文章的标签建落地页&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;单篇标签的聚合页只有一篇文章 + 一句导语，是标准的薄内容——Google 对这类页面没有好感，而它又会让站内多出 10 个&quot;看起来像页面但没内容&quot;的 URL。所以单篇标签只显示在文章底部（纯文本，不带链接），不建页、不进 sitemap。&lt;/p&gt;
&lt;p&gt;最终：&lt;strong&gt;10 个标签页 + 1 个索引页 = 11 个新页面&lt;/strong&gt;，可收录页面从 30 个涨到 41 个（+37%）。&lt;/p&gt;
&lt;h2&gt;每个标签页长什么样&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-tags-system/02-tags.webp&quot; alt=&quot;标签索引页：10 个标签与各自篇数&quot; /&gt;&lt;/p&gt;
&lt;p&gt;以 &lt;code&gt;/tags/Astro/&lt;/code&gt; 为例，一个页面要有四样东西：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;独立的 title / description&lt;/strong&gt;（不能复用文章页的，也不能所有标签页共用一句）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;单 h1&lt;/strong&gt; —— 由布局的 &lt;code&gt;heading&lt;/code&gt; 属性渲染成 &lt;code&gt;&amp;lt;h1 class=&quot;sr-only&quot;&amp;gt;&lt;/code&gt;，可见标题用 h2；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;结构化数据&lt;/strong&gt; —— &lt;code&gt;CollectionPage&lt;/code&gt; + &lt;code&gt;ItemList&lt;/code&gt;，把该标签下的文章作为 &lt;code&gt;itemListElement&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内容本身&lt;/strong&gt; —— 按时间倒序的文章列表 + 页脚「其他标签」交叉链接。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;结构化数据那段值得单独说：标签页的价值是&quot;聚合&quot;，所以用 &lt;code&gt;CollectionPage&lt;/code&gt;，而不是 &lt;code&gt;BlogPosting&lt;/code&gt;。&lt;code&gt;ItemList&lt;/code&gt; 里最多放 20 条，标题直接取文章标题、URL 取绝对地址。&lt;/p&gt;
&lt;h2&gt;描述怎么生成：唯一、且够长&lt;/h2&gt;
&lt;p&gt;第一版我自动生成的是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;peroe 的博客中带有「统计」标签的 2 篇文章，按发布时间从新到旧排列。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;体检立刻报「description 38 字（建议 60~160）」。而且 10 个标签页都是同一个句式，只有标签名不同——&lt;strong&gt;这是重复内容&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;改成用该标签下最新两篇文章的标题拼摘要：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;peroe 的博客中带有「Astro」标签的 11 篇文章…… 例如：《Astro 静态站的三个隐形约定：CSS 是全局的》、
《静态站的标签体系：挖出 11 个可收录页面》。每篇都附带实测数据与可复现的命令。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果是每页 100~130 字、彼此唯一，而且自然带上了文章关键词。这也是我后来给「相关文章」组件定描述时用的同一个思路：&lt;strong&gt;让自动生成的文案里带真实内容片段&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;三个坑&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-tags-system/03-pitfalls.webp&quot; alt=&quot;三个中文标签相关的坑：URL 编码、canonical 比较、描述长度&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;坑一：&lt;code&gt;getStaticPaths&lt;/code&gt; 里不能提前编码。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ❌ 构建直接失败：NoMatchingStaticPathFound: /tags/建站/
params: { tag: encodeURIComponent(tag) }
// ✅ 给原始标签名，Astro 会解码 URL 后再匹配
params: { tag }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是中文标签必踩的一脚：链接里需要 &lt;code&gt;encodeURIComponent&lt;/code&gt;（浏览器行为），但路由参数不能。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;坑二：审计脚本对中文路径的 canonical 误报。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/tags/统计/&lt;/code&gt; 的 canonical 是 &lt;code&gt;https://blog.peroe.cn/tags/%E7%BB%9F%E8%AE%A1/&lt;/code&gt; —— 两者等价，但我自己的体检脚本是逐字符比较，于是报了 10 条「canonical 指向别处」。改成比较前先 &lt;code&gt;unquote()&lt;/code&gt; 两边，误报清零。&lt;/p&gt;
&lt;p&gt;写体检脚本的人容易犯这个错：&lt;strong&gt;URL 比较必须按语义比，不能按字符串比&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;坑三：sitemap 是手写清单。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;新页面建好了但 sitemap 里没有——它不会自己出现。我维护的是一份 &lt;code&gt;staticPages&lt;/code&gt; 清单，得手动补 &lt;code&gt;/tags/&lt;/code&gt;，再按「≥2 篇」的同一条件生成 10 条标签页条目（&lt;code&gt;lastmod&lt;/code&gt; 取该标签下最新文章的更新时间）。&lt;strong&gt;建页条件必须和 &lt;code&gt;getStaticPaths&lt;/code&gt; 完全一致&lt;/strong&gt;，否则 sitemap 里会混进 404。&lt;/p&gt;
&lt;h2&gt;顺带修的一件事：文章页显示标签&lt;/h2&gt;
&lt;p&gt;标签页有了，但读者是从文章页过来的——所以给 &lt;code&gt;PostMeta&lt;/code&gt; 加了标签渲染：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{tags.map((tag) =&amp;gt;
  linkableTags.includes(tag)
    ? &amp;lt;a href={`/tags/${encodeURIComponent(tag)}/`}&amp;gt;{tag}&amp;lt;/a&amp;gt;   // ≥2 篇：可点
    : &amp;lt;span&amp;gt;{tag}&amp;lt;/span&amp;gt;                                        // 单篇：纯文本
)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;linkableTags&lt;/code&gt; 在构建期算好（复用同一份统计），这样单篇标签不会产生 404。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;标签体系的价值有两层：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;对读者&lt;/strong&gt;：从一篇文章跳到同主题的其他文章；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对搜索引擎&lt;/strong&gt;：多出 11 个主题聚合页，能吃「Astro 建站」「网站性能优化」这类单篇标题命中不了的词。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;判断标准可以总结成三条：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;≥2 篇才建页&lt;/strong&gt;（薄内容不建）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;描述必须唯一且够长&lt;/strong&gt;（拼真实标题，别套模板）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;建页条件在 &lt;code&gt;getStaticPaths&lt;/code&gt;、sitemap、链接渲染三处保持一致&lt;/strong&gt;（否则不是 404 就是孤儿页）。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;标签页与站点地图的联动细节，见&lt;a href=&quot;/posts/blog-seo-self-audit/&quot;&gt;博客 SEO 自我体检&lt;/a&gt;那一篇里的体检清单。&lt;/p&gt;
</content:encoded></item><item><title>和 Cookie 横幅抢 LCP：我试到哪一步就停了</title><link>https://blog.peroe.cn/posts/blog-banner-lcp/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-banner-lcp/</guid><description>上一篇发现真正的 LCP 元素是 Cookie 横幅后，我试了三种办法：提前加载、压缩体积 75%、preload 脚本。性能分从 37 提到 50、FCP 从 3.5s 到 1.5s，但 LCP 卡在 7.4s 不动。这篇记下为什么压小体积没用，以及我为什么停在这里。</description><pubDate>Sat, 24 Oct 2026 14:00:00 GMT</pubDate><content:encoded>&lt;p&gt;上一篇&lt;a href=&quot;/posts/blog-measurement-trap/&quot;&gt;《我的性能测试骗了我》&lt;/a&gt;结尾留了个问题：Lighthouse 表示 LCP 元素是 Cookie 横幅的文案，LCP 12.1 秒。&lt;/p&gt;
&lt;p&gt;这一轮我把它当成真正的对手，试了三招，最后停在离目标还有一段距离的地方。过程比结果有用。&lt;/p&gt;
&lt;h2&gt;三招，逐招看效果&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-banner-lcp/01-attempts.webp&quot; alt=&quot;三次尝试的实测数据：LCP 12.1s → 7.2s → 7.8s → 7.4s&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;尝试&lt;/th&gt;
&lt;th&gt;做法&lt;/th&gt;
&lt;th&gt;性能分&lt;/th&gt;
&lt;th&gt;FCP&lt;/th&gt;
&lt;th&gt;LCP&lt;/th&gt;
&lt;th&gt;TBT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;改前&lt;/td&gt;
&lt;td&gt;横幅等 &lt;code&gt;window.load&lt;/code&gt; 再加载&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;3.5s&lt;/td&gt;
&lt;td&gt;12.1s&lt;/td&gt;
&lt;td&gt;1850ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;①&lt;/td&gt;
&lt;td&gt;提前到 &lt;code&gt;DOMContentLoaded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;1.6s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.2s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1480ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;②&lt;/td&gt;
&lt;td&gt;① + 横幅体积 −75%&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;1.6s&lt;/td&gt;
&lt;td&gt;7.8s&lt;/td&gt;
&lt;td&gt;1650ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;③&lt;/td&gt;
&lt;td&gt;② + &lt;code&gt;preload&lt;/code&gt; 横幅脚本&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;1.5s&lt;/td&gt;
&lt;td&gt;7.4s&lt;/td&gt;
&lt;td&gt;1390ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;① 有效&lt;/strong&gt;：横幅从 12 秒提前到 7 秒出现，LCP 跟着降了 5 秒。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;② 几乎没用&lt;/strong&gt;：我把横幅从 360×490 压到 320×137（面积 −75%，用官方文档给的 CSS 类名做的），LCP 一点没动 —— 甚至因为测量噪声略涨。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;③ 在噪声范围内&lt;/strong&gt;：preload 让 66 KB 的脚本更早开始下载，但对 LCP 的影响看不出。&lt;/p&gt;
&lt;h2&gt;为什么「压小」没用&lt;/h2&gt;
&lt;p&gt;这是这轮最值得记下来的一课：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;LCP 判的不是「最大」，而是「最晚出现的那块大字」。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;我第一次读到这句话时以为它只是概括，真正踩过才发现它是判据。横幅压小之后，它的文本块确实不再是页面上面积最大的元素了 —— 但它仍然是&lt;strong&gt;最后出现&lt;/strong&gt;的大块，于是 LCP 元素一直是它（Lighthouse 三次都报同一个 &lt;code&gt;p.cc-nb-text&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;换句话说：只要横幅在 7 秒出现、且它的面积还超过页面上其它任何一个内容块，LCP 就是 7 秒。压缩体积只有在「压到比正文还小」且「正文出现得更早」时才可能改变结论 —— 而我实测压到 43.7k px² 时，Lighthouse 依然选了它。&lt;/p&gt;
&lt;h2&gt;还有一个隐性原因：那是第三方脚本&lt;/h2&gt;
&lt;p&gt;横幅不是我写的，是 66 KB 的第三方库，在 1.6 Mbps + 4 倍 CPU 下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;下载 66KB（~0.4s）+ 解析执行（~1s，4x 降速）+ 构建 DOM + 渲染那块文本 ≈ 7 秒
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我能控制的只有「什么时候开始下载」和「下载完之后长什么样」，&lt;strong&gt;控制不了它自身的解析成本&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;所以我停在这里&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-banner-lcp/02-compact.webp&quot; alt=&quot;横幅现在是紧凑条：两行截断、按钮紧凑，完整说明仍在「更改我的偏好」里&quot; /&gt;&lt;/p&gt;
&lt;p&gt;把 LCP 压到 2.5 秒以内，只剩两条路：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A. 换掉这个第三方横幅，自建一个约 2 KB 的同意条。&lt;/strong&gt;
代价是要自己实现条款文案、偏好中心、以及与 GA4 / Clarity 的联动 —— 这些属于&lt;strong&gt;合规实现&lt;/strong&gt;，不是纯技术活。我停在这里，等站长确认。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;B. 接受约 7.4 秒，把优化重心移回内容侧。&lt;/strong&gt;
从&quot;用户能不能尽快看到内容&quot;的角度，FCP 1.5 秒其实已经不错了；LCP 被横幅绑架是结构性问题。&lt;/p&gt;
&lt;h2&gt;这轮的实际收益&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-banner-lcp/03-numbers.webp&quot; alt=&quot;改动有效，但没体现在 LCP 上&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;改前&lt;/th&gt;
&lt;th&gt;现在&lt;/th&gt;
&lt;th&gt;变化&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FCP&lt;/td&gt;
&lt;td&gt;3.5s&lt;/td&gt;
&lt;td&gt;1.5s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−2.0s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TBT&lt;/td&gt;
&lt;td&gt;1850ms&lt;/td&gt;
&lt;td&gt;1390ms&lt;/td&gt;
&lt;td&gt;−460ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;性能分&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;+13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;横幅面积&lt;/td&gt;
&lt;td&gt;176k px²&lt;/td&gt;
&lt;td&gt;43.7k px²&lt;/td&gt;
&lt;td&gt;−75%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LCP&lt;/td&gt;
&lt;td&gt;12.1s&lt;/td&gt;
&lt;td&gt;7.4s&lt;/td&gt;
&lt;td&gt;−4.7s（未达 2.5s）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;顺手还修了两件小事的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;RSS 排序&lt;/strong&gt;：之前沿用列表排序（置顶优先），「关于本站」永远是 feed 第一条，新文章反而沉到后面 —— 订阅者看到的&quot;最新&quot;是两个月前的置顶文。改成按时间倒序。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;弱锚文本&lt;/strong&gt;：一条内链的锚是「这篇」，改成「首屏从 392KB 砍到 59KB」。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;三招里只有「提前加载」真正有效，而它之所以有效，是因为&lt;strong&gt;把大块出现的时间往前挪了 5 秒&lt;/strong&gt;。压缩体积、preload 都是在优化&quot;这块东西本身&quot;，但 LCP 关心的是&quot;它什么时候出现&quot;。&lt;/p&gt;
&lt;p&gt;这也解释了为什么很多站点优化了半天图片和字体，LCP 纹丝不动 —— &lt;strong&gt;先去 Lighthouse 里看 LCP 元素是谁&lt;/strong&gt;，再决定动哪里。&lt;/p&gt;
</content:encoded></item><item><title>我的性能测试骗了我：把第三方全拦掉再测</title><link>https://blog.peroe.cn/posts/blog-measurement-trap/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-measurement-trap/</guid><description>一个多月来我的脚本一直显示 LCP 1.9 秒，直到跑了一次 Lighthouse——同一个页面 LCP 12.4 秒。差异不在工具，而在我脚本的启动参数里那行 host-resolver-rules：它把所有第三方域名都解析成了 NOTFOUND。附三个数据源的对比与修复过程。</description><pubDate>Sat, 24 Oct 2026 13:40:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
这篇是我自己的「打脸记录」：前面几篇性能文章里的数字，是在一个&lt;strong&gt;没有第三方脚本&lt;/strong&gt;的平行世界里测出来的。真实场景要慢得多。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;起因&lt;/h2&gt;
&lt;p&gt;为了写上一篇的对比，我跑了一次 Lighthouse：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Lighthouse 12.8 (mobile)
  得分: 47     FCP 5.5s     LCP 12.4s     TBT 490ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可我自己的脚本一直在报 LCP &lt;strong&gt;1.9 秒&lt;/strong&gt;。差 6 倍，必须查清楚。&lt;/p&gt;
&lt;h2&gt;三个&quot;测量&quot;，三个世界&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-measurement-trap/01-three.webp&quot; alt=&quot;同一页面三个数据源：1.9s / 12.4s / 12.1s&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;数据源&lt;/th&gt;
&lt;th&gt;FCP&lt;/th&gt;
&lt;th&gt;LCP&lt;/th&gt;
&lt;th&gt;TBT&lt;/th&gt;
&lt;th&gt;请求 / 传输&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;我的 CDP 脚本&lt;/td&gt;
&lt;td&gt;1.4s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.9s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;117ms&lt;/td&gt;
&lt;td&gt;41 / 59KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lighthouse simulate&lt;/td&gt;
&lt;td&gt;5.5s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12.4s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;490ms&lt;/td&gt;
&lt;td&gt;83 / 1039KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lighthouse devtools&lt;/td&gt;
&lt;td&gt;3.5s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12.1s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1850ms&lt;/td&gt;
&lt;td&gt;83 / 1039KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;请求数差一倍、传输差 17 倍。答案藏在我脚本的启动参数里：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;args: [&quot;--no-proxy-server&quot;,
       &quot;--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE blog.peroe.cn , EXCLUDE q2.qlogo.cn&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两行本来是&lt;strong&gt;为了绕开本机代理&lt;/strong&gt;（用 CDP 限速时，代理会让连接超时）。副作用是：所有不在白名单里的域名都被解析成 &lt;code&gt;NOTFOUND&lt;/code&gt; —— &lt;strong&gt;GA4、Clarity、Cookie 横幅、Umami、Giscus 全军覆没&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;说白了，我这个脚本测的是「&lt;strong&gt;如果这个站没有第三方脚本，它有多快&lt;/strong&gt;」。那当然快。&lt;/p&gt;
&lt;h2&gt;Lighthouse 说 LCP 是谁&lt;/h2&gt;
&lt;p&gt;更进一步，Lighthouse 直接告诉我们 LCP 元素是哪个：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-measurement-trap/02-lcp.webp&quot; alt=&quot;LCP 元素是 Cookie 横幅的文案，12 秒才出现&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;LCP 元素: &amp;lt;p class=&quot;cc-nb-text&quot; id=&quot;termsfeed-com---nb-text&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不是文章标题，不是卡片，是&lt;strong&gt;Cookie 横幅的说明文字&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;时间线是：&lt;code&gt;TTFB 1.76s&lt;/code&gt; → &lt;code&gt;DOMContentLoaded&lt;/code&gt; → &lt;code&gt;window.load&lt;/code&gt; → 横幅脚本（66 KB）异步插入 → 解析执行 → 渲染出那块大文本，&lt;strong&gt;大约 12 秒&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;而 &lt;strong&gt;LCP 会一直记录到「用户首次交互」为止&lt;/strong&gt;，所以这个晚出现的大块，直接把 LCP 顶到了 12 秒。&lt;/p&gt;
&lt;h2&gt;修了什么&lt;/h2&gt;
&lt;p&gt;最直接的一刀：&lt;strong&gt;别让横幅排在最后&lt;/strong&gt;。它原来等 &lt;code&gt;window.load&lt;/code&gt; 才加载，我改成 &lt;code&gt;DOMContentLoaded&lt;/code&gt; 就加载（脚本本身仍是 &lt;code&gt;async&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 改前
window.addEventListener(&quot;load&quot;, load, { once: true });
// 改后
document.addEventListener(&quot;DOMContentLoaded&quot;, load, { once: true });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实测（Lighthouse devtools 模式）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;改前（线上）&lt;/th&gt;
&lt;th&gt;改后（本地构建）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;性能分&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;49&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FCP&lt;/td&gt;
&lt;td&gt;3.5s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.6s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LCP&lt;/td&gt;
&lt;td&gt;12.1s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.2s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LCP 元素&lt;/td&gt;
&lt;td&gt;横幅文案&lt;/td&gt;
&lt;td&gt;仍是横幅文案&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;还没解决的：横幅仍然是最大的元素&lt;/h2&gt;
&lt;p&gt;7.2 秒仍然远超 2.5 秒的「良好」线，而且 LCP 元素没变 —— 只要那块说明文字是页面上最大的元素、又是最晚出现的，它就会一直是 LCP。&lt;/p&gt;
&lt;p&gt;两条路，都需要权衡：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把横幅做小&lt;/strong&gt;：限制文字块宽度/行数（它现在是 351px 高的大块），让正文成为最大元素。风险是同意说明变得不明显。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;换掉 TermsFeed&lt;/strong&gt;：自建一个 2KB 的同意条。66KB 的第三方脚本 + 4 倍 CPU，只为了弹一条 Cookie 提示，性价比很低。&lt;strong&gt;风险是合规&lt;/strong&gt;：条款文案、偏好中心、与 GA4/Clarity 的联动都要自己实现。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我倾向第二条，但它动的是合规相关的部分，&lt;strong&gt;需要你确认后再做&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;三条教训&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. 测量脚本的&quot;隐含条件&quot;必须写下来。&lt;/strong&gt; 我那行 &lt;code&gt;host-resolver-rules&lt;/code&gt; 是三个月前为了修代理超时加的，早就忘了。它悄悄改变了被测对象。现在我在脚本头部固定写一行注释：&lt;em&gt;「本模式会阻断所有第三方域名，只用于隔离自有资源的性能」&lt;/em&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 换一个工具复核。&lt;/strong&gt; 同一个站点、同一台机器，Lighthouse 和我的脚本差 6 倍 —— &lt;strong&gt;不是谁错了，是测的东西不同&lt;/strong&gt;。两个数字都有价值，但必须知道各自的前提。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 永远看 LCP 元素是谁。&lt;/strong&gt; 数字只是结果，「谁成为了 LCP」才是病因。这次如果不是 Lighthouse 告诉我那是 Cookie 横幅，我可能会去优化图片和字体。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-measurement-trap/03-banner.webp&quot; alt=&quot;首页现在的样子：横幅更早出现，LCP 从 12 秒降到 7 秒&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;这轮的收获不是&quot;优化了 5 秒&quot;，而是&lt;strong&gt;发现自己的测量方法在骗自己&lt;/strong&gt;。前面几篇里那些好看的数字（FCP 1.4s / LCP 1.9s / 41 个请求）都是真的，但含义是「排除第三方之后」——等于给站点做了一次「断网测试」。&lt;/p&gt;
&lt;p&gt;真实场景的数字是：&lt;strong&gt;LCP 7.2s（横幅修好后）、TBT 1.5s、83 个请求 / 1MB&lt;/strong&gt;。接下来的工作很清楚：把第三方从关键路径上撤下来，而不是继续优化我自己的 CSS。&lt;/p&gt;
&lt;p&gt;同类的测量陷阱还有一次：&lt;a href=&quot;/posts/blog-banner-lcp/&quot;&gt;和 Cookie 横幅抢 LCP&lt;/a&gt;里那三次尝试，有两招其实没用。&lt;/p&gt;
</content:encoded></item><item><title>我的博客向陌生人暴露了多少信息</title><link>https://blog.peroe.cn/posts/blog-self-audit/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-self-audit/</guid><description>静态博客没有后端，但公开面比想象的大：证书透明度日志能翻出 13 个子域、DNS 里有 DKIM 与验证记录、页面里带着统计 ID 和联系方式。这篇把三层公开面逐一查清，并修掉了其中一个真正的问题（头像外链 107KB）。</description><pubDate>Sat, 24 Oct 2026 13:20:00 GMT</pubDate><content:encoded>&lt;p&gt;写上一篇&lt;a href=&quot;/posts/blog-third-party-audit/&quot;&gt;第三方请求审计&lt;/a&gt;时我意识到一件事：&lt;strong&gt;「我的站」暴露的不止是页面内容&lt;/strong&gt;。任何人只要会 &lt;code&gt;curl&lt;/code&gt;、会查 DNS、会搜证书日志，就能拼出一张比你想象中完整的地图。&lt;/p&gt;
&lt;p&gt;这篇把三层公开面逐一查了一遍，最后修掉了一个真问题。&lt;/p&gt;
&lt;h2&gt;第一层：证书透明度日志&lt;/h2&gt;
&lt;p&gt;每次给域名签证书，签名记录都会进 CT 日志，而且&lt;strong&gt;永久公开&lt;/strong&gt;。查一下 &lt;code&gt;juluo.work&lt;/code&gt;：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-self-audit/01-subdomains.webp&quot; alt=&quot;crt.sh 能翻出 13 个子域，包括早就没用的测试子域&quot; /&gt;&lt;/p&gt;
&lt;p&gt;翻出来 18 个名字，去掉泛域名后是 13 个具体子域：&lt;code&gt;api&lt;/code&gt;、&lt;code&gt;dak&lt;/code&gt;、&lt;code&gt;docker&lt;/code&gt;、&lt;code&gt;dok&lt;/code&gt;、&lt;code&gt;git&lt;/code&gt;、&lt;code&gt;hd&lt;/code&gt;、&lt;code&gt;ht&lt;/code&gt;、&lt;code&gt;mail&lt;/code&gt;、&lt;code&gt;p&lt;/code&gt;、&lt;code&gt;t&lt;/code&gt;、&lt;code&gt;blog&lt;/code&gt;、&lt;code&gt;send&lt;/code&gt;，还有一个 &lt;code&gt;juluo.juluo.work&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这些里有一半是&lt;strong&gt;早就删掉的测试子域&lt;/strong&gt;（&lt;code&gt;docker&lt;/code&gt;、&lt;code&gt;dok&lt;/code&gt;、&lt;code&gt;dak&lt;/code&gt;、&lt;code&gt;hd&lt;/code&gt;、&lt;code&gt;ht&lt;/code&gt;）—— 我从来没主动公开过它们，但它们躺在 CT 日志里，任何人都能查到，并且可以顺手扫一遍。&lt;/p&gt;
&lt;p&gt;⚠️ 更要紧的是：扫出来之后&lt;strong&gt;能不能被打&lt;/strong&gt;。我给每个名字都 &lt;code&gt;dig&lt;/code&gt; 了一遍：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;全部都有解析 —— 都走 *.juluo.work 的通配符，指向优选域名
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，扫 &lt;code&gt;docker.juluo.work/.env&lt;/code&gt; 这种请求&lt;strong&gt;不会得到 NXDOMAIN&lt;/strong&gt;，而会打到我的边缘节点上。这解释了 Cloudflare 日志里那些每天几十次的 &lt;code&gt;/.env&lt;/code&gt;、&lt;code&gt;/.ssh/id_rsa&lt;/code&gt; 探测（全部 403）。&lt;strong&gt;通配符 DNS 让扫描器把每一个名字都当成活主机。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;第二层：DNS 里的&quot;业务指纹&quot;&lt;/h2&gt;
&lt;p&gt;DNS 是公开数据库。翻一遍 TXT / MX，能看出你用了哪些服务：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;记录&lt;/th&gt;
&lt;th&gt;能看出什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MX juluo.work → route1-3.mx.cloudflare.net&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;邮件走 Cloudflare Email Routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MX send.juluo.work → feedback-smtp...amazonses.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;发信用 Amazon SES&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TXT resend._domainkey&lt;/code&gt; / &lt;code&gt;cf2024-1._domainkey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同时接过 Resend 与 Cloudflare 发信&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TXT _dmarc → p=none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;只监控不拦截&lt;/strong&gt;（下一步该收紧）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TXT google-site-verification&lt;/code&gt; / &lt;code&gt;edgeonereclaim&lt;/code&gt; / &lt;code&gt;_esaauth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;接过 Google 站长、腾讯 EdgeOne、阿里 ESA&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这些都不算&quot;泄露&quot;，但它们是&lt;strong&gt;基础设施指纹&lt;/strong&gt;：一个熟练的人看完就知道你的邮件怎么发、CDN 用了几家、验证过哪些平台。&lt;/p&gt;
&lt;h2&gt;第三层：页面本身&lt;/h2&gt;
&lt;p&gt;页面是最容易被忽略的一层 —— 它对外&lt;strong&gt;主动&lt;/strong&gt;发出信息。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-self-audit/02-identifiers.webp&quot; alt=&quot;页面里能直接读到的标识：QQ 号、统计 ID、外链头像 107KB&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;项&lt;/th&gt;
&lt;th&gt;值&lt;/th&gt;
&lt;th&gt;是否算问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;QQ 号&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wpa.qq.com/msgrd?...&amp;amp;uin=1576586736&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;联系方式，&lt;strong&gt;本来就公开&lt;/strong&gt;（我主动放的）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GA4 ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;G-C3WKKH0NPV&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;公开无妨（只能往里灌垃圾数据）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Umami 站点 ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;842d980c-…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;分享用，本来就公开&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;头像&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;q2.qlogo.cn/headimg_dl?...&amp;amp;spec=0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;有问题&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;前三个是设计如此，最后一个不是。&lt;/p&gt;
&lt;h2&gt;真正要修的那一条：头像&lt;/h2&gt;
&lt;p&gt;头像的问题是三合一的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;URL 里带着 QQ 号&lt;/strong&gt; —— 虽然是联系方式，但&quot;每个访客的浏览器都会主动连腾讯&quot;这件事没写在任何地方；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一张 107 KB&lt;/strong&gt; 的图，只为显示 254px；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;favicon 复用同一个地址&lt;/strong&gt;，所以一次访问要发 1~2 个请求到 &lt;code&gt;q2.qlogo.cn&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;于是我在构建期把它取回来，压成三档放进仓库：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 640 / 320 / 140 三个尺寸，WebP
avatar-640.webp   32.3 KB
avatar-320.webp   14.7 KB
avatar-140.webp    4.6 KB   ← favicon
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Profile.astro&lt;/code&gt; 里用 &lt;code&gt;srcset&lt;/code&gt; 让浏览器按 DPR 自己挑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;img src=&quot;/assets/avatar/avatar-640.webp&quot;
     srcset=&quot;/assets/avatar/avatar-320.webp 1x, /assets/avatar/avatar-640.webp 2x&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改完的实测：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-self-audit/03-avatar.webp&quot; alt=&quot;头像已本地化：src 与 srcset 都指向站内，页面里再无 qlogo 引用&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;线上页面 qlogo 引用: False
头像 src: /assets/avatar/avatar-640.webp
srcset:   /assets/avatar/avatar-320.webp 1x, /assets/avatar/avatar-640.webp 2x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;收益：外部请求 0 个、头像 106.8KB → 32.3KB（1x 下 14.7KB）、favicon 4.6KB。&lt;/strong&gt; 隐私政策里「头像取自 QQ 头像服务」那句也同步改成了「已本地化」。&lt;/p&gt;
&lt;h2&gt;没改、但建议盯着的两件事&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. DMARC 还是 &lt;code&gt;p=none&lt;/code&gt;。&lt;/strong&gt; 意思是「收到伪造我域名的邮件只报告、不拦截」。收紧到 &lt;code&gt;p=quarantine&lt;/code&gt; 能挡掉大部分伪造，但&lt;strong&gt;要先确认自己的发信链路（SES / Resend / Cloudflare）SPF、DKIM 都对齐&lt;/strong&gt;，否则会误伤自己的邮件。这条我没动 —— 涉及邮件可达性，得你先确认。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 那 13 个子域要不要清理。&lt;/strong&gt; 现在它们全靠通配符&quot;假装存在&quot;。如果其中某个将来真的挂服务（比如 &lt;code&gt;git.&lt;/code&gt;），记得别留下没人管的记录 —— &lt;strong&gt;子域接管&lt;/strong&gt;的典型成因就是&quot;记录还在、服务没了&quot;。&lt;/p&gt;
&lt;h2&gt;一份可复用的自查清单&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;证书日志&lt;/strong&gt;：&lt;code&gt;crt.sh/?q=%25.&amp;lt;你的域&amp;gt;&lt;/code&gt; 看有哪些名字被记住了（包括你以为删掉的）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DNS&lt;/strong&gt;：&lt;code&gt;dig TXT/MX&lt;/code&gt; 一遍，看能推出多少业务信息（DMARC 策略尤其要看）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;通配符&lt;/strong&gt;：有 &lt;code&gt;*.&lt;/code&gt; 记录就假设&lt;strong&gt;所有&lt;/strong&gt;子域都会被扫，别把&quot;没有服务&quot;当成&quot;没有暴露&quot;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;页面请求&lt;/strong&gt;：开 DevTools 数一遍对外请求，每个都问「它必须存在吗」；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;页面标识&lt;/strong&gt;：统计 ID、联系方式、图片外链，逐项确认是「我主动公开的」还是「顺手带上的」。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;静态博客的优势是&lt;strong&gt;没有后端数据&lt;/strong&gt;，但公开面依然有三层：证书、DNS、页面。&lt;/p&gt;
&lt;p&gt;这次审计最值钱的收获不是发现什么惊天漏洞，而是&lt;strong&gt;把「我以为」变成了清单&lt;/strong&gt;：QQ 号是主动放的、统计 ID 无所谓、头像外链则完全没必要。少一个外部请求、少 90KB，同时也少一条&quot;每个访客都在替我连腾讯&quot;的暗线。&lt;/p&gt;
</content:encoded></item><item><title>随机图当封面：一张 250KB，我没让它进列表</title><link>https://blog.peroe.cn/posts/blog-cover-images/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-cover-images/</guid><description>静态博客的卡片没图会很难看，随机图 API 看起来很省事。但实测那张图 250KB、Cache-Control 还是 max-age=0——如果首页 10 条卡片都挂上，一次访问就是 2.5MB。这篇算清这笔账，并给出三种封面方案的取舍。</description><pubDate>Sat, 24 Oct 2026 13:00:00 GMT</pubDate><content:encoded>&lt;p&gt;文章写完忘了配图，列表卡片就空一块。随机图 API 看起来很诱人：&lt;code&gt;image: random&lt;/code&gt; 一行配置，每篇文章自动有张不一样的封面。我实测了一下那张图，最后决定&lt;strong&gt;不把它放进列表&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;三种封面方案&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;做法&lt;/th&gt;
&lt;th&gt;代价&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;手写静态封面&lt;/td&gt;
&lt;td&gt;每个 &lt;code&gt;public/assets/images/posts/&amp;lt;slug&amp;gt;/cover.webp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;每篇文章要多做一张图&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;构建期生成&lt;/td&gt;
&lt;td&gt;用脚本按标题生成（我做过 &lt;a href=&quot;/tools/cover/&quot;&gt;封面生成器&lt;/a&gt;）&lt;/td&gt;
&lt;td&gt;要写脚本，风格统一但缺少变化&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;运行时随机图&lt;/td&gt;
&lt;td&gt;&lt;code&gt;image: random&lt;/code&gt;，浏览器去第三方接口取&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;别人的服务器 + 别人的体积&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;本站的模板支持第三种：&lt;code&gt;image: random&lt;/code&gt; 时，卡片渲染的不是一个本地文件，而是一次外部请求。&lt;/p&gt;
&lt;h2&gt;它到底返回了多大一张图&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;curl -sI https://pic.060730.xyz/ri/h/42.webp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-cover-images/01-api.webp&quot; alt=&quot;随机图接口实测：250KB、Cache-Control 是 max-age=0&quot; /&gt;&lt;/p&gt;
&lt;p&gt;关键三行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Content-Length: 256098                          ← 250 KB
Cache-Control:  public, max-age=0, must-revalidate
CF-Cache-Status: HIT                            ← 边缘有缓存，但浏览器每次都要问
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;max-age=0&lt;/code&gt; 意味着&lt;strong&gt;浏览器每次访问都要回来验证一遍&lt;/strong&gt;。它不像我在&lt;a href=&quot;/posts/blog-cache-layers/&quot;&gt;缓存那篇&lt;/a&gt;里给 &lt;code&gt;/_astro/&lt;/code&gt; 加的 &lt;code&gt;immutable&lt;/code&gt; —— 那张图永远处于&quot;可能变了&quot;的状态。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-cover-images/03-random.webp&quot; alt=&quot;&amp;quot;随机封面&amp;quot;长这样：一张 900 宽的插画&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;算一笔账&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-cover-images/02-math.webp&quot; alt=&quot;10 条卡片 × 250KB = 一次访问 2.5MB&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;项目&lt;/th&gt;
&lt;th&gt;数值&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;首页卡片数&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;单张随机图&lt;/td&gt;
&lt;td&gt;250 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;一次访问的封面流量&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.5 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;本站自己的配图（已压缩 + 长缓存）&lt;/td&gt;
&lt;td&gt;平均 70 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;对比之下，我这 19 篇文章的 51 张配图一共 3.63 MB，&lt;strong&gt;全部加载一遍比首页挂满随机图还省&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;而且这 2.5 MB 还有一个隐含成本：它是&lt;strong&gt;串行增加的&lt;/strong&gt;——卡片进入视口才加载，用户往下滚就一直有请求打出去，而每张都不便宜。&lt;/p&gt;
&lt;h2&gt;那随机图什么时候合理&lt;/h2&gt;
&lt;p&gt;不是不能用，而是别用在&quot;列表默认&quot;。我总结的边界：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;单篇文章的封面&lt;/strong&gt;：一页就一张，250 KB 可以接受（还记得加 &lt;code&gt;loading=&quot;lazy&quot;&lt;/code&gt;）；&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;工具页/画廊的占位&lt;/strong&gt;：用户明确在看图，愿意等；&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;首页列表的默认封面&lt;/strong&gt;：10 张起步，成本 ×N；&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;移动端优先的站点&lt;/strong&gt;：2.5 MB 在 4G 上就是几秒。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;真要在列表里用，正确的做法是&lt;strong&gt;让接口返回缩略图&lt;/strong&gt;（比如再压到 30 KB、宽度 400），并且给它一个正常的 &lt;code&gt;Cache-Control&lt;/code&gt;。这两件事都得改服务端，改不了就别用。&lt;/p&gt;
&lt;h2&gt;兜底：图挂了怎么办&lt;/h2&gt;
&lt;p&gt;外部图片还有一个绕不开的问题：&lt;strong&gt;对方挂了或者改规则了&lt;/strong&gt;。本站的处理是 &lt;code&gt;onerror&lt;/code&gt; 时隐藏图片、保留文字卡片：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;img src={image} onerror=&quot;this.style.display=&apos;none&apos;&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样最坏情况是&quot;卡片没有封面&quot;，而不是&quot;卡片里一个破图图标&quot;。说实话，这个兜底本身也说明了外部图片的定位：&lt;strong&gt;锦上添花，不是必需品&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;随机图是很典型的&quot;看起来免费&quot;的东西：省了自己做图的工夫，代价转移到了访客的流量和你的依赖清单上。&lt;/p&gt;
&lt;p&gt;三条结论：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;列表默认不要挂 250 KB 级的随机图&lt;/strong&gt;，成本按卡片数线性上升；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;单篇封面可以接受&lt;/strong&gt;，配上 &lt;code&gt;loading=&quot;lazy&quot;&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;判断标准很简单：&lt;strong&gt;这张图属于&quot;内容&quot;还是&quot;装饰&quot;&lt;/strong&gt; —— 装饰性的东西不该占据首屏流量的头几名。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我现在的做法是：文章有配图就用本地压缩好的 WebP（平均 70 KB、长缓存），没有就&lt;strong&gt;让卡片干净地只显示文字&lt;/strong&gt;。空一块，比 2.5 MB 划算。&lt;/p&gt;
</content:encoded></item><item><title>三层缓存各自为政：我的静态资源被一行 Cache-Control 拖慢</title><link>https://blog.peroe.cn/posts/blog-cache-layers/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-cache-layers/</guid><description>浏览器缓存、Cloudflare 边缘缓存、源站响应头三层叠在一起时，谁说了算？抓自己站的真实响应头发现：连内容哈希命名的 CSS/JS 都是 max-age=0，每次访问都要重新验证。附发现过程、Cloudflare 规则写法和免费套餐的坑。</description><pubDate>Sat, 24 Oct 2026 12:40:00 GMT</pubDate><content:encoded>&lt;p&gt;缓存这件事最容易产生「我以为」：以为 CDN 会缓存、以为浏览器会记住、以为 max-age 越大越好。真正会发生什么，只能看响应头。&lt;/p&gt;
&lt;p&gt;我写了个脚本抓自己站的缓存头，第一遍就发现：&lt;strong&gt;连文件名带内容哈希的 CSS/JS，都被设成了 &lt;code&gt;max-age=0&lt;/code&gt;。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-cache-layers/01-before.webp&quot; alt=&quot;抓包结果：所有资源都是 max-age=0, must-revalidate&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;三层缓存，各管一段&lt;/h2&gt;
&lt;p&gt;一个静态站请求要走三层，每层的依据不同：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层&lt;/th&gt;
&lt;th&gt;依据&lt;/th&gt;
&lt;th&gt;看什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;浏览器&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Cache-Control&lt;/code&gt; / &lt;code&gt;ETag&lt;/code&gt; / &lt;code&gt;Last-Modified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;要不要发请求、发条件请求还是全新请求&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare 边缘&lt;/td&gt;
&lt;td&gt;自己的缓存规则 + 源站头&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cf-cache-status: HIT/MISS&lt;/code&gt;、&lt;code&gt;age&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;源站（Worker 静态资源）&lt;/td&gt;
&lt;td&gt;部署时生成的头&lt;/td&gt;
&lt;td&gt;上面的 &lt;code&gt;Cache-Control&lt;/code&gt; 就是从这一层出来的&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;关键点：&lt;strong&gt;浏览器和边缘看的是同一行 &lt;code&gt;Cache-Control&lt;/code&gt;，但它们的判断逻辑不同&lt;/strong&gt;。Cloudflare 会把 &lt;code&gt;max-age=0&lt;/code&gt; 的资源也缓存（&lt;code&gt;cf-cache-status&lt;/code&gt; 照样 HIT，它靠自己的 TTL 与 revalidate 策略），而浏览器则会老老实实每次都回来问一句。&lt;/p&gt;
&lt;h2&gt;问题：哈希命名的资源也被设成 max-age=0&lt;/h2&gt;
&lt;p&gt;我的构建产物是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/_astro/MainGridLayout.C94uCSmW.css   ← 文件名里的 hash 就是内容指纹
/_astro/page.DkKPsu5I.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这种命名方式天然支持永久缓存：&lt;strong&gt;内容一变，文件名就变，旧文件永远不会被误用&lt;/strong&gt;。合理的头是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Cache-Control: public, max-age=31536000, immutable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但实际抓到的全是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Cache-Control: public, max-age=0, must-revalidate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这行对 HTML 是对的（内容会变，必须验证），对哈希资源就是浪费：&lt;strong&gt;每次进页面都要回源验证一遍 CSS/JS&lt;/strong&gt;，即使它们一年都不会变。&lt;/p&gt;
&lt;p&gt;另外还发现：&lt;strong&gt;HTML 页面没有 ETag&lt;/strong&gt;（&lt;code&gt;/&lt;/code&gt;、文章页都没有），意味着连&quot;条件请求省流量&quot;的机会都没有 —— 每次导航都是全新下载。而 &lt;code&gt;robots.txt&lt;/code&gt;、&lt;code&gt;sitemap.xml&lt;/code&gt;、CSS、JS 都有 ETag，带 &lt;code&gt;If-None-Match&lt;/code&gt; 会正确返回 304。&lt;/p&gt;
&lt;h2&gt;修法：给 /_astro/ 单独加一条响应头规则&lt;/h2&gt;
&lt;p&gt;源站是 Cloudflare Workers 的静态资源模式，我改不了它的默认头。但 Cloudflare 的 &lt;strong&gt;Response Header Transform&lt;/strong&gt; 规则可以覆盖：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;表达式：starts_with(http.request.uri.path, &quot;/_astro/&quot;)
动作：  set cache-control = public, max-age=31536000, immutable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用 API 一秒钟就能建好：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -X PUT \
  &quot;https://api.cloudflare.com/client/v4/zones/$ZONE/rulesets/phases/http_response_headers_transform/entrypoint&quot; \
  -H &quot;Authorization: Bearer $TOKEN&quot; -H &quot;Content-Type: application/json&quot; \
  --data-binary @rule.json
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;rules&quot;: [{
    &quot;action&quot;: &quot;rewrite&quot;,
    &quot;action_parameters&quot;: {
      &quot;headers&quot;: { &quot;cache-control&quot;: { &quot;operation&quot;: &quot;set&quot;, &quot;value&quot;: &quot;public, max-age=31536000, immutable&quot; } }
    },
    &quot;expression&quot;: &quot;starts_with(http.request.uri.path, \&quot;/_astro/\&quot;)&quot;,
    &quot;description&quot;: &quot;Astro build assets immutable cache&quot;
  }]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生效后的实测：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-cache-layers/02-after.webp&quot; alt=&quot;改后：CSS/JS 变成一年 immutable，HTML 与 robots 保持 max-age=0&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/_astro/*.css  -&amp;gt; cache-control: public, max-age=31536000, immutable
/_astro/*.js   -&amp;gt; cache-control: public, max-age=31536000, immutable
/              -&amp;gt; Cache-Control: public, max-age=0, must-revalidate
/robots.txt    -&amp;gt; Cache-Control: public, max-age=0, must-revalidate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HTML 保持 &lt;code&gt;max-age=0&lt;/code&gt; 是&lt;strong&gt;故意的&lt;/strong&gt;：页面内容每天都在变，用 ETag 验证比&quot;缓存十分钟&quot;更安全。&lt;/p&gt;
&lt;h2&gt;两个平台细节（写规则前值得知道）&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. 免费套餐不能用正则。&lt;/strong&gt; 我第一版写的是 &lt;code&gt;http.request.uri.path matches &quot;^/_astro/&quot;&lt;/code&gt;，直接被拒：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;not entitled: the use of operator Matches is not allowed,
a Business plan or a WAF Advanced plan is required
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;换成 &lt;code&gt;starts_with(...)&lt;/code&gt; 就行 —— 对路径前缀判断来说完全够用。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 规则只对「到达边缘的请求」生效。&lt;/strong&gt; 如果资源本身被浏览器缓存着（同一个 URL、未过期），请求根本不会到 Cloudflare，规则也就无从生效。所以验证时要么换 URL（构建产物 hash 变了自然会换），要么在 DevTools 里勾&quot;Disable cache&quot;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-cache-layers/03-site.webp&quot; alt=&quot;这些页面的 CSS/JS 现在会被浏览器缓存一年，不再每次回源验证&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;一套可以照抄的判断表&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;资源&lt;/th&gt;
&lt;th&gt;建议的 Cache-Control&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;哈希命名的构建产物（&lt;code&gt;/*.abc123.css&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public, max-age=31536000, immutable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;内容变则文件名变，永久缓存安全&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTML&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public, max-age=0, must-revalidate&lt;/code&gt; + ETag&lt;/td&gt;
&lt;td&gt;内容常变，靠 ETag 走 304&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;不哈希的图片&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public, max-age=86400&lt;/code&gt; 以上&lt;/td&gt;
&lt;td&gt;很少变；换图时改文件名&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;robots / sitemap / RSS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;max-age=0&lt;/code&gt; 或按更新频率&lt;/td&gt;
&lt;td&gt;搜索引擎需要拿到最新内容&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;缓存不是&quot;设一个数&quot;就完事，而是三层各自判断的结果。这次改动很小（一条规则、两个表达式），但它解释了为什么我的&lt;strong&gt;第二次访问&lt;/strong&gt;和第一次一样慢 —— 浏览器从来没记住过那些哈希资源。&lt;/p&gt;
&lt;p&gt;验证缓存改动只需要两个动作：&lt;strong&gt;抓两遍响应头&lt;/strong&gt;（看 &lt;code&gt;Cache-Control&lt;/code&gt; 与 &lt;code&gt;cf-cache-status&lt;/code&gt;）、&lt;strong&gt;发一次条件请求&lt;/strong&gt;（看是否 304）。两个都符合预期，才算真的缓存上了。&lt;/p&gt;
&lt;p&gt;首屏优化的完整背景（埋点延后、头像取图、关键 CSS 瘦身）见&lt;a href=&quot;/posts/blog-home-performance/&quot;&gt;首屏从 392KB 砍到 59KB&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;同一条链路上还有一个更顽固的对手：Cookie 横幅抢走了 LCP，过程见&lt;a href=&quot;/posts/blog-banner-lcp/&quot;&gt;和 Cookie 横幅抢 LCP&lt;/a&gt;。&lt;/p&gt;
</content:encoded></item><item><title>图片格式的收益边界：PNG → WebP → AVIF 实测</title><link>https://blog.peroe.cn/posts/blog-image-formats/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-image-formats/</guid><description>拿自己站上 12 张真实配图做实验：WebP 换 AVIF 在「同画质」下平均省 35%，但常见的「AVIF 省一半」是拿不同质量档比出来的。附 PSNR 对齐方法、编码耗时与什么时候才值得上 AVIF。</description><pubDate>Sat, 24 Oct 2026 12:20:00 GMT</pubDate><content:encoded>&lt;p&gt;「AVIF 比 WebP 小 50%」这句话在中文技术圈流传很广。我拿自己站上 12 张真实配图做了实验，结论是：&lt;strong&gt;同画质下平均省 35%，但如果你按&quot;质量参数相同&quot;去比，AVIF 甚至可能更大。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;差异在于怎么定义「同画质」。这篇文章把方法、数据和结论都写清楚。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-image-formats/01-sizes.webp&quot; alt=&quot;12 张真实配图：WebP 与 AVIF 在不同质量档下的体积&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;第一轮：按质量参数比，结论是错的&lt;/h2&gt;
&lt;p&gt;最直接的做法是「WebP quality=80 vs AVIF quality=80」。实测下来 AVIF 反而更大（上表 &lt;code&gt;q75&lt;/code&gt; 那一列已经能看出趋势，q80 更明显）。&lt;/p&gt;
&lt;p&gt;原因很简单：&lt;strong&gt;两种编码器的 quality 不是一个标尺&lt;/strong&gt;。WebP 的 80 与 AVIF 的 80 对应的视觉质量完全不同，直接比体积没有意义。&lt;/p&gt;
&lt;p&gt;这一轮唯一有效的结论是：AVIF 的等视觉质量点大概在 q50~q60 附近，而不是 q75+。&lt;/p&gt;
&lt;h2&gt;第二轮：用 PSNR 对齐画质&lt;/h2&gt;
&lt;p&gt;正确做法是先定义「什么算同画质」，再在那个点上比体积。我用 PSNR（峰值信噪比）当判据：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;参考图 = 仓库里现成的 WebP（它本身已经是有损压缩过的，作为基准）；&lt;/li&gt;
&lt;li&gt;对每张图用 AVIF 从 q40 到 q80 逐档编码，算它与参考图的 PSNR；&lt;/li&gt;
&lt;li&gt;取&lt;strong&gt;第一个 PSNR ≥ 38 dB&lt;/strong&gt; 的档位 —— 38 dB 大约是「并排看、放大到 100% 也看不出差别」的水平。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-image-formats/02-psnr.webp&quot; alt=&quot;用 PSNR 对齐画质后，AVIF 平均省 35%&quot; /&gt;&lt;/p&gt;
&lt;p&gt;结果：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;图片&lt;/th&gt;
&lt;th&gt;WebP q80&lt;/th&gt;
&lt;th&gt;同画质 AVIF&lt;/th&gt;
&lt;th&gt;节省&lt;/th&gt;
&lt;th&gt;编码耗时&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;78 KB 网页截图&lt;/td&gt;
&lt;td&gt;79.7 KB&lt;/td&gt;
&lt;td&gt;q50 → 46.1 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;42%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;138 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;144 KB 网页截图&lt;/td&gt;
&lt;td&gt;183.9 KB&lt;/td&gt;
&lt;td&gt;q60 → 152.1 KB&lt;/td&gt;
&lt;td&gt;17%&lt;/td&gt;
&lt;td&gt;146 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;31 KB 手机截图&lt;/td&gt;
&lt;td&gt;31.9 KB&lt;/td&gt;
&lt;td&gt;q50 → 19.1 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;167 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23 KB 终端截图&lt;/td&gt;
&lt;td&gt;23.7 KB&lt;/td&gt;
&lt;td&gt;q50 → 14.4 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;39%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;58 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;平均规律：文字/界面类截图省 35%~42%，照片类只省 17% 左右。&lt;/strong&gt; 这很符合直觉：AVIF 的优势在大面积平滑渐变与照片，而截图里全是锐利的文字边缘，省不出那么多。&lt;/p&gt;
&lt;p&gt;编码耗时方面，&lt;code&gt;1400×900&lt;/code&gt; 一张图 WebP 约 113 ms、AVIF q60 约 144 ms（1.3 倍）。对构建期批量处理来说完全可以接受。&lt;/p&gt;
&lt;h2&gt;那为什么我还在用 WebP&lt;/h2&gt;
&lt;p&gt;三个理由，按权重排：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 收益要看绝对值。&lt;/strong&gt; 我站上单张图平均 70 KB（51 张 / 3.63 MB），省 35% 就是每张省 25 KB、全站省约 1.2 MB。而文章页一次只加载 3~5 张图 —— &lt;strong&gt;单页实际省下 100 KB 左右&lt;/strong&gt;。这 100 KB 值得做，但不值得为它牺牲&quot;一套格式、一个脚本&quot;的简单性。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 兼容性还差一点。&lt;/strong&gt; AVIF 在 Chrome 85+、Firefox 93+、Safari 16.4+ 才支持。2023 年之后的浏览器都没问题，但&lt;strong&gt;没有一个通用格式做不到的兜底&lt;/strong&gt;：老浏览器打开就是空白图。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 一部分图是从截图直接来的。&lt;/strong&gt; 截图（大量锐利文字）恰好是 AVIF 收益最低的一类，而它是我最主要的图源。&lt;/p&gt;
&lt;h2&gt;什么时候值得上 AVIF&lt;/h2&gt;
&lt;p&gt;如果你符合下面两条以上，就该上：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;站点图片总量大（几十 MB 以上），或单张图很大（&amp;gt; 500 KB）；&lt;/li&gt;
&lt;li&gt;图片以照片/插画为主，而不是文字截图；&lt;/li&gt;
&lt;li&gt;访客主要在移动网络；&lt;/li&gt;
&lt;li&gt;已经有 &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; 或图片组件，能同时输出两种格式。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;写法就是标准的 &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; 回退：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;picture&amp;gt;
  &amp;lt;source srcset=&quot;/img/cover.avif&quot; type=&quot;image/avif&quot;&amp;gt;
  &amp;lt;source srcset=&quot;/img/cover.webp&quot; type=&quot;image/webp&quot;&amp;gt;
  &amp;lt;img src=&quot;/img/cover.jpg&quot; alt=&quot;封面&quot; width=&quot;1200&quot; height=&quot;675&quot; loading=&quot;lazy&quot;&amp;gt;
&amp;lt;/picture&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;浏览器会挑它支持的第一个格式，&lt;strong&gt;两种都压好、谁支持谁用&lt;/strong&gt;，这才是不牺牲兼容性的做法。&lt;/p&gt;
&lt;h2&gt;一个容易被忽略的对比维度&lt;/h2&gt;
&lt;p&gt;除了体积，还有两个数字值得一起看：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;WebP&lt;/th&gt;
&lt;th&gt;AVIF&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;同画质体积&lt;/td&gt;
&lt;td&gt;基准&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;省 17%~42%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;编码耗时&lt;/td&gt;
&lt;td&gt;113 ms&lt;/td&gt;
&lt;td&gt;144 ms（1.3×）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;解码耗时&lt;/td&gt;
&lt;td&gt;快&lt;/td&gt;
&lt;td&gt;略慢（移动端更明显）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;浏览器支持&lt;/td&gt;
&lt;td&gt;全平台&lt;/td&gt;
&lt;td&gt;2023 年后&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;解码耗时&lt;/strong&gt;这一项常被忽略：AVIF 的解码比 WebP 重，在低端手机上首屏图片渲染可能反而更慢。省下的字节要能抵过解码代价，才算真的赚。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-image-formats/03-render.webp&quot; alt=&quot;这些图在页面上的实际观感：压缩率再高，看不清就白搭&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;三条可以直接用：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;别按质量参数比&lt;/strong&gt;：WebP 80 与 AVIF 80 不是同一个东西，要先定义&quot;同画质&quot;（我用 PSNR ≥ 38 dB）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;截图与照片要分开看&lt;/strong&gt;：截图省 35%~40%，照片只省 15%~20%；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;决定因素是绝对字节数&lt;/strong&gt;：单页省 100 KB 值得做，但为它引入两套格式、两套脚本，就不一定了。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我目前的结论是：&lt;strong&gt;继续用 WebP 单格式&lt;/strong&gt;，等图片量再涨或者需要面向移动网络时，再切到 &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; + 双格式。到时候这篇文章里的数字可以直接拿来算收益。&lt;/p&gt;
&lt;p&gt;每一步怎么执行写在&lt;a href=&quot;/posts/blog-image-pipeline/&quot;&gt;文章配图流水线&lt;/a&gt;里，两篇可以对照着看。&lt;/p&gt;
&lt;p&gt;封面图该用多大的图、要不要用随机图接口，见&lt;a href=&quot;/posts/blog-cover-images/&quot;&gt;随机图当封面那篇&lt;/a&gt;。&lt;/p&gt;
</content:encoded></item><item><title>静态博客接评论：Giscus 的配置、主题联动与三个坑</title><link>https://blog.peroe.cn/posts/blog-giscus-comments/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-giscus-comments/</guid><description>静态博客没有后端，评论只能挂第三方。这篇记录 Giscus 的完整接入：六个配置字段各自的作用、用自定义 CSS 做暗色联动的原理、不靠肉眼验证评论是否真的加载的方法，以及仓库迁移、改 slug、iframe 布局跳动三个坑。</description><pubDate>Sat, 24 Oct 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;静态博客没有后端，评论只能挂在别人身上。我选的是 &lt;a href=&quot;https://giscus.app&quot;&gt;Giscus&lt;/a&gt;：把评论存进 GitHub Discussions，访客用 GitHub 账号登录发言。&lt;/p&gt;
&lt;p&gt;选它的理由只有两条，但都很硬：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;不用自己存任何用户数据&lt;/strong&gt; —— 没有邮箱、没有昵称、没有密码，登录和身份全在 GitHub 那边；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据是你的&lt;/strong&gt; —— 评论就是仓库里的 Discussion，随时可以导出、迁移，甚至换一个前端重新渲染。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;六个字段，各有各的用处&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;export const giscusConfig: GiscusConfig = {
    enable: true,
    repo: &quot;juluowork/giscus&quot;,          // 评论存在哪个仓库
    repoId: &quot;R_kgDOPdSugg&quot;,            // GraphQL node id（不是仓库名）
    category: &quot;Announcements&quot;,         // 存到哪个 Discussion 分类
    categoryId: &quot;DIC_kwDOPdSugs4CuINO&quot;,// 分类的 node id
    mapping: &quot;pathname&quot;,               // 页面与 thread 的对应规则
    lang: &quot;zh-CN&quot;,
    theme: `https://${customDomain}/css/giscus.css`,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个 &lt;code&gt;id&lt;/code&gt; 最容易填错：&lt;code&gt;repo&lt;/code&gt; 和 &lt;code&gt;category&lt;/code&gt; 只是给人看的，真正的依据是 &lt;code&gt;repoId&lt;/code&gt; / &lt;code&gt;categoryId&lt;/code&gt;（GitHub GraphQL 的 node id）。填错的表现是&quot;评论区加载出来了，但一直转圈 / 报 repo not found&quot;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;mapping: &quot;pathname&quot;&lt;/code&gt; 意味着&lt;strong&gt;一个 URL 路径对应一个 thread&lt;/strong&gt;。这一条后面会变成坑。&lt;/p&gt;
&lt;h2&gt;主题联动：iframe 拿不到父页面的样式&lt;/h2&gt;
&lt;p&gt;评论区是 iframe，&lt;strong&gt;它里面的 CSS 与你的站点完全隔离&lt;/strong&gt;。所以&quot;自动跟随亮/暗色&quot;不能靠继承，只能靠传参。&lt;/p&gt;
&lt;p&gt;Giscus 支持 &lt;code&gt;theme&lt;/code&gt; 传一个&lt;strong&gt;样式表 URL&lt;/strong&gt;，它会把这个 CSS 注入到 iframe 内部。于是我把主题色、背景色、字体都写进自己站点的 &lt;code&gt;/css/giscus.css&lt;/code&gt;，再让 iframe 指向它：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* public/css/giscus.css 的一部分 */
main { --color-canvas-default: transparent; }
.gsc-comment-box { border-radius: 0; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样做的好处是：&lt;strong&gt;换主题不用改 Giscus 配置&lt;/strong&gt;，因为样式文件本身就在你的仓库里。代价是改样式之后要让访客刷新（iframe 的 CSS 会被浏览器缓存）。&lt;/p&gt;
&lt;h2&gt;怎么验证「评论真的加载了」&lt;/h2&gt;
&lt;p&gt;「页面上有个框」不等于评论能用。我用的验证方法是量高度 + 看 iframe 参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;await page.goto(url, { waitUntil: &quot;load&quot; });
await page.waitForTimeout(6000);
const iframe = document.querySelector(&quot;iframe[src*=&apos;giscus&apos;]&quot;);
console.log(iframe.src);                          // 检查 origin / theme 参数
console.log(iframe.getBoundingClientRect().height); // 初始 150，加载完 372
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实测结果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;初始占位高度 &lt;strong&gt;150px&lt;/strong&gt;（脚本还没跑完）；&lt;/li&gt;
&lt;li&gt;滚到底、等 6 秒后 iframe 长到 &lt;strong&gt;372px&lt;/strong&gt;，说明评论 UI 真的渲染了；&lt;/li&gt;
&lt;li&gt;iframe 的 &lt;code&gt;src&lt;/code&gt; 里带着 &lt;code&gt;origin=https://blog.peroe.cn/posts/xxx/#giscus-container&lt;/code&gt; 与 &lt;code&gt;theme=https://blog.peroe.cn/css/giscus.css&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-giscus-comments/01-comments.webp&quot; alt=&quot;评论区实测：表情、评论数、编辑器与 GitHub 登录按钮都正常&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这个&quot;看高度&quot;的方法很笨，但比截图肉眼看更可靠 —— 因为&lt;strong&gt;加载失败时 iframe 停在 150px&lt;/strong&gt;，肉眼看起来只是&quot;评论区有点矮&quot;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-giscus-comments/02-iframe.webp&quot; alt=&quot;iframe 检查输出：src 参数正确、高度从 150 长到 372&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;三个坑&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. 仓库迁到组织之后，必须重装 Giscus App。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我把评论仓库从个人账号迁到了 &lt;code&gt;juluowork&lt;/code&gt; 组织，结果评论区一片空白。原因是 Giscus 的 GitHub App 授权是&lt;strong&gt;按账号&lt;/strong&gt;的：个人账号装过的授权，不会覆盖组织下的仓库。重新到 &lt;a href=&quot;https://github.com/apps/giscus&quot;&gt;https://github.com/apps/giscus&lt;/a&gt; 选组织安装即可，&lt;code&gt;repoId&lt;/code&gt; / &lt;code&gt;categoryId&lt;/code&gt; 都不用改。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 改 slug 等于换了一个 thread。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;mapping: &quot;pathname&quot;&lt;/code&gt; 用路径做键。我把一篇文章的 slug 从 &lt;code&gt;xxx&lt;/code&gt; 改成 &lt;code&gt;yyy&lt;/code&gt;，评论就&quot;消失&quot;了 —— 其实是新路径对应了一个新的 Discussion，旧评论还在旧 thread 里。&lt;strong&gt;如果文章已经有人评论，改 slug 前先想想这条&lt;/strong&gt;；实在要改，可以在旧 thread 留一条指向新地址的说明。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. iframe 是异步加载的，会给页面带来布局跳动。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;评论脚本在页面底部注入 iframe，高度从 0 涨到几百像素 —— 如果它上面还有内容，整块会被顶下去（和&lt;a href=&quot;/posts/blog-home-performance/&quot;&gt;上次那个 CLS&lt;/a&gt; 同源）。解决办法是给容器一个 &lt;code&gt;min-height&lt;/code&gt;，或者让脚本在滚动到附近时才加载。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;Giscus 的接入成本很低（填六个字段、贴一段脚本），但有三件事值得在接入时就定下来：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;评论仓库的归属&lt;/strong&gt;：放在组织下比个人账号更稳，但要记得重装 App；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;mapping 规则&lt;/strong&gt;：&lt;code&gt;pathname&lt;/code&gt; 最直观，但要接受&quot;改 slug = 换 thread&quot;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;验证方式&lt;/strong&gt;：量 iframe 高度、看 src 参数，比截图看一眼可靠。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;顺带说一句：这个站的评论数据（0 条）在 &lt;code&gt;juluowork/giscus&lt;/code&gt; 仓库的 Discussions 里，如果要迁移或备份，直接导出就是 Markdown。&lt;/p&gt;
&lt;p&gt;评论是本站第三个「把访客交给别人」的地方，完整清单在&lt;a href=&quot;/posts/blog-third-party-audit/&quot;&gt;第三方请求审计&lt;/a&gt;。&lt;/p&gt;
</content:encoded></item><item><title>Astro 静态站的三个隐形约定：CSS 是全局的</title><link>https://blog.peroe.cn/posts/blog-astro-traps/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-astro-traps/</guid><description>Astro 的默认行为大多很合理，但有三条不写在报错里的约定：script 默认被打包成模块、CSS import 默认对全站生效、dist 目录从不清理。每一条我都在这两周踩过，附判断命令与修法。</description><pubDate>Sat, 24 Oct 2026 11:40:00 GMT</pubDate><content:encoded>&lt;p&gt;用 Astro 写静态站很省心，但它的默认行为里有三条&lt;strong&gt;不写在报错里、也不写在文档首页&lt;/strong&gt;的约定。这三条我都在这两周里踩过，代价分别是：一个整站不跑、多下 200KB 关键 CSS、以及一次错误的性能/SEO 结论。&lt;/p&gt;
&lt;h2&gt;约定一：&lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 默认不是你想的那种 script&lt;/h2&gt;
&lt;p&gt;我在接 Cookie 同意横幅时写了这样一段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
import { cookieConsentConfig } from &quot;@/config&quot;;
---
&amp;lt;script&amp;gt;
  // 以为会原样输出到页面里
  cc.run({ website_name: cookieConsentConfig.websiteName });
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果浏览器报 &lt;code&gt;cookieConsentConfig is not defined&lt;/code&gt;，横幅整条不出现。&lt;/p&gt;
&lt;p&gt;原因：Astro 会把&lt;strong&gt;没有 &lt;code&gt;is:inline&lt;/code&gt; 的 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 当成模块打包&lt;/strong&gt;（&lt;code&gt;type=&quot;module&quot;&lt;/code&gt;，走 Vite 的模块图）。打包后它运行在独立作用域里，&lt;strong&gt;拿不到 frontmatter 的变量&lt;/strong&gt;。想让它和 frontmatter 共享数据，要么用 &lt;code&gt;is:inline&lt;/code&gt; 让它原样输出，要么用 &lt;code&gt;define:vars&lt;/code&gt; 显式注入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script is:inline define:vars={{ cookieConsentConfig }}&amp;gt;
  cc.run({ website_name: cookieConsentConfig.websiteName });
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一个坑在 JSON-LD 上还有变体：结构化数据必须用 &lt;code&gt;set:html&lt;/code&gt; 输出，不能用 &lt;code&gt;{JSON.stringify(x)}&lt;/code&gt;，否则引号会被转义成 &lt;code&gt;&amp;amp;quot;&lt;/code&gt;，搜索引擎读到一段坏 JSON。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-astro-traps/02-banner.webp&quot; alt=&quot;修好之后，Cookie 横幅正常出现，并且读得到 frontmatter 里的站点名&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;自检&lt;/strong&gt;：&lt;code&gt;grep -c &quot;is:inline&quot; dist/index.html&lt;/code&gt; —— 如果一个都没有，说明你站点里所有 inline 脚本都变成了模块，任何&quot;页面里的小脚本&quot;都会失效。&lt;/p&gt;
&lt;h2&gt;约定二：CSS import 默认是全局的&lt;/h2&gt;
&lt;p&gt;这条最贵。&lt;/p&gt;
&lt;p&gt;我在布局里 import 了公式与代码高亮的样式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// src/layouts/Layout.astro —— 每个页面都会经过这里
import &quot;katex/dist/katex.min.css&quot;;
import &quot;prismjs/themes/prism-tomorrow.css&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看起来很自然：文章页要公式、要代码块。但 Astro 会把&lt;strong&gt;布局链上的 CSS 合并进一份全站关键 CSS&lt;/strong&gt;，于是首页、归档、工具页统统要多下这份样式 —— 而首页连一个公式都没有。&lt;/p&gt;
&lt;p&gt;更糟的是，这份关键 CSS 里还有上游模板遗留的&lt;strong&gt;编辑器样式&lt;/strong&gt;（&lt;code&gt;@import &quot;@toast-ui/editor/dist/toastui-editor.css&quot;&lt;/code&gt;）：项目里根本没人 import 那个编辑器的 JS，也没有任何 &lt;code&gt;.toastui-editor-*&lt;/code&gt; 的 DOM，但它的样式一直在被打包。&lt;/p&gt;
&lt;p&gt;按选择器前缀统计，311.8 KB 的关键 CSS 是这么构成的：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-astro-traps/01-css-scope.webp&quot; alt=&quot;按选择器前缀统计关键 CSS：编辑器样式占了 160KB&quot; /&gt;&lt;/p&gt;
&lt;p&gt;修法两条：把只在文章页用的样式&lt;strong&gt;移到文章页&lt;/strong&gt;，把模板遗留的死 &lt;code&gt;@import&lt;/code&gt; 删掉。结果首页关键 CSS 从 &lt;strong&gt;311.8 KB 降到 104 KB&lt;/strong&gt;，文章页看不出任何差别。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;自检&lt;/strong&gt;：打开构建产物里的 HTML，数一下 &lt;code&gt;&amp;lt;link rel=&quot;stylesheet&quot;&amp;gt;&lt;/code&gt; 与它们的体积，再问一句「这个页面真的用得到这些样式吗」。工具页、首页、文章页的 CSS 才应该不同。&lt;/p&gt;
&lt;h2&gt;约定三：&lt;code&gt;dist/&lt;/code&gt; 从不清空&lt;/h2&gt;
&lt;p&gt;我为 SEO 体检写了个脚本，扫 &lt;code&gt;dist/**/*.html&lt;/code&gt;。某次扫出来 28 个页面，其中一个是 &lt;code&gt;/t/&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/t/   - canonical 指向 https://i.juluo.work/
      - 缺 description / 缺 h1 / 缺 JSON-LD
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但线上访问它明明 404。原因是：&lt;strong&gt;&lt;code&gt;dist/&lt;/code&gt; 在两次构建之间不会被清理&lt;/strong&gt;，&lt;code&gt;/t/&lt;/code&gt; 是几天前删掉的页面残留的产物。我的审计脚本于是给了一份「本地有问题、线上不存在」的结论。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-astro-traps/03-ghost.webp&quot; alt=&quot;幽灵页面：本地 dist 里还在，线上早就 404&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Cloudflare 从干净仓库构建，所以线上是对的；但&lt;strong&gt;本地做任何基于产物的分析（审计、体积统计、性能测试）之前，都该先删 &lt;code&gt;dist/&lt;/code&gt;&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;自检&lt;/strong&gt;：&lt;code&gt;ls dist/*/index.html&lt;/code&gt; 对着 &lt;code&gt;src/pages&lt;/code&gt; 数一遍；更省事的做法是每次分析前跑一次 &lt;code&gt;rm -rf dist &amp;amp;&amp;amp; pnpm astro build&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;附带的第四个坑：0 不等于「没有数据」&lt;/h2&gt;
&lt;p&gt;这个坑不是 Astro 的，但同样隐形，而且就在这一轮被截图暴露出来：新发布的文章卡片上显示「0 次」，而自建统计里明明已经有浏览记录。&lt;/p&gt;
&lt;p&gt;原因在合并逻辑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 之前：只有 Umami 返回 undefined 才认为&quot;缺口&quot;
const missing = paths.filter((p) =&amp;gt; umami?.paths[p] === undefined);
// ...
const all = { ...selfHosted, ...umami };   // umami 的 0 会盖掉自建的正数
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Umami 对&lt;strong&gt;新路径返回的是「存在但为 0」&lt;/strong&gt;，不是「没有这条记录」。于是自建侧的数被当成&quot;已经由 Umami 覆盖&quot;，永远显示 0。修法是把 &lt;code&gt;0&lt;/code&gt; 也当作缺口，并在合并时让自建的正数优先：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const missing = paths.filter((p) =&amp;gt; {
    const v = umami?.paths[p];
    return v === undefined || v === 0;      // 0 也算缺口
});

const all = { ...(selfHosted ?? {}) };
for (const [p, v] of Object.entries(umami?.paths ?? {})) {
    if (v &amp;gt; 0 || all[p] === undefined) all[p] = v;   // 0 不覆盖已有的正数
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;教训：&lt;strong&gt;「查不到」和「查到了 0」是两件事&lt;/strong&gt;。API 用 0 表示&quot;没有数据&quot;时，任何&quot;缺省才补&quot;的兜底逻辑都会失效。&lt;/p&gt;
&lt;h2&gt;三条约定合起来的一句话&lt;/h2&gt;
&lt;p&gt;Astro 把「布局、样式、脚本、产物」都当成可优化的整体来管，所以它的默认值是&lt;strong&gt;为产物服务&lt;/strong&gt;的，不是为「你以为的作用域」服务的。判断方法很简单：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;脚本&lt;/strong&gt;：产物里的 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 有没有 &lt;code&gt;type=&quot;module&quot;&lt;/code&gt;？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;样式&lt;/strong&gt;：产物里每个页面引用的 CSS 是不是同一份、多大？&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;产物&lt;/strong&gt;：你分析的是不是一次干净构建的结果？&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这三问都答得出来，Astro 的「隐形约定」就不隐形了。&lt;/p&gt;
&lt;p&gt;同一条「作用域」思路也解释了&lt;a href=&quot;/posts/blog-cache-layers/&quot;&gt;缓存那篇&lt;/a&gt;的结论：内容哈希命名的资源应当永久缓存。&lt;/p&gt;
</content:encoded></item><item><title>同一个站的两套统计，为什么差了 2.7 倍</title><link>https://blog.peroe.cn/posts/blog-analytics-compare/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-analytics-compare/</guid><description>本站装着两套统计：Umami Cloud 与自建的 Worker + D1。同一时间窗内，首页一个记 67 次、一个记 180 次。我做了个实验证明主因是广告拦截器，并列出另外三种会放大差距的机制，以及最后决定给读者看哪个数字。</description><pubDate>Sat, 24 Oct 2026 11:20:00 GMT</pubDate><content:encoded>&lt;p&gt;本站装着两套访问统计，而且都是我自己在用的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;/posts/blog-view-counter/&quot;&gt;Umami Cloud&lt;/a&gt; —— 从 2025 年就在记，跨域名迁移后历史保留；&lt;/li&gt;
&lt;li&gt;自建 Worker + D1 —— 一个 &lt;code&gt;tracker.js&lt;/code&gt; 加一张 &lt;code&gt;pageviews&lt;/code&gt; 表（&lt;code&gt;pathname&lt;/code&gt; 主键 + &lt;code&gt;views&lt;/code&gt;）。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;按理说它们看的是同一批访客。但把时间窗对齐到「迁移到 &lt;code&gt;peroe.cn&lt;/code&gt; 之后」，同一段时间里&lt;strong&gt;首页被记了 67 次和 180 次&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-analytics-compare/01-compare.webp&quot; alt=&quot;同一时间窗的两套统计：首页 67 vs 180，全站 267 vs 294&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;先对齐口径，再谈差异&lt;/h2&gt;
&lt;p&gt;不同口径比大小没有意义，所以我做了三件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;同一个时间窗&lt;/strong&gt;：都从 2026-10-06（迁到新域那天）开始算 —— Umami 里还留着旧站 &lt;code&gt;blog.peroe.top&lt;/code&gt; 的历史，不截断的话它当然更大；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同一个指标&lt;/strong&gt;：都取 pageview（页面浏览次数），不拿 visitors / visits 去比；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同一个站点&lt;/strong&gt;：都只看 &lt;code&gt;blog.peroe.cn&lt;/code&gt; 下的路径。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;对齐之后，全站是 267 对 294 —— 差 10%，还算接近。但&lt;strong&gt;单个页面差得离谱&lt;/strong&gt;：首页 67 vs 180（2.7 倍）、&lt;code&gt;/posts/hello-world/&lt;/code&gt; 1 vs 7（7 倍）。&lt;/p&gt;
&lt;h2&gt;实验：把 Umami 的域名挡掉&lt;/h2&gt;
&lt;p&gt;第一个怀疑对象是广告拦截器。&lt;code&gt;cloud.umami.is&lt;/code&gt; 是个知名统计域名，uBlock、AdGuard 之类默认就会拦；而 &lt;code&gt;t.peroe.cn&lt;/code&gt; 是我自己的域名，任何拦截列表里都没有。&lt;/p&gt;
&lt;p&gt;验证方式很直接：用 Playwright 打开首页，把 &lt;code&gt;*.umami.is&lt;/code&gt; 的请求全部 abort（模拟装了拦截器），然后看自建计数的表现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;await page.route(&quot;**://*.umami.is/**&quot;, (route) =&amp;gt; route.abort());   // 模拟拦截

const before = 179;   // 实验前 D1 里 &quot;/&quot; 的 views
await page.goto(&quot;https://blog.peroe.cn/&quot;);
await page.waitForTimeout(9000);
// 实验后：D1 的 &quot;/&quot; 变成 180
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果：&lt;strong&gt;被拦下的 Umami 请求 2 个、自建计数 +1、Umami +0&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;也就是说，装了拦截器的访客在两套统计里只留下一条记录 —— 他们的每一次访问，Umami 都看不见，而自建计数看得见。中文互联网上装拦截器的比例不低，这足以解释绝大部分差距。&lt;/p&gt;
&lt;h2&gt;另外三种会放大差距的机制&lt;/h2&gt;
&lt;p&gt;除了拦截器，还有三个我在实现里能确认的因素：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 脚本加载时机不同。&lt;/strong&gt; 我把 GA4 与 Clarity 延后到 &lt;code&gt;requestIdleCallback&lt;/code&gt; 才加载（见&lt;a href=&quot;/posts/blog-home-performance/&quot;&gt;首屏优化那篇&lt;/a&gt;），Umami 的 &lt;code&gt;script.js&lt;/code&gt; 也在那次改造后跟着延后；而 &lt;code&gt;tracker.js&lt;/code&gt; 是页面一解析就执行。&lt;strong&gt;秒退的访客&lt;/strong&gt;在 Umami 这边可能根本没等到 idle 就离开了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. bot 过滤不同。&lt;/strong&gt; Umami 会按 User-Agent 过滤已知爬虫；我的 &lt;code&gt;tracker.js&lt;/code&gt; 只有一行 &lt;code&gt;fetch&lt;/code&gt;，不做任何判断。这个站被扫描器盯得挺紧（Cloudflare 日志里每天都有几十个伪装 Googlebot 的请求打 &lt;code&gt;/.env&lt;/code&gt;），它们大多不执行 JS，但只要有能跑 JS 的抓取器，自建计数就会虚高。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 我自己的自动化访问。&lt;/strong&gt; 写这几篇文章的过程中，我在这台机器上把首页加载了上百次（截图、测性能、验证结构化数据）。每一次都是真实的一次 &lt;code&gt;/send&lt;/code&gt;，也都有机会被 Umami 记上 —— 但它俩对&quot;这次访问&quot;的容忍度不同：我经常在页面刚 &lt;code&gt;load&lt;/code&gt; 完就关掉浏览器，Umami 的 idle 回调还没跑。&lt;/p&gt;
&lt;h2&gt;那到底该信哪个&lt;/h2&gt;
&lt;p&gt;我的结论是：&lt;strong&gt;两个都不&quot;错&quot;，它们量的是不同的集合。&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Umami 更接近&quot;人&quot;&lt;/strong&gt;：过滤爬虫、要求脚本加载完成，数字偏低但更像真实读者；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自建 D1 更接近&quot;请求&quot;&lt;/strong&gt;：只要页面跑了 JS 就记，数字偏高但能覆盖被拦截器挡掉的那部分。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以我把它们当&lt;strong&gt;区间&lt;/strong&gt;用：真实访问量大概在「Umami 的 267」到「D1 的 294」之间，偏向 Umami 那一侧（因为拦截器用户毕竟还是少数，而我的自动化访问占了不少）。&lt;/p&gt;
&lt;h2&gt;展示哪个数字&lt;/h2&gt;
&lt;p&gt;站点侧栏显示的是 Umami 的数字：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-analytics-compare/02-sidebar.webp&quot; alt=&quot;侧栏显示的访问量取自 Umami，而不是自建 D1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;理由有两个：一是它跨域名迁移后历史连续（从 2025 年记到现在），读者看到的是一条平滑上升的曲线，而不是某天突然归零；二是它过滤了爬虫，数字更&quot;干净&quot;。&lt;/p&gt;
&lt;p&gt;自建 D1 则退居兜底 —— 当某个路径 Umami 还没有数据（比如刚发布几分钟的新文章）时，才用它补一下。&lt;/p&gt;
&lt;h2&gt;如果你也想接两套统计&lt;/h2&gt;
&lt;p&gt;三条建议：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;先对齐口径再比&lt;/strong&gt;：截断时间窗、只比同一个指标，否则会得出&quot;某套统计坏了&quot;的错误结论；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;把差异当成信息&lt;/strong&gt;：两套的差就是&quot;被拦截/被过滤的比例&quot;，这本身比单一数字更有意思；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;展示时选一个口径并写清楚&lt;/strong&gt;：读者不需要知道你有两套统计，但你自己要知道屏幕上那个数字代表什么。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;顺带一个实现细节：D1 的 HTTP 查询有&lt;strong&gt;几秒的读延迟&lt;/strong&gt;，我刚发完一条就查会看不到。第一次实验我因此以为&quot;自建计数没记上&quot;，多等几秒才发现早就 &lt;code&gt;+1&lt;/code&gt; 了 —— 排查计数问题时，先怀疑读延迟，再怀疑写入。&lt;/p&gt;
&lt;p&gt;这些数字最终怎么显示到页面上，见&lt;a href=&quot;/posts/blog-view-counter/&quot;&gt;浏览量统计那篇&lt;/a&gt;；完整的第三方清单见&lt;a href=&quot;/posts/blog-third-party-audit/&quot;&gt;审计那篇&lt;/a&gt;。&lt;/p&gt;
</content:encoded></item><item><title>文章配图流水线：截图、压成 WebP、随构建分发</title><link>https://blog.peroe.cn/posts/blog-image-pipeline/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-image-pipeline/</guid><description>写技术文章一半时间花在图上。这套流水线只有四条规则：截图必须真实、按出现顺序编号、统一压成 WebP、和文章目录同名进仓库。附实测压缩数据（2073KB → 357KB）与五个踩过的坑。</description><pubDate>Sat, 24 Oct 2026 11:00:00 GMT</pubDate><content:encoded>&lt;p&gt;写一篇带实测的技术文章，大概一半时间花在图上：截哪几张、截图里的数字对不对、压完还看不看得清、放进哪个目录。这些事重复十几次之后，必须有流水线——否则每次都要重新想一遍。&lt;/p&gt;
&lt;p&gt;我这 13 篇文章一共 51 张配图、3.63 MB，全部走同一条流水线。规则只有四条。&lt;/p&gt;
&lt;h2&gt;规则一：截图必须是真实页面的&lt;/h2&gt;
&lt;p&gt;不做示意图、不用拼贴。写性能就打开 Chrome 的 Performance 面板或跑真实脚本，写 Cloudflare 就截控制台里的真实配置。文章里说「实测」的每一个数字，都要能在截图里找到。&lt;/p&gt;
&lt;p&gt;截图工具是 Playwright + 本机 Chrome，&lt;strong&gt;必须 &lt;code&gt;--headed&lt;/code&gt;&lt;/strong&gt; —— 无头模式会被 Cloudflare 的浏览器校验拦住，截出来是一片空白。常用参数就几个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 整页截图，等 3 秒让懒加载与动画稳定，顺手隐藏 Cookie 横幅
node shot.mjs --url https://blog.peroe.cn/posts/xxx/ \
  --out ./02-post.png --width 1280 --height 900 --scroll 700 \
  --hide &quot;#termsfeed-com---nb&quot; --wait 3500
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--hide&lt;/code&gt; 那一条是血泪：Cookie 横幅固定在底部，不藏掉会挡住正文的结论部分。&lt;/p&gt;
&lt;h2&gt;规则二：文件名就是出场顺序&lt;/h2&gt;
&lt;p&gt;配图放在 &lt;code&gt;public/assets/images/posts/&amp;lt;slug&amp;gt;/&lt;/code&gt;，&lt;strong&gt;目录名与文章 slug 完全一致&lt;/strong&gt;，文件按正文出现顺序编号：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public/assets/images/posts/blog-image-pipeline/
├── 01-convert.webp     ← 正文里第一张出现的图
├── 02-post.webp
└── 03-weight.webp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;正文里用绝对路径引用（以 &lt;code&gt;/&lt;/code&gt; 开头，走 public 目录）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;![压缩实测：2073KB → 357KB](/assets/images/posts/blog-image-pipeline/01-convert.webp)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;目录名和 slug 不一致是最常见的 404 来源：文章能构建成功、图片全裂，而且本地开发时因为缓存还可能看着正常。&lt;/p&gt;
&lt;h2&gt;规则三：统一压成 WebP，宽度封顶&lt;/h2&gt;
&lt;p&gt;截图先存 PNG（无损、方便复核），再统一转 WebP：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-image-pipeline/01-convert.webp&quot; alt=&quot;压缩实测：一批截图 2073KB → 357KB&quot; /&gt;&lt;/p&gt;
&lt;p&gt;我这批的压缩率是 &lt;strong&gt;83%&lt;/strong&gt;，单张从几百 KB 降到一个屏都不一定用得完的大小。脚本做的事情很简单：&lt;code&gt;Pillow&lt;/code&gt; 打开、&lt;code&gt;convert(&quot;RGB&quot;)&lt;/code&gt;、&lt;code&gt;save(quality=80)&lt;/code&gt;、限制最大宽度 1500px（截图放大就看不清字了，1500 够用）。&lt;/p&gt;
&lt;p&gt;几个经验值：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;建议&lt;/th&gt;
&lt;th&gt;原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;终端输出 / 表格&lt;/td&gt;
&lt;td&gt;宽度 1000~1100、quality 80&lt;/td&gt;
&lt;td&gt;等宽字体在高压缩下容易糊&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;网页截图&lt;/td&gt;
&lt;td&gt;宽度 1280~1400、quality 80&lt;/td&gt;
&lt;td&gt;再大就是浪费，页面本身按 1200 设计&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;图表 / 细线&lt;/td&gt;
&lt;td&gt;quality 85&lt;/td&gt;
&lt;td&gt;细线在 WebP 里最先出现振铃&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;单张上限&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;300 KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;超过就该考虑裁掉无关区域&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;规则四：图进仓库，不走图床&lt;/h2&gt;
&lt;p&gt;这是被讨论最多的一条。我选择把图片直接提交进仓库，理由是三个「不」：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;不会跑图&lt;/strong&gt;：图床跑路、防盗链、限速、跨域——这些问题全部消失，图片和 HTML 同源、同一套 CDN；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不依赖第三方&lt;/strong&gt;：访客打开文章时，除了统计与评论，不再有任何外部图片请求；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;构建时能校验&lt;/strong&gt;：图片不存在，构建产物里就是一个 404 的 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;，本地一看就知道。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;代价是仓库体积。所以我给单张设了 300 KB 的上限（当前最大的一张 184 KB），并且&lt;strong&gt;只保留文章里真正用到的图&lt;/strong&gt;——草稿截图、重复截图一律不进仓库。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-image-pipeline/03-weight.webp&quot; alt=&quot;目前 13 篇文章共 51 张图、3.63 MB，按文章分布&quot; /&gt;&lt;/p&gt;
&lt;p&gt;3.63 MB 对一个博客来说完全可以接受：它随构建分发，不占额外存储、不需要额外域名，也不会因为某个第三方服务挂掉而集体失效。&lt;/p&gt;
&lt;h2&gt;五个踩过的坑&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. 忘了按顺序编号&lt;/strong&gt;。写完后插入了一张新图，编号乱了，正文里的 &lt;code&gt;01/02/03&lt;/code&gt; 与目录对不上。解决办法是写完先列一遍图的位置，再统一编号。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 直接把 PNG 提交了&lt;/strong&gt;。一张 1280×900 的网页截图 PNG 有 800 KB 到 1.2 MB，同样尺寸的 WebP 只有 40~120 KB。现在 &lt;code&gt;public/&lt;/code&gt; 里不允许出现 PNG 截图。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 目录名与 slug 不一致&lt;/strong&gt;。改 slug 时忘了改目录，图片全 404。现在改 slug 会顺手 &lt;code&gt;git mv&lt;/code&gt; 目录。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. 截图里带着指纹信息&lt;/strong&gt;。浏览器窗口标题栏、书签栏、系统时钟都可能泄露「这台机器是谁」。要么截图前隐藏，要么裁掉页头。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. 正文里的数字与截图对不上&lt;/strong&gt;。有一次脚本重跑后数字变了，正文还写着旧值。现在改完会重新截图，并把数字直接从脚本输出里抄——&lt;strong&gt;不要凭记忆写数字&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;一条命令的收尾流程&lt;/h2&gt;
&lt;p&gt;现在的固定动作是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 1. 截图（headed，藏掉 Cookie 横幅）
node shot.mjs --url &amp;lt;文章地址&amp;gt; --out 02-post.png --width 1280 --hide &quot;#termsfeed-com---nb&quot;

# 2. 转 WebP（顺便限制宽度）
python to-webp.py . --quality 80 --max-width 1500

# 3. 确认体积（单张 &amp;lt; 300KB）
ls -lh *.webp

# 4. 写文章、引用、构建
pnpm astro build --force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;四步走完，图的格式、体积、路径、命名就都是确定的——写正文时可以专心在内容上。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;配图流水线的价值不在「省时间」，而在&lt;strong&gt;让每次的结果一样&lt;/strong&gt;：同样的命名规则、同样的压缩参数、同样的引用路径。当这些都不需要再想的时候，写文章才真正只剩下「把话说清楚」这件事。&lt;/p&gt;
&lt;p&gt;三条可以立刻照抄的规则：&lt;strong&gt;真实截图&lt;/strong&gt;（数字能对上）、&lt;strong&gt;顺序编号 + 目录同名&lt;/strong&gt;（不会 404）、&lt;strong&gt;一律 WebP 且单张 &amp;lt; 300 KB&lt;/strong&gt;（不拖慢文章页）。其余的（工具、参数）按自己的习惯换都行。&lt;/p&gt;
&lt;p&gt;格式选择（继续用 WebP 还是上 AVIF）的实测数据在&lt;a href=&quot;/posts/blog-image-formats/&quot;&gt;图片格式的收益边界&lt;/a&gt;。&lt;/p&gt;
</content:encoded></item><item><title>静态博客的隐私边界：一次第三方请求审计</title><link>https://blog.peroe.cn/posts/blog-third-party-audit/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-third-party-audit/</guid><description>静态博客没有后端，并不意味着访客只跟你的服务器打交道。用脚本审一遍页面请求：首页向 15 个第三方域发出了 30 个请求，其中一个来自模板遗留的 browser-update.org，会把访客 IP 与 UA 交给外部服务。附审计脚本与自查清单。</description><pubDate>Sat, 24 Oct 2026 10:35:00 GMT</pubDate><content:encoded>&lt;p&gt;「静态站没有后端，所以没什么隐私问题」——这句话只说对了一半。没有数据库、没有登录、没有人存你的手机号，但&lt;strong&gt;页面本身可以调外部服务&lt;/strong&gt;：统计、评论、头像、Cookie 横幅、性能监控……这些多半不是你主动选的，而是跟着模板一起搬进来的。&lt;/p&gt;
&lt;p&gt;我写了个脚本把首页与文章页的外部请求全部列出来，结果是：&lt;strong&gt;首页向 15 个第三方域发出了 30 个请求&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-third-party-audit/01-before.webp&quot; alt=&quot;审计结果：15 个第三方域，其中一个是模板遗留的 browser-update.org&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;怎么审：只看「不是我的域名」&lt;/h2&gt;
&lt;p&gt;思路很朴素：拦截所有响应，凡是 host 不等于自己域名的，按域聚合并统计。核心代码十几行（Node + Playwright）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;page.on(&quot;response&quot;, (res) =&amp;gt; {
    const u = new URL(res.url());
    if (u.hostname === &quot;blog.peroe.cn&quot;) return;   // 自己的域跳过
    reqs.push({
        host: u.hostname,
        type: res.request().resourceType(),        // script / image / xhr / fetch
        len: Number(res.headers()[&quot;content-length&quot;] ?? 0),
    });
});

// 等 9 秒：埋点脚本是延后加载的，早扫描会漏掉
await page.waitForTimeout(9000);

// 再按 host 聚合打印：${count}× ${bytes} ${host} [${types}]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个容易漏的地方：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;别在 &lt;code&gt;load&lt;/code&gt; 事件就收工&lt;/strong&gt; —— 埋点、懒加载图片、评论组件都可能在 &lt;code&gt;load&lt;/code&gt; 之后才请求，我这里是等了 9 秒；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;fetch&lt;/code&gt;/&lt;code&gt;xhr&lt;/code&gt; 也算&lt;/strong&gt; —— 最典型的追踪请求（GA 的 &lt;code&gt;/g/collect&lt;/code&gt;、Umami 的 &lt;code&gt;/api/send&lt;/code&gt;）都是 fetch，不是脚本。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;15 个域都是谁&lt;/h2&gt;
&lt;p&gt;把结果按「谁发起的」分类，其实很清楚：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类别&lt;/th&gt;
&lt;th&gt;域&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;自己的服务&lt;/td&gt;
&lt;td&gt;&lt;code&gt;t.peroe.cn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;自建浏览量统计（&lt;code&gt;tracker.js&lt;/code&gt; + &lt;code&gt;/send&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内容必需&lt;/td&gt;
&lt;td&gt;&lt;code&gt;q2.qlogo.cn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;侧栏头像（QQ 头像服务）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;评论&lt;/td&gt;
&lt;td&gt;&lt;code&gt;giscus.app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;文章页才加载，基于 GitHub Discussions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie 合规&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www.termsfeed.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同意横幅脚本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;统计&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cloud.umami.is&lt;/code&gt; + &lt;code&gt;gateway-us.umami.is&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Umami 上报 + 共享数据读取&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;统计&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www.googletagmanager.com&lt;/code&gt; + &lt;code&gt;www.google-analytics.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GA4（&lt;strong&gt;177 KB&lt;/strong&gt; 的 &lt;code&gt;gtag.js&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;统计&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www.clarity.ms&lt;/code&gt; + &lt;code&gt;scripts.clarity.ms&lt;/code&gt; + &lt;code&gt;c./i./q.clarity.ms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clarity 主脚本与上报&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;平台&lt;/td&gt;
&lt;td&gt;&lt;code&gt;static.cloudflareinsights.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cloudflare Web Analytics（RUM），由 CDN 侧自动注入&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这些都是我自己开的、知道的。但列表最后一行是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1x  browser-update.org  [script]  /update.min.js      ← 模板遗留
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;这个我从来没开通过。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;意外发现：模板自带的「浏览器过旧」脚本&lt;/h2&gt;
&lt;p&gt;顺藤摸瓜，它在 &lt;code&gt;BodyThirdPartyScripts.astro&lt;/code&gt; 里，是一段从上游模板继承下来的代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var $buoop = { required: { e: -4, f: -3, o: -3, s: -1, c: -3 }, api: 2024.11, ... };
function $buo_f() {
    var e = document.createElement(&quot;script&quot;);
    e.src = &quot;//browser-update.org/update.min.js&quot;;   // ← 每次访问都会请求它
    document.body.appendChild(e);
}
document.addEventListener(&quot;DOMContentLoaded&quot;, $buo_f, false);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它做的是「如果你的浏览器太旧，就弹一条提示」。听起来无害，代价是：&lt;strong&gt;每个访客打开任何一个页面，都会向 &lt;code&gt;browser-update.org&lt;/code&gt; 发一个请求&lt;/strong&gt;，连带把 IP、User-Agent、Referer 暴露给一个跟本站毫无关系的第三方。&lt;/p&gt;
&lt;p&gt;而我这个站本来就需要现代浏览器（用到了 &lt;code&gt;:has()&lt;/code&gt;、CSS 嵌套、&lt;code&gt;svh&lt;/code&gt; 单位），旧浏览器打开本来就是「样式崩了」而不是「能看但别扭」——为了一条提示，多一个追踪面，不值。&lt;/p&gt;
&lt;p&gt;删掉整块脚本和配套的 &lt;code&gt;.buorg&lt;/code&gt; 样式（1833 字节），重新扫一遍：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-third-party-audit/02-after.webp&quot; alt=&quot;删除后：外部请求 30 → 29，域 15 → 14，browser-update.org 消失&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;另一个发现：10 个请求只为了显示几个数字&lt;/h2&gt;
&lt;p&gt;审计里最扎眼的是这行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;10x  gateway-us.umami.is  [fetch]  /api/share/...  /api/websites/...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;首页要显示「全站浏览量」和每篇文章的浏览量，而 Umami 的共享接口&lt;strong&gt;没有批量查询&lt;/strong&gt;，只能一条路径一个请求：1 个全站 + 9 篇文章 = 10 个请求。&lt;/p&gt;
&lt;p&gt;这是我在 &lt;a href=&quot;/posts/blog-view-counter/&quot;&gt;浏览量那篇&lt;/a&gt; 里做过的取舍：要连续的历史数字（Umami 从 2025 年就在记），就得接受 N+1 个请求。如果哪天更在意请求数，可以换成 &lt;code&gt;metrics?type=path&lt;/code&gt; 一次拿全站 Top N —— 但那是 UV 口径，和现在展示的 PV 对不上。&lt;/p&gt;
&lt;p&gt;写在这里是想说明：&lt;strong&gt;第三方请求不只是&quot;隐私问题&quot;，也是&quot;你自己设计的成本&quot;&lt;/strong&gt;。审计时两种都要看。&lt;/p&gt;
&lt;h2&gt;隐私页现在写什么&lt;/h2&gt;
&lt;p&gt;删掉一个脚本之后，我把 &lt;code&gt;/privacy/&lt;/code&gt; 的第三方清单补全了 —— 之前漏写的两个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare Web Analytics&lt;/strong&gt;：CDN 侧自动注入 &lt;code&gt;beacon.min.js&lt;/code&gt;，不使用 Cookie，但会上报页面地址、加载耗时、浏览器信息；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TermsFeed&lt;/strong&gt;：同意横幅的脚本来源，以及它写入的 &lt;code&gt;cookie_consent_level&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-third-party-audit/03-privacy.webp&quot; alt=&quot;隐私政策里的第三方清单：每一类都写明域名与用途&quot; /&gt;&lt;/p&gt;
&lt;p&gt;原则很简单：&lt;strong&gt;页面上跑了谁的代码，隐私政策里就要有谁&lt;/strong&gt;。列出域名比写「我们可能使用第三方服务」有用得多，因为访客可以直接对照自己浏览器的网络面板。&lt;/p&gt;
&lt;h2&gt;一份可以照抄的自查清单&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;把第三方请求当成待办事项&lt;/strong&gt;：新装一个评论/统计/字体服务，就顺手跑一次审计；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;区分「我选的」和「模板带的」&lt;/strong&gt;：前者写进隐私政策，后者默认删掉再说 —— 需要再装；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;注意延迟加载&lt;/strong&gt;：等够时间（我用 9 秒）再统计，否则埋点会被漏掉；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;连 &lt;code&gt;fetch&lt;/code&gt; 一起看&lt;/strong&gt;：很多追踪走的是 fetch，不是 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;头像、字体、图标也是第三方&lt;/strong&gt;：它们能看到访客的 IP 与 Referer；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;定期复查&lt;/strong&gt;：模板升级、插件更新都可能悄悄加回东西。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;静态博客的隐私优势是&lt;strong&gt;真的&lt;/strong&gt;（没有后端、没有数据库、没有账号体系），但它不等于「零第三方」。这次审计最大的收获不是删掉了一个脚本，而是意识到：&lt;strong&gt;你交付的页面是一个请求图，而不是一份 HTML&lt;/strong&gt;。那张图里有几个节点，值得你每隔一阵子数一遍。&lt;/p&gt;
&lt;p&gt;审计脚本我留在本地工具目录里，以后每次加新服务都跑一次；目前第三方已经从 15 个域降到 14 个，剩下的每一个我都能说清它为什么存在。&lt;/p&gt;
&lt;p&gt;第三方里最容易被忽略的是评论系统（见 &lt;a href=&quot;/posts/blog-giscus-comments/&quot;&gt;Giscus 那篇&lt;/a&gt;）；至于两套统计为什么数字不同，见&lt;a href=&quot;/posts/blog-analytics-compare/&quot;&gt;两套统计对比&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;顺着「谁在收数据」再往下想一层，就是&lt;a href=&quot;/posts/blog-self-audit/&quot;&gt;我的博客向陌生人暴露了多少信息&lt;/a&gt;——证书日志、DNS、页面标识三层。&lt;/p&gt;
</content:encoded></item><item><title>首屏从 392KB 砍到 59KB，FCP 从 7.4 秒到 2.1 秒</title><link>https://blog.peroe.cn/posts/blog-home-performance/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-home-performance/</guid><description>用 CDP 模拟中端手机实测博客首页：GA4 与 Clarity 挤在关键路径、头像和 favicon 直接加载 107KB 原图、312KB 关键 CSS 拖慢首屏。四处改动后传输 392KB→59KB，FCP 7.4s→2.1s，长任务 13→2 个。</description><pubDate>Sat, 24 Oct 2026 09:45:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;[!TIP]
2026-10-24 后续：又做了两轮 —— 从关键 CSS 里挖出 &lt;strong&gt;160KB 编辑器死代码&lt;/strong&gt;（282KB → 104KB），
最后发现 CLS 的真凶是&lt;strong&gt;侧栏在 DOM 里排在 &amp;lt;main&amp;gt; 之前&lt;/strong&gt;、而视觉顺序相反，把侧栏挪到后面后
CLS 直接归零。三项核心指标现已全部进入 Google「良好」区间，详见文末两节。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;上一轮做完 &lt;a href=&quot;/posts/blog-seo-self-audit/&quot;&gt;SEO 自我体检&lt;/a&gt;，顺手用同样的思路量了一次首屏：&lt;strong&gt;FCP 7.4 秒、LCP 43 秒、CLS 0.5&lt;/strong&gt;，而且 LCP 元素居然是 Cookie 横幅的文案 —— 说明打开页面那几秒里，浏览器的注意力全被别的请求抢走了。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
测量方式：Playwright 驱动本机 Chrome，用 CDP 把网络限到 &lt;strong&gt;1.6Mbps / 150ms RTT&lt;/strong&gt;、CPU 降速 &lt;strong&gt;4×&lt;/strong&gt;（大致相当于一台中端安卓机在 4G 上的体感），再用 &lt;code&gt;PerformanceObserver&lt;/code&gt; 采集 FCP / LCP / CLS / 长任务。
所有数字都是同一脚本、同一参数、改动前后各跑两遍的结果。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;别只看分数，要看「谁在抢首屏」&lt;/h2&gt;
&lt;p&gt;Lighthouse 会给一个总分，但它不会告诉你 LCP 元素是 Cookie 横幅。我更关心两个问题：&lt;strong&gt;首屏到底在等什么&lt;/strong&gt;、&lt;strong&gt;谁把主线程占住了&lt;/strong&gt;。所以脚本除了打指标，还记录每次请求的字节数与资源类型，以及 LCP 到底落在哪个元素上。&lt;/p&gt;
&lt;p&gt;第一次跑出来是这样：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-home-performance/01-before.webp&quot; alt=&quot;改造前：FCP 7.4 秒，LCP 落在 Cookie 横幅上，单页 392KB / 76 个请求&quot; /&gt;&lt;/p&gt;
&lt;p&gt;拆开看，问题有四层：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;现象&lt;/th&gt;
&lt;th&gt;证据&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;埋点脚本抢跑&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gtag.js&lt;/code&gt; 单个 &lt;strong&gt;177.1KB&lt;/strong&gt;，Clarity 还要再拉一坨；两者都在首屏阶段就开始下&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;头像和 favicon 用原图&lt;/td&gt;
&lt;td&gt;&lt;code&gt;q2.qlogo.cn&lt;/code&gt; 的 &lt;code&gt;spec=0&lt;/code&gt; 原图 &lt;strong&gt;106.8KB&lt;/strong&gt;，被下载了两次（头像 + favicon）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;关键 CSS 过大&lt;/td&gt;
&lt;td&gt;首页要下 311.8KB 的 &lt;code&gt;MainGridLayout.css&lt;/code&gt;（里面混进了 KaTeX 与 Prism 的样式）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;结果&lt;/td&gt;
&lt;td&gt;长任务 10~13 个 / 1.7~2.1 秒；LCP 被晚到的 Cookie 横幅顶替&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;改动一：埋点延后到空闲时段&lt;/h2&gt;
&lt;p&gt;统计脚本对首屏没有任何价值，但它默认和页面同时开始加载。我把 GA4 与 Clarity 都挪到 &lt;code&gt;window.load&lt;/code&gt; 之后的 &lt;code&gt;requestIdleCallback&lt;/code&gt; 里再动态注入：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var loadAnalytics = function () {
    if (window.__trackingDenied) return;   // 访客拒绝过就一个字节都不下载

    window.dataLayer = window.dataLayer || [];
    window.gtag = window.gtag || function () { window.dataLayer.push(arguments); };
    window.gtag(&quot;js&quot;, new Date());
    window.gtag(&quot;config&quot;, GA_ID);
    var g = document.createElement(&quot;script&quot;);
    g.async = true;
    g.src = &quot;https://www.googletagmanager.com/gtag/js?id=&quot; + GA_ID;
    document.head.appendChild(g);
};

var idle = window.requestIdleCallback || function (fn) { return setTimeout(fn, 1200); };
if (document.readyState === &quot;complete&quot;) idle(loadAnalytics);
else window.addEventListener(&quot;load&quot;, function () { idle(loadAnalytics); }, { once: true });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段改动的关键是&lt;strong&gt;不能改变行为语义&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;统计口径不变 —— pageview 只是晚一两秒上报；&lt;/li&gt;
&lt;li&gt;「默认开启、拒绝即停用」不变 —— 加载前再查一次 &lt;code&gt;__trackingDenied&lt;/code&gt;，拒绝过的访客&lt;strong&gt;一个埋点请求都不会发&lt;/strong&gt;（我专门用 &lt;code&gt;cookie_consent_level.tracking=false&lt;/code&gt; 跑了一遍：0 个请求、&lt;code&gt;ga-disable&lt;/code&gt; 为 true、Clarity 未被注入）；&lt;/li&gt;
&lt;li&gt;用户中途点「同意」也不需要特判 —— 那时 &lt;code&gt;__trackingDenied&lt;/code&gt; 是 false，延迟加载自然会执行。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;改动二：头像按 DPR 取图&lt;/h2&gt;
&lt;p&gt;头像在侧栏里显示宽度最多 280px，但 &lt;code&gt;&amp;lt;img src&amp;gt;&lt;/code&gt; 指向的是 &lt;code&gt;spec=0&lt;/code&gt;（QQ 头像的原图），实测 106.8KB。qlogo 支持用 &lt;code&gt;spec&lt;/code&gt; 指定尺寸，我实测了三档：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;spec&lt;/th&gt;
&lt;th&gt;实际体积&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spec=0&lt;/code&gt;（原图）&lt;/td&gt;
&lt;td&gt;106.8 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spec=640&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;52.8 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spec=140&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.2 KB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;于是给它加 &lt;code&gt;srcset&lt;/code&gt;，让浏览器自己挑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const avatarSrcSet = config.avatar.includes(&quot;spec=0&quot;)
	? `${config.avatar.replace(&quot;spec=0&quot;, &quot;spec=140&quot;)} 1x, ${config.avatar.replace(&quot;spec=0&quot;, &quot;spec=640&quot;)} 2x`
	: undefined;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再加 &lt;code&gt;fetchpriority=&quot;low&quot;&lt;/code&gt;（头像不该和正文抢优先级）。&lt;code&gt;width&lt;/code&gt; / &lt;code&gt;height&lt;/code&gt; 本来就有，所以不会引入新的布局偏移。&lt;/p&gt;
&lt;h2&gt;改动三：favicon 顺手换成小图&lt;/h2&gt;
&lt;p&gt;排查时发现 &lt;code&gt;spec=0&lt;/code&gt; 那张 106.8KB 的图被请求了&lt;strong&gt;两次&lt;/strong&gt;：一次是头像，一次是 favicon —— 而 favicon 在模板里直接复用了同一个 URL。浏览器对 favicon 的请求很积极，等于每来一个访客就白下 100KB。&lt;/p&gt;
&lt;p&gt;改成 &lt;code&gt;spec=140&lt;/code&gt;（6.2KB），缩到 16px / 32px 完全够用。&lt;/p&gt;
&lt;h2&gt;改动四：把 KaTeX 与 Prism 赶出全站关键 CSS&lt;/h2&gt;
&lt;p&gt;311.8KB 的关键 CSS 是这么来的：&lt;code&gt;Layout.astro&lt;/code&gt; 里全局 import 了 &lt;code&gt;katex.min.css&lt;/code&gt;、&lt;code&gt;prism-tomorrow.css&lt;/code&gt; 和自定义的 &lt;code&gt;prism-custom.css&lt;/code&gt;。可公式与代码块&lt;strong&gt;只出现在文章里&lt;/strong&gt;，首页并不需要。&lt;/p&gt;
&lt;p&gt;把这三个 import 从 &lt;code&gt;Layout.astro&lt;/code&gt; 挪到 &lt;code&gt;src/pages/posts/[...slug].astro&lt;/code&gt;，首页 CSS 从 &lt;strong&gt;311.8KB 降到 282KB&lt;/strong&gt;，文章页依旧完整（多出 32.4KB 的 katex/prism chunk，按需加载）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-// Layout.astro（全站）
-import &quot;prismjs/themes/prism-tomorrow.css&quot;;
-import &quot;katex/dist/katex.min.css&quot;;
-import &quot;../styles/prism-custom.css&quot;;

+// src/pages/posts/[...slug].astro（只有文章页）
+import &quot;katex/dist/katex.min.css&quot;;
+import &quot;prismjs/themes/prism-tomorrow.css&quot;;
+import &quot;../../styles/prism-custom.css&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;效果&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-home-performance/02-after.webp&quot; alt=&quot;改造后：FCP/LCP 2.1~2.8 秒，单页 59KB / 41 个请求，长任务只剩 2~3 个&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;改造前&lt;/th&gt;
&lt;th&gt;改造后&lt;/th&gt;
&lt;th&gt;变化&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FCP&lt;/td&gt;
&lt;td&gt;3708 / 7400 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2132 / 2772 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;约快 3 倍&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LCP&lt;/td&gt;
&lt;td&gt;24080 / 43372 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2132 / 2772 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;从「被横幅顶替」变成头像&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;长任务&lt;/td&gt;
&lt;td&gt;10~13 个 / 1686~2121 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2~3 个 / 134~204 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;主线程压力降一个数量级&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;头像 + favicon&lt;/td&gt;
&lt;td&gt;215 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;59 KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;少下 156KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;关键 CSS&lt;/td&gt;
&lt;td&gt;311.8 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;282 KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;少 30KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-home-performance/03-home-mobile.webp&quot; alt=&quot;手机宽度下的首页：侧栏头像与列表都在，视觉上没有任何变化&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;第二轮：282 KB 里有 160 KB 是死代码&lt;/h2&gt;
&lt;p&gt;既然 CLS 卡在关键 CSS 上，下一步就是把它拆小。我写了个脚本按选择器前缀统计这 282 KB 的构成，结果很意外：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;103.0 KB  (36.6%)  .toastui-editor-toolbar-
 32.9 KB  (11.7%)  .toastui-editor-context-
 15.8 KB  ( 5.6%)  .custom-md
 12.0 KB  ( 4.3%)  .prose
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;TOAST UI 编辑器的样式占了 160 多 KB&lt;/strong&gt;，其中最夸张的一条规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.toastui-editor-toolbar-icons,
.toastui-editor-context-menu span:before { ... }   /* 71.9 KB */
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;那是把整套工具栏图标以 base64 内联成的雪碧图。问题是：这个站&lt;strong&gt;根本没有编辑器&lt;/strong&gt; —— 没有任何代码 &lt;code&gt;import &quot;@toast-ui/editor&quot;&lt;/code&gt; 的 JS，也没有任何组件渲染 &lt;code&gt;.toastui-editor-*&lt;/code&gt; 的 DOM。它只是上游模板留在 &lt;code&gt;src/styles/main.css&lt;/code&gt; 开头的两个 &lt;code&gt;@import&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-@import &quot;@toast-ui/editor/dist/toastui-editor.css&quot;;
-@import &quot;@toast-ui/editor/dist/theme/toastui-editor-dark.css&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;删掉之后，首页关键 CSS &lt;strong&gt;282 KB → 104 KB&lt;/strong&gt;（−63%）。&lt;/p&gt;
&lt;p&gt;顺手又处理了另一块：&lt;code&gt;fancybox.css&lt;/code&gt;（32.4 KB）只有「点开文章图片」时才用得上，但 Layout 里那段脚本静态 import 了它，于是每个页面都吃一份。改成按需动态 import：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function initFancybox() {
    if (!document.querySelector(&quot;.custom-md img, #post-cover img&quot;)) return;  // 没图片的页面直接返回
    const { bindFancybox, cleanupFancybox } = await import(&quot;@utils/fancybox&quot;);
    cleanupFancybox();
    bindFancybox(&quot;.custom-md img, #post-cover img&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在 fancybox 的 JS 变成独立 chunk，只有文章页点图片时才下载（lightbox 行为实测未受影响）；它的 CSS 仍被 Astro 挂在共享 chunk 上，这块还没解决。&lt;/p&gt;
&lt;h2&gt;第三轮：CLS 的真凶是 DOM 顺序&lt;/h2&gt;
&lt;p&gt;砍掉 178 KB 之后 CLS 从 0.43 降到 0.29~0.39，但&lt;strong&gt;没根治&lt;/strong&gt;。我一度以为继续砍 CSS 就行，直到把位移的坐标打印出来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;归档页：CLS 0.392，侧栏 y 78 → 564，位移 486px
主内容高度：486px
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;位移量 &lt;strong&gt;恰好等于主内容的高度&lt;/strong&gt; —— 这不是&quot;字号变了导致的轻微抖动&quot;，而是&lt;strong&gt;侧栏被整块推到主内容下面&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;再去看 &lt;code&gt;MainGridLayout.astro&lt;/code&gt; 的结构就明白了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!-- DOM 顺序：侧栏在前，主内容在后 --&amp;gt;
&amp;lt;div id=&quot;sidebar&quot;&amp;gt;…&amp;lt;/div&amp;gt;
&amp;lt;main id=&quot;main-content&quot;&amp;gt;…&amp;lt;/main&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但手机端网格用 &lt;code&gt;row-start-1&lt;/code&gt; / &lt;code&gt;row-start-2&lt;/code&gt; 把主内容放到第一行、侧栏放到第二行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;main id=&quot;main-content&quot; class=&quot;row-start-1 …&quot;&amp;gt;
&amp;lt;div id=&quot;sidebar&quot; class=&quot;row-start-2 …&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;DOM 顺序与视觉顺序相反&lt;/strong&gt;。样式还没生效（或晚到）时，浏览器只能按 DOM 顺序先把侧栏排在最上面；样式一到，网格把侧栏推到主内容下面 —— 486px 的整块位移就这么产生了。这也解释了为什么&quot;关掉 JS 依然位移&quot;：跟脚本无关，纯粹是排版兜底状态与最终状态不一致。&lt;/p&gt;
&lt;p&gt;修法只有一件事：&lt;strong&gt;把 &lt;code&gt;&amp;lt;div id=&quot;sidebar&quot;&amp;gt;&lt;/code&gt; 整块移到 &lt;code&gt;&amp;lt;/main&amp;gt;&lt;/code&gt; 之后&lt;/strong&gt;，让 DOM 顺序与视觉顺序对齐。桌面端的左右布局由显式 &lt;code&gt;md:col-start-*&lt;/code&gt; / &lt;code&gt;lg:col-start-*&lt;/code&gt; 决定，不受 DOM 顺序影响，所以一行都不用改。&lt;/p&gt;
&lt;p&gt;改完实测：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;页面&lt;/th&gt;
&lt;th&gt;改前&lt;/th&gt;
&lt;th&gt;改后&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;首页&lt;/td&gt;
&lt;td&gt;0.394&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.000（0 次偏移）&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;归档页&lt;/td&gt;
&lt;td&gt;0.392&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.000（0 次偏移）&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;最终成绩&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;优化前&lt;/th&gt;
&lt;th&gt;最终&lt;/th&gt;
&lt;th&gt;Google「良好」线&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FCP&lt;/td&gt;
&lt;td&gt;3708 / 7400 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1364 / 1744 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 1800 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LCP&lt;/td&gt;
&lt;td&gt;24080 / 43372 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1644 / 1924 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 2500 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLS&lt;/td&gt;
&lt;td&gt;0.50 / 0.70&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;长任务&lt;/td&gt;
&lt;td&gt;10~13 个 / 1.7~2.1 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1~2 个 / 53~117 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;首页传输&lt;/td&gt;
&lt;td&gt;392 KB / 76 请求&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;59 KB / 41 请求&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;关键 CSS&lt;/td&gt;
&lt;td&gt;311.8 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;104 KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;三项核心 Web Vitals 全部进入「良好」区间。回头看，几轮改动里&lt;strong&gt;收益最大的一条几乎没有技术含量&lt;/strong&gt;：把侧栏从 &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; 前面挪到后面。&lt;/p&gt;
&lt;p&gt;这也印证了排查 CLS 的正确姿势：先打印&lt;strong&gt;位移元素与位移量&lt;/strong&gt;，再看那个数字能不能和某个元素的高度对上。对上了，八成是布局顺序/占位问题，而不是字体或图片。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;这次最值钱的不是那 4 个改动，而是&lt;strong&gt;测量脚本&lt;/strong&gt;：同一套参数，改完再跑一遍，数字会诚实告诉你哪一步有效。&lt;/p&gt;
&lt;p&gt;如果你也想量一次自己的站，记住三件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;一定要&lt;strong&gt;同时降网速和降 CPU&lt;/strong&gt;，只降一个会得出误导性结论；&lt;/li&gt;
&lt;li&gt;记录每个请求的&lt;strong&gt;字节数和类型&lt;/strong&gt;，比看总分有用得多；&lt;/li&gt;
&lt;li&gt;盯住 &lt;strong&gt;LCP 元素是谁&lt;/strong&gt; —— 如果它是个 Cookie 横幅或侧栏卡片，说明真正的内容根本没排上队。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;现在回头看，最贵的教训是那条 0.39 的 CLS：它逼着我先砍了 178KB 的 CSS（那 160KB 死代码确实该删），
但真正修好它只花了一个 div 的位置调整。&lt;strong&gt;先量清楚，再动手。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;缓存层是同一套思路的另一半：&lt;a href=&quot;/posts/blog-cache-layers/&quot;&gt;三层缓存各自为政&lt;/a&gt;里解释了为什么哈希资源要永久缓存；改样式踩的坑则记在 &lt;a href=&quot;/posts/blog-astro-traps/&quot;&gt;Astro 的三个隐形约定&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;另外把「部署体积」和「访客成本」分开量了一遍：&lt;code&gt;dist&lt;/code&gt; 16 MB，但首页压缩后只传 28 KB，见&lt;a href=&quot;/posts/blog-build-size/&quot;&gt;构建产物 16 MB，访客每次只下 28 KB&lt;/a&gt;。&lt;/p&gt;
</content:encoded></item><item><title>博客 SEO 自我体检：27 个页面里揪出四个系统性毛病</title><link>https://blog.peroe.cn/posts/blog-seo-self-audit/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-seo-self-audit/</guid><description>不靠站长后台，直接扫构建产物做 SEO 体检：发现全站 h1 数量为 0、28 个页面都没有 og:image、静态页共用同一段描述、404 页输出了两份 head。附体检清单与修法。</description><pubDate>Sat, 24 Oct 2026 08:45:00 GMT</pubDate><content:encoded>&lt;p&gt;站点上线三个月，Google 收录了首页和几篇文章，但长尾几乎没动静。与其在 Search Console 里猜，不如把构建产物当成一份可以逐行检查的试卷：&lt;strong&gt;我自己写了一支&quot;体检笔&quot;，一次性扫完 27 个页面&lt;/strong&gt;，然后发现四个毛病全都不是个别页面写歪，而是&lt;strong&gt;模板层的系统性缺口&lt;/strong&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
环境：Astro 静态站（&lt;code&gt;output: &quot;static&quot;&lt;/code&gt;），产物在 &lt;code&gt;dist/&lt;/code&gt;，Node 22 + Python 3.13。
体检脚本用 Python 写，只读 HTML、不依赖第三方库，跑一次不到两秒。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;为什么不用现成工具&lt;/h2&gt;
&lt;p&gt;Lighthouse、PageSpeed Insights、SEO 检测站我全都试过，它们的问题不是不准，而是&lt;strong&gt;方向不对&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;它们盯着单页打分，而模板问题会以&quot;每页都掉一分&quot;的形式出现，&lt;strong&gt;看不出共性&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;它们检查的是普遍规则（有没有 viewport、有没有 HSTS），不关心&quot;这个站的标题是不是一个 &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;&quot;；&lt;/li&gt;
&lt;li&gt;最要命的是&lt;strong&gt;依赖线上环境&lt;/strong&gt;：本地改完还要等部署才能看到结果，来回十几分钟。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而我要回答的问题是：「我这套模板，&lt;strong&gt;每一页&lt;/strong&gt;都合格吗？」这本质上是&lt;strong&gt;对构建产物做批处理&lt;/strong&gt;，写脚本比点按钮快得多。&lt;/p&gt;
&lt;h2&gt;体检脚本：一张规则表 + 一次遍历&lt;/h2&gt;
&lt;p&gt;核心就是「读 HTML → 抽字段 → 套阈值 → 打印表格」。规则表是全文的灵魂，我列在下面，你可以按自己站的情况增删：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;检查项&lt;/th&gt;
&lt;th&gt;规则&lt;/th&gt;
&lt;th&gt;为什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;存在，12~48 字&lt;/td&gt;
&lt;td&gt;中文标题在 SERP 里大约展示 28~30 字，太长会被截断&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;存在，60~160 字，且&lt;strong&gt;各页不同&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;全站共用一段描述等于放弃摘要控制权&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;canonical&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;存在、绝对地址、&lt;strong&gt;指向自身&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;分页和标签页最容易写错&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;恰好 1 个&lt;/strong&gt;，且非空&lt;/td&gt;
&lt;td&gt;0 个是丢了主标题语义，多个是主题分散&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;存在且可访问&lt;/td&gt;
&lt;td&gt;分享到群里有没有缩略图全靠它&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON-LD&lt;/td&gt;
&lt;td&gt;存在且能被 &lt;code&gt;json.loads&lt;/code&gt; 解析&lt;/td&gt;
&lt;td&gt;结构错一个逗号就整段失效&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;html lang&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;存在&lt;/td&gt;
&lt;td&gt;中文站最常见的一处遗漏&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;img alt&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;全部有&lt;/td&gt;
&lt;td&gt;顺手查的无障碍项&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;正文字数&lt;/td&gt;
&lt;td&gt;工具页/文章页 ≥ 300 字&lt;/td&gt;
&lt;td&gt;抓&quot;JS 渲染、爬虫看到空壳&quot;的薄内容&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;代码不长，关键只有几行（完整版见文末）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for page in sorted(DIST.rglob(&quot;*.html&quot;)):
    html = page.read_text(encoding=&quot;utf-8&quot;, errors=&quot;replace&quot;)
    html = re.sub(r&quot;(?s)&amp;lt;!--.*?--&amp;gt;&quot;, &quot; &quot;, html)      # 注释里的 &amp;lt;title&amp;gt; 会骗过正则
    h1s = re.findall(r&quot;(?is)&amp;lt;h1[^&amp;gt;]*&amp;gt;(.*?)&amp;lt;/h1&amp;gt;&quot;, html)
    ...
    if len(h1s) != 1:
        issues.append(f&quot;h1 数量 = {len(h1s)}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!TIP]
第二行是踩坑换来的：我一开始忘了剥注释，结果 404 页被判成「标题 330 字」——
因为页面里有一句注释写着 &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;，正则一路吃到真正的 &lt;code&gt;&amp;lt;/title&amp;gt;&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;第一次跑出来的结果&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-seo-self-audit/01-audit-before.webp&quot; alt=&quot;体检脚本输出：文章页 h1 全部为 0，静态页描述全是 34 字&quot; /&gt;&lt;/p&gt;
&lt;p&gt;28 个页面，28 个&quot;有问题&quot;。把重复的行合并同类项，其实是四件事：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;文章页 h1 = 0        → 9 篇文章 + 首页 + 分页页 + 归档页，一共 12 个页面
全站缺 og:image      → 28 / 28
静态页描述 = 34 字   → 8 个静态页 + 7 个工具页，全是站点默认描述
404 页 head 重复     → 两份 &amp;lt;head&amp;gt;、两个 &amp;lt;title&amp;gt;、两段描述
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;毛病一：文章标题根本不是 h1&lt;/h3&gt;
&lt;p&gt;打开文章页源码，标题长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div id=&quot;post-title&quot; class=&quot;...text-3xl...&quot;&amp;gt;
    &amp;lt;span&amp;gt;博客 SEO 自我体检&amp;lt;/span&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;主题作者把标题当成&quot;一段排版好的文字&quot;来写，视觉上完全没问题 —— 字号、粗细、左侧那条主题色竖线都对。但&lt;strong&gt;语义上它只是一个 div&lt;/strong&gt;，搜索引擎读到的文章页没有主标题。&lt;/p&gt;
&lt;p&gt;修法是把 &lt;code&gt;div&lt;/code&gt; 换成 &lt;code&gt;h1&lt;/code&gt;，&lt;code&gt;id&lt;/code&gt; 和 &lt;code&gt;class&lt;/code&gt; 一个都不动：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-&amp;lt;div id=&quot;post-title&quot; class=&quot;...&quot;&amp;gt;
+&amp;lt;h1 id=&quot;post-title&quot; class=&quot;...&quot;&amp;gt;
     &amp;lt;span&amp;gt;{entry.data.title}&amp;lt;/span&amp;gt;
-&amp;lt;/div&amp;gt;
+&amp;lt;/h1&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里有个细节值得记：&lt;code&gt;#post-title&lt;/code&gt; 被两个脚本按 id 依赖着（行内 diff 高亮、Pagefind 的搜索权重标记），所以&lt;strong&gt;只换标签、不换 id&lt;/strong&gt;，改完这两处逻辑毫发无损。&lt;/p&gt;
&lt;p&gt;首页、&lt;code&gt;/2/&lt;/code&gt;、&lt;code&gt;/archive/&lt;/code&gt; 是另一类情况：它们本来就&lt;strong&gt;没有可见标题&lt;/strong&gt;（只有文章卡片列表）。硬塞一个大标题会破坏设计，我的做法是给布局加一个 &lt;code&gt;heading&lt;/code&gt; 属性，在 &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; 里输出一个 &lt;code&gt;sr-only&lt;/code&gt; 的 h1：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{heading &amp;amp;&amp;amp; &amp;lt;h1 class=&quot;sr-only&quot;&amp;gt;{heading}&amp;lt;/h1&amp;gt;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后首页传「站点名 · 副标题」、分页页传「站点名 · 第 N 页」、归档页传「文章归档」。屏幕阅读器和爬虫都能读到，视觉上零变化。&lt;/p&gt;
&lt;h3&gt;毛病二：28 个页面都没有分享图&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Layout.astro&lt;/code&gt; 里原本是这么写的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ogImage &amp;amp;&amp;amp; &amp;lt;meta property=&quot;og:image&quot; content={new URL(ogImage, Astro.site).toString()} /&amp;gt;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;逻辑本身没错，问题在于&lt;strong&gt;文章 frontmatter 里普遍没有 &lt;code&gt;image&lt;/code&gt; 字段&lt;/strong&gt; —— 于是 &lt;code&gt;ogImage&lt;/code&gt; 永远是 &lt;code&gt;undefined&lt;/code&gt;，这行永远不输出。分享到群里，卡片就是纯文字。&lt;/p&gt;
&lt;p&gt;修法是给站点做一张默认图，然后&lt;strong&gt;无条件输出&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const resolvedOgImage = new URL(ogImage || &quot;/og-default.png&quot;, Astro.site).toString();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-seo-self-audit/04-og-card.webp&quot; alt=&quot;默认分享图：1200×630，暗色底 + 主题色竖线 + 站点名&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这张图是用 Python + Pillow 画的（1200×630，竖版渐变 + 主题色光晕 + 微软雅黑），35 KB。只有用默认图时才补 &lt;code&gt;og:image:width/height&lt;/code&gt;，文章自带封面时就不写死尺寸 —— 免得声明了 1200×630 实际是别的比例。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-seo-self-audit/02-home.webp&quot; alt=&quot;站点首页：h1 改成 sr-only 之后，视觉上和改之前一模一样&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;毛病三：十几个页面共用同一段描述&lt;/h3&gt;
&lt;p&gt;体检表里那些 &lt;code&gt;34&lt;/code&gt; 特别扎眼：所有不传 &lt;code&gt;description&lt;/code&gt; 的页面都落到了 &lt;code&gt;siteConfig.description&lt;/code&gt;。对搜索引擎来说，这十几个页面的摘要是一模一样的 —— &lt;strong&gt;摘要本该是&quot;这一页讲什么&quot;，不是&quot;这个站讲什么&quot;&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;修法很土但有效：给每个页面单独写。14 个页面，全部控制在 60~72 字（中文的 SERP 摘要大约展示 78 字，60 是个安全值）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;MainGridLayout
    title=&quot;工具&quot;
    description=&quot;peroe 的博客在线工具箱：封面生成、图片画廊、幻影坦克、零宽短链等小工具，全部在浏览器本地运行，不上传数据、不需要登录。&quot;
&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因为文件多，这类机械改动我用 Python 批量做，每处替换都断言「命中数 == 1」：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;n = text.count(old)
assert n == 1, f&quot;{rel}: 期望命中 1 处，实际 {n} 处&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没有这条断言，&lt;code&gt;replace&lt;/code&gt; 静默不命中时你根本不会发现 —— 这项检查在本次改 23 处时救了我两次（有两处字符串写错了一个空格）。&lt;/p&gt;
&lt;h3&gt;毛病四：404 页输出了两份 head&lt;/h3&gt;
&lt;p&gt;体检表里 &lt;code&gt;/404&lt;/code&gt; 那一行 &lt;code&gt;description&lt;/code&gt; 是 34 字，可我明明给它写了描述。翻构建产物才发现页面里有&lt;strong&gt;两份 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;html&amp;gt;&amp;lt;head&amp;gt;...&amp;lt;title&amp;gt;页面未找到 - peroe 的博客&amp;lt;/title&amp;gt;
       &amp;lt;meta name=&quot;description&quot; content=&quot;（站点默认描述）&quot;&amp;gt;
...&amp;lt;!----&amp;gt;&amp;lt;html&amp;gt;&amp;lt;head&amp;gt;&amp;lt;title&amp;gt;页面不存在 - peroe 的博客&amp;lt;/title&amp;gt;...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原因是 404 页这么写的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;Layout title=&quot;页面未找到&quot;&amp;gt;          &amp;lt;!-- 外层已经套了一个 Layout --&amp;gt;
    &amp;lt;MainGridLayout title=&quot;页面不存在&quot;&amp;gt;  &amp;lt;!-- 这里面又包含一个 Layout --&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MainGridLayout&lt;/code&gt; 内部本身就包了 &lt;code&gt;Layout&lt;/code&gt;，再套一层就成了「Layout 里套 Layout」——整份文档被渲染两遍，浏览器能容错，爬虫只会困惑。删掉外层那层就干净了。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
这类错误&lt;strong&gt;不会报错、不会白屏&lt;/strong&gt;，只有把产物当文本看才会暴露。所以体检脚本必须扫 &lt;code&gt;dist/&lt;/code&gt;，而不是看浏览器里&quot;页面能用&quot;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;附带发现：薄内容工具页&lt;/h3&gt;
&lt;p&gt;体检表还标了两行字数：&lt;code&gt;/tools/gallery/&lt;/code&gt; 只有 105 字、&lt;code&gt;/tools/files/&lt;/code&gt; 只有 166 字。这两页的功能都是&lt;strong&gt;脚本渲染&lt;/strong&gt;的（画廊靠 JS 拉图、文件列表靠 JS 渲染），爬虫来的时候看到一个空壳。&lt;/p&gt;
&lt;p&gt;处理方式不是 &lt;code&gt;noindex&lt;/code&gt;（这些页是有用的入口），而是&lt;strong&gt;补服务端渲染的正文&lt;/strong&gt;：讲清楚这些图/文件从哪来、为什么不放第三方图床、下载前要注意什么。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-seo-self-audit/03-files-faq.webp&quot; alt=&quot;补了正文说明的文件页：爬虫现在能看到&amp;quot;这些文件是什么&amp;quot;和&amp;quot;下载前请注意&amp;quot;&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;复查：从 28 降到 12&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-seo-self-audit/05-audit-after.webp&quot; alt=&quot;复扫结果：h1 全部为 1、og:image 全覆盖、描述 48~131 字&quot; /&gt;&lt;/p&gt;
&lt;p&gt;剩下 12 条基本是&quot;可以接受但能更好&quot;的：工具页还没加 &lt;code&gt;WebApplication&lt;/code&gt; 结构化数据、两篇演示文章的描述偏短、首页用的是站点描述（它本来就是站点首页，合理）。这类留在待办里，不再算问题。&lt;/p&gt;
&lt;h2&gt;顺手一提：站点地图曾经整整四天没被收录&lt;/h2&gt;
&lt;p&gt;体检顺带治好了另一个老毛病：Search Console 一直报「无法抓取」，而同一台服务器上的 &lt;code&gt;rss.xml&lt;/code&gt; 却是「成功」。&lt;/p&gt;
&lt;p&gt;真正的报错在详情页里（列表页只写&quot;无法抓取&quot;，点进去才看到「&lt;strong&gt;无法读取此站点地图&lt;/strong&gt;」）。拿官方 &lt;code&gt;sitemap.xsd&lt;/code&gt; 一校验，立刻现形：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Element &apos;changefreq&apos;: This element is not expected.
第一条 &amp;lt;url&amp;gt; 的子元素顺序: loc → priority → changefreq
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;官方 XSD 里 &lt;code&gt;&amp;lt;url&amp;gt;&lt;/code&gt; 是 &lt;code&gt;&amp;lt;xsd:sequence&amp;gt;&lt;/code&gt;，顺序必须固定为 &lt;code&gt;loc → lastmod → changefreq → priority&lt;/code&gt;。我当初是按&quot;看着顺眼&quot;排的，XML 依然 well-formed，Bing 也照样认，&lt;strong&gt;只有严格按 XSD 校验的 Google 会拒收&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;修完之后我在生成函数里加了一道自检：逐条 &lt;code&gt;&amp;lt;url&amp;gt;&lt;/code&gt; 校验子元素顺序，不符就在构建日志里告警 —— 这种&quot;静默失败&quot;必须让构建期替人盯着。&lt;/p&gt;
&lt;h2&gt;一份可以照抄的体检清单&lt;/h2&gt;
&lt;p&gt;如果你也是静态站，把下面这张表对着自己的模板过一遍，八成能拣到几条：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;每个页面恰好一个 h1&lt;/strong&gt;，且 h1 不是 logo 也不是站点名（列表页例外，可以用 sr-only）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;og:image&lt;/code&gt; 一定要有兜底&lt;/strong&gt;，别指望每篇文章都配封面；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;描述逐页写&lt;/strong&gt;，别全站共用 —— 这十几段文字是唯一能控制的摘要；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;警惕嵌套布局&lt;/strong&gt;：一个页面里出现两个 &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;，就是套了两层 Layout；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;功能页要有服务端可见的正文&lt;/strong&gt;，纯 JS 渲染的页面在爬虫眼里是空的；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;XML 产物要按 schema 校验&lt;/strong&gt;，well-formed ≠ 符合 XSD；&lt;/li&gt;
&lt;li&gt;改完&lt;strong&gt;重新扫一遍&lt;/strong&gt;，把&quot;改好了&quot;变成表格里的一个数字，而不是一句自我感觉。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;这四个毛病的共同点：&lt;strong&gt;它们都不影响&quot;人能看见的页面&quot;&lt;/strong&gt;。h1 变成 div、描述复用、head 重复、正文靠 JS —— 浏览器里一切正常，所以永远等不到&quot;有人报错&quot;。&lt;/p&gt;
&lt;p&gt;SEO 的日常因此更像&lt;strong&gt;定期体检&lt;/strong&gt;而不是&quot;修 bug&quot;：写一支便宜的脚本、定一张规则表、每次改完模板跑一遍。这套流程两秒钟一次，比在站长后台等四天要划算得多。&lt;/p&gt;
&lt;p&gt;体检脚本我已经放进了本地工具目录（&lt;code&gt;seo-audit.py&lt;/code&gt;），下一轮维护时直接复跑；等文章攒多一些，再补 &lt;code&gt;WebApplication&lt;/code&gt;、&lt;code&gt;Article&lt;/code&gt; 的多语言字段和 &lt;code&gt;sameAs&lt;/code&gt; 之类的细节。&lt;/p&gt;
&lt;p&gt;体检里的性能部分单独写过一篇：&lt;a href=&quot;/posts/blog-home-performance/&quot;&gt;首屏从 392KB 砍到 59KB&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;把标签变成聚合页面的那次改造写在&lt;a href=&quot;/posts/blog-tags-system/&quot;&gt;静态站的标签体系&lt;/a&gt;里。&lt;/p&gt;
&lt;p&gt;这套体检的八项判据、九类真实问题与三条教训，后来单独整理成了&lt;a href=&quot;/posts/blog-seo-checklist/&quot;&gt;一份可复跑的清单&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;站内搜索是怎么做索引的，也单独写过：&lt;a href=&quot;/posts/blog-site-search/&quot;&gt;27 篇文章的站内搜索&lt;/a&gt;。&lt;/p&gt;
</content:encoded></item><item><title>给静态博客的单篇文章上密码：构建期加密与浏览器解密</title><link>https://blog.peroe.cn/posts/blog-post-encryption/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-post-encryption/</guid><description>静态站没有服务端，密码保护只能靠真加密：构建期用 PBKDF2-HMAC-SHA256 派生密钥、AES-256-GCM 加密正文，产物里只剩盐、IV 和密文。这篇讲清参数取舍、公开仓库的明文隔离、元数据泄露清单与解密后要重跑的初始化。</description><pubDate>Mon, 05 Oct 2026 15:10:00 GMT</pubDate><content:encoded>&lt;p&gt;静态博客的每位读者拿到的都是同一份 HTML，「给某篇文章上密码」的第一反应往往是前端藏一下。真要让不知道密码的人读不到正文，只剩一条路：&lt;strong&gt;构建期就把正文加密成密文，密码只存在于作者的本地和读者的浏览器里&lt;/strong&gt;。本站刚把这条路走通，过程中发现真正麻烦的不是加密，而是正文之外那些顺手搬运内容的地方。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
前提：本站是 Astro 静态输出 + Svelte 组件岛，部署在 Cloudflare Workers 的静态资源模式下（&lt;code&gt;assets.directory = ./dist&lt;/code&gt;，没有 Worker 脚本入口）。没有服务端逻辑、没有数据库、没有鉴权层——下面所有取舍都从这一点长出来。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-post-encryption/01-locked.webp&quot; alt=&quot;锁定态：页面里只有一个密码框和一行密码提示，正文、字数、阅读时长和侧栏目录都不在页面上&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;没有服务端，「上密码」到底能不能做&lt;/h2&gt;
&lt;p&gt;能，但只能做一半，得先把期望摆正。&lt;/p&gt;
&lt;p&gt;静态站构建完就是一堆文件，Workers 的静态资源模式只负责按 URL 发出去。读者 A 和读者 B 请求 &lt;code&gt;/posts/foo/&lt;/code&gt;，拿到的是同一个字节流。既然服务端不做任何判断，&lt;strong&gt;任何「服务器校验密码」的方案在这里都不成立&lt;/strong&gt;——没有地方运行那段校验代码。&lt;/p&gt;
&lt;p&gt;剩下的只有两类：模板语法层面的掩护，和真加密。前者我全部排除了。&lt;/p&gt;
&lt;h2&gt;先排除三种障眼法&lt;/h2&gt;
&lt;p&gt;第一种是 CSS 隐藏：&lt;code&gt;display: none&lt;/code&gt;、&lt;code&gt;visibility: hidden&lt;/code&gt;、&lt;code&gt;filter: blur(8px)&lt;/code&gt;。它们的共性是——&lt;strong&gt;正文完整地躺在 HTML 里&lt;/strong&gt;：查看源代码、&lt;code&gt;curl&lt;/code&gt; 一次、禁用 JavaScript，随便哪种都能读到。模糊是给眼睛看的，不是给解析器看的。&lt;/p&gt;
&lt;p&gt;第二种是 JS 比对密码：密码写在脚本里，输入对了再 &lt;code&gt;document.body.innerHTML = 明文&lt;/code&gt;。密码本身就在产物里，搜一下 &lt;code&gt;password&lt;/code&gt; 就完了。&lt;/p&gt;
&lt;p&gt;第三种本站现成就有：&lt;code&gt;.spoiler&lt;/code&gt;，由 &lt;code&gt;||文字||&lt;/code&gt; 语法（&lt;code&gt;src/plugins/remark-spoiler.js&lt;/code&gt;）生成的 span：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;newChildren.push({
	type: &quot;html&quot;,
	value: &apos;&amp;lt;span class=&quot;spoiler&quot; title=&quot;点击显示&quot;&amp;gt;&apos;,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;样式是给文字加一层毛玻璃，点一下才去掉：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.spoiler {
	filter: blur(0.4rem);
	transition: filter 0.3s;
	cursor: pointer;
	user-select: none;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它在防剧透这个场景里是对的：读者&lt;strong&gt;本来就想看&lt;/strong&gt;，只是不想提前被剧透。当密码用就完全错了——文字从生成 HTML 那一刻起就是明文，&lt;code&gt;Ctrl+U&lt;/code&gt; 就能读。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;做法&lt;/th&gt;
&lt;th&gt;正文在产物里的形态&lt;/th&gt;
&lt;th&gt;谁能读到&lt;/th&gt;
&lt;th&gt;适合挡什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CSS 隐藏 / blur&lt;/td&gt;
&lt;td&gt;明文&lt;/td&gt;
&lt;td&gt;所有人（看源代码即可）&lt;/td&gt;
&lt;td&gt;剧透、不想一眼看到的段落&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS 比对密码&lt;/td&gt;
&lt;td&gt;明文 + 密码字符串&lt;/td&gt;
&lt;td&gt;所有人（搜 &lt;code&gt;password&lt;/code&gt; 即可）&lt;/td&gt;
&lt;td&gt;什么都不适合&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.spoiler&lt;/code&gt;（&lt;code&gt;||文字||&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;明文 + 一行 blur 样式&lt;/td&gt;
&lt;td&gt;所有人&lt;/td&gt;
&lt;td&gt;剧透、折叠长段落&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;构建期真加密&lt;/td&gt;
&lt;td&gt;密文（盐 + IV + 密文）&lt;/td&gt;
&lt;td&gt;只有知道密码的人&lt;/td&gt;
&lt;td&gt;真正不想公开的内容&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;真加密的形态：三段数据流&lt;/h2&gt;
&lt;p&gt;思路的转变只有一句话：把「校验密码」换成「用密码解密」。密码不参与任何比较，它只是 PBKDF2 的输入；输错密码的唯一后果是 AES-GCM 认证失败、解出一堆乱码。所以产物里一个密码字符都没有。&lt;/p&gt;
&lt;p&gt;本站的完整数据流是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;【构建期 · 本地，只跑一次】pnpm lock-post &amp;lt;slug&amp;gt;
  private/posts/&amp;lt;slug&amp;gt;.md            明文正文 + frontmatter 里的 password
    │ ① 临时写回 src/content/posts/&amp;lt;slug&amp;gt;.md（先去掉 password），跑一次全量构建
    │ ② 从 dist/posts/&amp;lt;slug&amp;gt;/index.html 抠出 .custom-md 的 innerHTML 与标题大纲
    │ ③ PBKDF2-SHA256(600000 次) 派生密钥 → AES-256-GCM 加密 { html, headings }
    │ ④ 写 src/data/encrypted/&amp;lt;slug&amp;gt;.json，把 md 换成 stub，删掉本轮含明文的 dist 页面
    ▼
  提交物只有两个：
    src/content/posts/&amp;lt;slug&amp;gt;.md        stub：frontmatter + encrypted: true，正文为空
    src/data/encrypted/&amp;lt;slug&amp;gt;.json     v / alg / kdf / iter / salt / iv / ct

【部署 · 交给 Cloudflare 构建】astro build --force
  dist/posts/&amp;lt;slug&amp;gt;/index.html         密码框 + 内联 JSON 载荷，没有正文

【浏览器 · 每个读者各跑一次】
  输入密码 → WebCrypto 按同一组参数派生密钥 → AES-GCM 解密
    → JSON.parse 出 { html, headings } → innerHTML 注入 → 重跑各项初始化
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;载荷的字段定义在 &lt;code&gt;src/utils/encrypted-format.ts&lt;/code&gt; 里，这个文件刻意保持「浏览器安全」（不 import &lt;code&gt;node:*&lt;/code&gt;），因为它同时被构建脚本和 Svelte 组件引用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export interface EncryptedPostPayload {
	v: number;
	alg: string;
	kdf: string;
	iter: number;
	salt: string;
	iv: string;
	ct: string;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;线上那篇演示文章的载荷长这样（&lt;code&gt;ct&lt;/code&gt; 是 15 KB 级别的密文，这里截断）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
	&quot;v&quot;: 1,
	&quot;alg&quot;: &quot;AES-GCM&quot;,
	&quot;kdf&quot;: &quot;PBKDF2-SHA256&quot;,
	&quot;iter&quot;: 600000,
	&quot;salt&quot;: &quot;rjp/Yhf9CDBymyJmIBI8xw==&quot;,
	&quot;iv&quot;: &quot;w0oOgg1cN/5cHJDG&quot;,
	&quot;ct&quot;: &quot;o66vi+/pGH5NmLSJjzw7Cyhnc+XsAWuEXevAwcrVg9v1/0DwvtWvo+NbYT…&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;盐、IV、迭代数都是公开的，这不是设计缺陷——密码学上它们本来就该公开，安全性由密码承担。真正要小心的是&lt;strong&gt;别让载荷提前闭合 HTML 标签&lt;/strong&gt;：内联前过了一道转义，把 &lt;code&gt;&amp;lt;&lt;/code&gt; 换成 &lt;code&gt;\u003c&lt;/code&gt;（&lt;code&gt;src/utils/encrypted-format.ts&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function serializePayloadForHtml(payload: EncryptedPostPayload): string {
	return JSON.stringify(payload).split(&quot;&amp;lt;&quot;).join(&quot;\\u003c&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;另一个刻意的设计是&lt;strong&gt;载荷缺失时构建直接失败，不降级&lt;/strong&gt;：&lt;code&gt;src/utils/encrypted-payload.ts&lt;/code&gt; 找不到密文 JSON 就抛错，而不是安静地渲染成一篇空文章：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;throw new Error(
	`[encrypted-post] 找不到加密载荷 ${ENCRYPTED_DIR}/${slug}.json；` +
		`请先在本地运行 \`pnpm lock-post ${slug}\` 生成密文（明文留在 private/posts/，不提交）。`,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对加密功能来说，「静默降级」比「构建失败」危险得多，所以这条错误信息故意写得很啰嗦。&lt;/p&gt;
&lt;h2&gt;参数与取舍&lt;/h2&gt;
&lt;p&gt;加密参数全部集中在 &lt;code&gt;src/utils/encrypted-format.ts&lt;/code&gt; 顶部，注释里写明了来源（ShokaX 与 hexo-blog-encrypt v4 的交集）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const PBKDF2_ITERATIONS = 600000;
export const SALT_BYTES = 16;
export const IV_BYTES = 12;
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;参数&lt;/th&gt;
&lt;th&gt;取值&lt;/th&gt;
&lt;th&gt;为什么是这个值&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;KDF&lt;/td&gt;
&lt;td&gt;PBKDF2-HMAC-SHA256&lt;/td&gt;
&lt;td&gt;WebCrypto 原生支持，浏览器端不用引任何第三方密码库&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;迭代次数&lt;/td&gt;
&lt;td&gt;600000&lt;/td&gt;
&lt;td&gt;单次猜测的成本：每猜一个密码要算 60 万次 SHA-256&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;盐&lt;/td&gt;
&lt;td&gt;16 字节随机&lt;/td&gt;
&lt;td&gt;同一密码在不同文章里派生出不同密钥，彩虹表失效&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IV&lt;/td&gt;
&lt;td&gt;12 字节随机&lt;/td&gt;
&lt;td&gt;GCM 的标准长度；同一密钥下 IV 重用是致命的，所以每篇重新生成&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;加密&lt;/td&gt;
&lt;td&gt;AES-256-GCM，tagLength 128&lt;/td&gt;
&lt;td&gt;自带认证标签：密码错和密文被改，都解不出来&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;版本与算法名&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v&lt;/code&gt; / &lt;code&gt;alg&lt;/code&gt; / &lt;code&gt;kdf&lt;/code&gt; / &lt;code&gt;iter&lt;/code&gt; 全部写进载荷&lt;/td&gt;
&lt;td&gt;将来换参数时，已经发出去的密文仍能按自己的参数解开&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;为什么不用单次 MD5。&lt;/strong&gt; 不少前端加密示例（比如 crypto-js 的默认用法 &lt;code&gt;CryptoJS.AES.encrypt(text, password)&lt;/code&gt;）走 OpenSSL 的 &lt;code&gt;EVP_BytesToKey&lt;/code&gt;：只做一次 MD5 派生密钥。单次 MD5 与 60 万次 PBKDF2-SHA256 之间隔着 60 万倍的计算量，GPU 再快，这个倍数也要照付——改加密参数不是「更安全一点」，是换了一个数量级。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么不用 bcrypt 先验密码。&lt;/strong&gt; bcrypt 回答的是「这个密码能不能通过校验」，而这里需要的是「用这个密码派生出一把能解密的密钥」——是 KDF，不是口令校验器。况且浏览器里没有原生 bcrypt，用一次就要引一个 wasm/JS 库，PBKDF2 则是 WebCrypto 自带的。&lt;code&gt;iter&lt;/code&gt; 写进载荷也是同理：以后把 600000 调大，老密文不会被写坏。&lt;/p&gt;
&lt;p&gt;浏览器端就是这两步：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const baseKey = await crypto.subtle.importKey(
	&quot;raw&quot;,
	new TextEncoder().encode(passphrase),
	&quot;PBKDF2&quot;,
	false,
	[&quot;deriveKey&quot;],
);
return crypto.subtle.deriveKey(
	{ name: &quot;PBKDF2&quot;, salt, iterations, hash: &quot;SHA-256&quot; },
	baseKey,
	{ name: &quot;AES-GCM&quot;, length: 256 },
	false,
	[&quot;decrypt&quot;],
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;安全边界，先把话说清楚&lt;/h2&gt;
&lt;p&gt;第一，&lt;strong&gt;密文、盐、IV、迭代数全在 HTML 里&lt;/strong&gt;。攻击者不需要访问服务器，把页面存下来就能离线爆破，还能并行、不受限流影响。所以密码强度就是全部的安全性——600000 次迭代只能把弱密码从「一秒破」推迟到「几天破」，救不回来。&lt;/p&gt;
&lt;p&gt;第二，&lt;strong&gt;出错提示必须统一&lt;/strong&gt;。AES-GCM 下「密码错」和「密文被篡改」在客户端不可区分，硬要区分只会给攻击者送信息，所以本站只提示一种：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;} catch {
	// 密码错误与密文被篡改在 AES-GCM 下无法区分，统一提示
	error = &quot;密码错误&quot;;
	password = &quot;&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-post-encryption/02-wrong-password.webp&quot; alt=&quot;输入错误密码后，页面上只多出一行「密码错误」，没有更具体的错误信息&quot; /&gt;&lt;/p&gt;
&lt;p&gt;第三，&lt;strong&gt;明文只在内存里&lt;/strong&gt;。组件不写 localStorage 也不写 sessionStorage，代价是每次刷新都要重新解密（手机上大概一两秒）。把明文写进 localStorage，等于给密码保护留了一把备用钥匙。&lt;/p&gt;
&lt;p&gt;第四，&lt;strong&gt;不算秘密的东西仍然公开&lt;/strong&gt;：标题、URL、发布时间、文章存在性，以及 frontmatter 里的 &lt;code&gt;passwordHint&lt;/code&gt;（它渲染成密码框下面那行小字，所以别把密码写进提示里）。&lt;/p&gt;
&lt;h2&gt;解密之后会发生什么（最容易翻车的地方）&lt;/h2&gt;
&lt;p&gt;解锁成功只是把 HTML 塞进 DOM，页面并不会重新加载一遍。而本站那些看着很时髦的渲染——代码高亮、图片灯箱、Mermaid 流程图、表格横向滚动容器、侧栏目录——&lt;strong&gt;都只在页面首次加载时跑一次&lt;/strong&gt;，初始化挂在 &lt;code&gt;DOMContentLoaded&lt;/code&gt; 上，解密却发生在页面早就 load 完之后。更隐蔽的是 &lt;code&gt;innerHTML&lt;/code&gt; 注入的 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 根本不会执行，链接卡片 &lt;code&gt;::url{}&lt;/code&gt; 的元数据请求恰好写在脚本里。&lt;/p&gt;
&lt;p&gt;所以 &lt;code&gt;PasswordGate.svelte&lt;/code&gt; 里专门有一个「注入后重跑」的函数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// innerHTML 注入的 &amp;lt;script&amp;gt; 不会执行（::url{} 链接卡片的元数据请求就在里面），手动补跑
bodyEl?.querySelectorAll(&quot;script&quot;).forEach((old) =&amp;gt; {
	const script = document.createElement(&quot;script&quot;);
	for (const attr of Array.from(old.attributes)) {
		script.setAttribute(attr.name, attr.value);
	}
	script.text = old.text;
	old.replaceWith(script);
});

// 表格包裹 + Mermaid 渲染（逻辑在 components/misc/Markdown.astro）
window.dispatchEvent(new CustomEvent(&quot;fuwari:markdown-injected&quot;));
// 代码高亮（Prism + 复制按钮）
initCodeHighlight();
// 图片灯箱（逻辑在 scripts/layout-fancybox-runtime.ts，避免把 Fancybox 打进文章页的 island 包）
window.dispatchEvent(new CustomEvent(&quot;fuwari:images-injected&quot;));
// 侧栏目录（服务端渲染时 headings 被刻意传成 []）
window.dispatchEvent(
	new CustomEvent(&quot;fuwari:toc-rebuild&quot;, { detail: { headings } }),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个事件各有接收方，都是「本来就在监听，只是以前没人派发」：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;window.addEventListener(&quot;fuwari:markdown-injected&quot;, () =&amp;gt; {
	initMarkdown();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;window.addEventListener(&quot;fuwari:images-injected&quot;, () =&amp;gt; {
	initFancybox();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;侧栏目录特殊一点：服务端渲染时它是空的，因为标题大纲会泄露正文结构——&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const encrypted = isEncrypted(entry);
const payload = encrypted ? getEncryptedPayload(entry.id) : null;
const tocHeadings = encrypted ? [] : headings;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以密文里连 &lt;code&gt;headings&lt;/code&gt; 一起加密了，解锁后由 &lt;code&gt;fuwari:toc-rebuild&lt;/code&gt; 事件重建列表，还得把那个自定义元素重新初始化一遍（&lt;code&gt;disconnectedCallback?.()&lt;/code&gt; 之后再 &lt;code&gt;init?.()&lt;/code&gt;），否则目录只是「看起来在那里」但点不动。&lt;/p&gt;
&lt;p&gt;这是整篇里最值得强调的一条经验：&lt;strong&gt;代码高亮和目录这类「增强」，写的时候都假设自己只跑一次&lt;/strong&gt;。我第一次解锁时正文出来了，代码块却是白板、表格顶到卡片边缘、目录空着——功能全对，样式全烂。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-post-encryption/03-unlocked.webp&quot; alt=&quot;解锁后：KaTeX 公式、表格、提示框、高亮代码块与链接卡片，全都是注入之后才渲染出来的&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;公开仓库的坑：明文绝不能进 git&lt;/h2&gt;
&lt;p&gt;这是最容易做错、也最难补救的一环：本站文章页有一个「在 GitHub 上编辑此页」的直链，指向 &lt;code&gt;src/content/posts/&amp;lt;slug&amp;gt;.md&lt;/code&gt;——&lt;strong&gt;那个文件必须能公开见人&lt;/strong&gt;，而仓库本身也是 public 的。&lt;/p&gt;
&lt;p&gt;所以本站把明文和提交物彻底分开：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件&lt;/th&gt;
&lt;th&gt;位置&lt;/th&gt;
&lt;th&gt;是否提交&lt;/th&gt;
&lt;th&gt;内容&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;明文 + 密码&lt;/td&gt;
&lt;td&gt;&lt;code&gt;private/posts/&amp;lt;slug&amp;gt;.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌（&lt;code&gt;.gitignore&lt;/code&gt; 第 70 行忽略 &lt;code&gt;private/&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;完整正文，frontmatter 里带 &lt;code&gt;password&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stub&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/content/posts/&amp;lt;slug&amp;gt;.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;frontmatter + &lt;code&gt;encrypted: true&lt;/code&gt;，正文为空&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;密文载荷&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/data/encrypted/&amp;lt;slug&amp;gt;.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v / alg / kdf / iter / salt / iv / ct&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;本地的明文长这样（&lt;code&gt;private/&lt;/code&gt; 已在 &lt;code&gt;.gitignore&lt;/code&gt; 里）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# private/posts/&amp;lt;slug&amp;gt;.md —— 只在本地
---
title: 一条需要密码的私人笔记
published: 2026-10-05 23:50:00 +08:00
password: &amp;lt;你的密码&amp;gt;        # lock-post 读它；stub 里会被删掉
passwordHint: &quot;……&quot;        # 可选，会渲染成密码框下面那行提示
---
正文照常写 Markdown
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一条命令完成整个转换：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm lock-post demo-private-note
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它按顺序做四件事，每一件都对应一个具体的坑：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;读明文&lt;/strong&gt;。从 &lt;code&gt;private/posts/&amp;lt;slug&amp;gt;.md&lt;/code&gt; 读正文和 frontmatter 里的 &lt;code&gt;password&lt;/code&gt;，缺失或正文为空就直接失败。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;临时渲染 + 构建&lt;/strong&gt;。把还原出来的 frontmatter（&lt;strong&gt;去掉 &lt;code&gt;password&lt;/code&gt;&lt;/strong&gt;）和正文写回 &lt;code&gt;src/content/posts/&amp;lt;slug&amp;gt;.md&lt;/code&gt;，跑一次 &lt;code&gt;pnpm update-diff&lt;/code&gt; + &lt;code&gt;astro build --force&lt;/code&gt;，再从 &lt;code&gt;dist/posts/&amp;lt;slug&amp;gt;/index.html&lt;/code&gt; 的 &lt;code&gt;.custom-md&lt;/code&gt; 容器里抠出正文 HTML 和标题大纲。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;加密 + 写载荷&lt;/strong&gt;。&lt;code&gt;crypto.getRandomValues&lt;/code&gt; 生成 16 字节盐和 12 字节 IV，PBKDF2 派生密钥，AES-GCM 加密 &lt;code&gt;{ html, headings }&lt;/code&gt;，写成 &lt;code&gt;src/data/encrypted/&amp;lt;slug&amp;gt;.json&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;换成 stub&lt;/strong&gt;。把 &lt;code&gt;src/content/posts/&amp;lt;slug&amp;gt;.md&lt;/code&gt; 覆盖成「frontmatter + 空正文」，顺手删掉本轮那个含明文的 &lt;code&gt;dist&lt;/code&gt; 页面——因为「提取完忘了重建就部署」是最容易犯的错。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;写完之后还有一道自检，把「密码泄漏进提交物」直接判死：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const leaks = [];
if (/^\s*password\s*:/m.test(stubText)) leaks.push(&quot;stub 里还有 password 字段&quot;);
if (stubText.includes(password)) leaks.push(&quot;stub 里出现了密码明文&quot;);
if (payloadText.includes(password)) leaks.push(&quot;密文 JSON 里出现了密码明文&quot;);
if (leaks.length) {
	fail(`自检不通过：${leaks.join(&quot;；&quot;)}`);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;失败时怎么回滚。&lt;/strong&gt; 这条命令会临时往被 git 跟踪的目录里写明文，所以脚本的 &lt;code&gt;finally&lt;/code&gt; 块是无条件的：只要这一轮没走完，就一定把 &lt;code&gt;src/content/posts/&amp;lt;slug&amp;gt;.md&lt;/code&gt; 恢复成 stub（或运行前的样子），绝不会把明文留在那里等你手动收拾：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;} finally {
	// 无论成功失败，都不要把明文留在被 git 跟踪的 src/content/posts 里
	if (!completed &amp;amp;&amp;amp; wroteTemp) {
		if (originalStub.includes(password) || /^\s*password\s*:/m.test(originalStub)) {
			fs.writeFileSync(tempFile, `---\n${stubFrontmatter}\n---\n`, &quot;utf8&quot;);
		} else {
			fs.writeFileSync(tempFile, originalStub, &quot;utf8&quot;);
		}
		console.error(
			`⚠️ 已把 ${path.relative(ROOT, tempFile)} 恢复为 stub（本轮未完成加密）`,
		);
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;跑完之后，仓库边界可以用命令验证，不用靠自觉：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-post-encryption/04-git-boundary.webp&quot; alt=&quot;真实执行结果：private/ 被 .gitignore 忽略、只有 stub 与密文被跟踪、密码从未出现在任何一次提交里&quot; /&gt;&lt;/p&gt;
&lt;p&gt;最后一条不在脚本里、在人的习惯里：&lt;strong&gt;提交信息也算公开信息&lt;/strong&gt;。本站加密文章的提交信息只写标题（本来就公开），但如果你顺手把正文摘要写进 commit message，它就永久留在 git 历史里了。&lt;/p&gt;
&lt;h2&gt;元数据泄露清单：只加密正文远远不够&lt;/h2&gt;
&lt;p&gt;正文是最显眼的泄露点，却不是唯一的：静态站会在构建期把内容「搬运」到很多地方，我挨个查了一遍：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;泄露点&lt;/th&gt;
&lt;th&gt;会泄露什么&lt;/th&gt;
&lt;th&gt;本站怎么处理&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;列表页摘要&lt;/td&gt;
&lt;td&gt;&lt;code&gt;excerpt&lt;/code&gt; 就是正文第一段（&lt;code&gt;remark-excerpt&lt;/code&gt; 生成）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PostCard.astro&lt;/code&gt; 里改成固定文案 &lt;code&gt;🔒 需要密码&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;字数 / 阅读时长&lt;/td&gt;
&lt;td&gt;正文规模（几万字还是几百字，一眼看出）&lt;/td&gt;
&lt;td&gt;包在 &lt;code&gt;{!encrypted &amp;amp;&amp;amp; (…)}&lt;/code&gt; 里，不渲染&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;侧栏目录 TOC&lt;/td&gt;
&lt;td&gt;标题大纲＝文章结构&lt;/td&gt;
&lt;td&gt;服务端传 &lt;code&gt;[]&lt;/code&gt;，解锁后用密文里的 &lt;code&gt;headings&lt;/code&gt; 重建&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSS 全文&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;description&amp;gt;&lt;/code&gt; 里塞的是渲染后的正文 HTML&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rss.xml.ts&lt;/code&gt; 里用 &lt;code&gt;publicPosts()&lt;/code&gt; 过滤&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/search.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;正文被压成纯文本的全文索引&lt;/td&gt;
&lt;td&gt;&lt;code&gt;search.json.ts&lt;/code&gt; 同样用 &lt;code&gt;publicPosts()&lt;/code&gt; 过滤&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sitemap.xml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;给搜索引擎的入口&lt;/td&gt;
&lt;td&gt;集合过滤条件里加 &lt;code&gt;data.encrypted !== true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;侧栏「总字数」&lt;/td&gt;
&lt;td&gt;把加密文章算进去就等于公开它的长度&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Profile.astro&lt;/code&gt; 累加时用 &lt;code&gt;publicPosts()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;上下篇标题&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prevTitle&lt;/code&gt; / &lt;code&gt;nextTitle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;故意保留&lt;/strong&gt;，见下&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;修订历史 / 编辑入口&lt;/td&gt;
&lt;td&gt;commit message、diff、可点的源码直链&lt;/td&gt;
&lt;td&gt;加密文章整块隐藏（&lt;code&gt;gitHubEditConfig.enable &amp;amp;&amp;amp; !encrypted&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;过滤入口只有一个文件，&lt;code&gt;src/utils/post-visibility.ts&lt;/code&gt;，其余地方一律调它：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/** 过滤掉加密文章（正文不可公开，不能进 RSS / 搜索索引 / 站点地图 / 字数统计） */
export function publicPosts(
	posts: CollectionEntry&amp;lt;&quot;posts&quot;&amp;gt;[],
): CollectionEntry&amp;lt;&quot;posts&quot;&amp;gt;[] {
	return posts.filter((post) =&amp;gt; !isEncrypted(post));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;列表页那一处是唯一需要「换个说法」而不是删掉的，因为卡片上总得显示点什么：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ encrypted ? &quot;🔒 需要密码&quot; : (description || remarkPluginFrontmatter.excerpt) }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;字数与阅读时长则直接隐藏：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{!encrypted &amp;amp;&amp;amp; (
&amp;lt;div class=&quot;text-sm text-white/30 flex gap-4 transition&quot;&amp;gt;
    &amp;lt;div&amp;gt;{remarkPluginFrontmatter.words} 字&amp;lt;/div&amp;gt;
    &amp;lt;div&amp;gt;|&amp;lt;/div&amp;gt;
    &amp;lt;div&amp;gt;{remarkPluginFrontmatter.minutes} 分钟&amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;站点地图是另一套过滤（它直接 &lt;code&gt;getCollection&lt;/code&gt;，没走 &lt;code&gt;getSortedPosts&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 加密文章不进站点地图（正文不对搜索引擎开放）
const posts = await getCollection(&quot;posts&quot;, ({ data }) =&amp;gt; {
	return !data.draft &amp;amp;&amp;amp; data.encrypted !== true;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;故意保留的部分也要说清楚。&lt;/strong&gt; 标题、URL、发布时间、文章存在性、上下篇标题，本站一概不隐藏。理由是读者需要在列表页看到「这里有一篇要密码的文章」，也需要能把链接发给别人；连存在性都藏起来的话，这套东西就变成「隐藏文章」了，那是另一个功能。取舍就一句话：&lt;strong&gt;加密保护的是正文，不是这篇文章的存在&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;顺带一提，&lt;code&gt;/posts.json&lt;/code&gt; 也没问题：它只有 &lt;code&gt;/posts/&amp;lt;slug&amp;gt;&lt;/code&gt; 路径数组，没有正文或摘要。&lt;/p&gt;
&lt;h2&gt;去试一下演示文章&lt;/h2&gt;
&lt;p&gt;本站已经发布了一篇演示：&lt;a href=&quot;/posts/demo-private-note/&quot;&gt;《一条需要密码的私人笔记》&lt;/a&gt;，&lt;strong&gt;密码是 &lt;code&gt;peroe-demo-2026&lt;/code&gt;&lt;/strong&gt;，上面三张截图就是它的三个状态。建议按这个顺序试：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;打开 &lt;code&gt;/posts/demo-private-note/&lt;/code&gt;，看到的是密码框（图 1）。顺手 &lt;code&gt;Ctrl+U&lt;/code&gt; 打开源代码，搜正文里的任何一句话——搜不到，页面里只有那段内联的 JSON 载荷。&lt;/li&gt;
&lt;li&gt;随便输一个密码，等半秒左右，页面只多一行「密码错误」（图 2）。那半秒就是 60 万次 PBKDF2 在算。&lt;/li&gt;
&lt;li&gt;输入 &lt;code&gt;peroe-demo-2026&lt;/code&gt;，正文出现，公式、表格、提示框、代码块、链接卡片一起就位（图 3）。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;什么内容适合加密，什么不适合&lt;/h2&gt;
&lt;p&gt;适合的是「不希望陌生人顺手读到，但也不是机密」的内容：只给一两个人看的私人笔记、账单与额度边界、还没定要不要公开的草稿、给朋友对答案的题解。&lt;/p&gt;
&lt;p&gt;不适合的同样明确：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;真正的秘密&lt;/strong&gt;（API Key、口令、身份证号）不要放：别人可以离线爆破，读者也可能转发。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要按人授权的&lt;/strong&gt;内容不要放。这套方案只有一个密码，谁拿到都能看，没有账号体系。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要撤回的&lt;/strong&gt;内容要谨慎。密文一旦推出去就进了别人的缓存和 CDN；改密码重新加密只对新访客生效，&lt;strong&gt;已经存下旧 HTML 的人仍然能用旧密码解开&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;纯防剧透&lt;/strong&gt;内容没必要：&lt;code&gt;.spoiler&lt;/code&gt; 那类轻量掩护就够了，真加密每次刷新都要解密，还要处理上面那一堆初始化。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这套方案的局限就三句：不含服务端鉴权（密码即全部）；不能防「读者自己转发解锁后的内容」；正文之外仍有一圈公开的元数据（标题、URL、存在性、发布时间）。接受这三条，它就能用。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;静态博客做密码保护，本质上是把「服务器校验」换成「密码学」：构建期加密、浏览器解密、产物里只有盐和密文。加密本身是半个下午的事，真正吃时间的是两件外围工作——&lt;strong&gt;把明文和密码挡在 git 之外&lt;/strong&gt;，以及&lt;strong&gt;把构建期顺手搬运正文的角落一个个堵上&lt;/strong&gt;；前者靠 &lt;code&gt;private/&lt;/code&gt; + stub + 自检 + &lt;code&gt;finally&lt;/code&gt; 兜底，后者靠一个统一的 &lt;code&gt;publicPosts()&lt;/code&gt; 过滤器。&lt;/p&gt;
&lt;p&gt;如果你的博客也是纯静态、也有几篇不想公开的内容，这套方案可以直接照搬；但如果需要的是「按人授权、可撤回」，那就不是加密能解决的，得去要一个真正的服务端。&lt;/p&gt;
</content:encoded></item><item><title>静态博客的站内搜索是怎么做的：一份 JSON 索引加自己写的打分</title><link>https://blog.peroe.cn/posts/blog-static-search/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-static-search/</guid><description>静态站没有后端，站内搜索只能在前端想办法。这篇拆开本站实现：构建期把 Markdown 压成一份 51 KB 的 search.json，浏览器整份拉下来逐字段数命中次数，再按日期排序。附实测的索引体积构成、逐字输入的重取代价与踩过的坑。</description><pubDate>Mon, 05 Oct 2026 14:50:00 GMT</pubDate><content:encoded>&lt;p&gt;静态站没有后端，站内搜索这种「看起来必须有服务端」的功能就得换个思路。本站的办法很土：构建期把每篇文章压成纯文本塞进一份 &lt;code&gt;search.json&lt;/code&gt;，打开搜索框时浏览器把整份索引拉下来，在本地逐字段数命中次数，再按日期排序。&lt;/p&gt;
&lt;p&gt;这份索引现在 51,653 字节、6 条记录，最大的一条 14 KB。文章少的时候它很划算——零后端、零外部服务、零依赖；但代价也是真的：&lt;strong&gt;每敲一个字，整份索引重新下载一遍&lt;/strong&gt;。这篇把这条链路拆开，讲清楚它为什么这么写、代价具体是多少。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
前提：本站是 Astro 静态输出 + Svelte 组件岛，跑在 Cloudflare Workers 的静态资源模式上——没有服务端逻辑，也没有可写的数据库，所以「搜索」只能发生在浏览器里。
下面的数字都是 2026-10-05 在 &lt;a href=&quot;https://blog.juluo.work&quot;&gt;https://blog.juluo.work&lt;/a&gt; 上实测的，当时线上索引收录 6 篇文章。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-static-search/01-live-search-panel.webp&quot; alt=&quot;在博客首页输入 cloudflare 后，搜索面板列出 6 条结果并按命中次数标注&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;先比较三条路&lt;/h2&gt;
&lt;p&gt;站内搜索常见三种做法，我挨个想过：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;为什么没用&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Algolia / 各类托管搜索&lt;/td&gt;
&lt;td&gt;要注册服务、拿 API Key、写前端 SDK，还要担心免费额度和数据出境。为了 6 篇文章不值得&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pagefind&lt;/td&gt;
&lt;td&gt;构建期切片索引、运行时按需拉分片，是这类需求的正统解法；但本站文章总量还没到「一次全量下载会疼」的量级，引入它的配置成本大于收益&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;自己烘一份 JSON&lt;/td&gt;
&lt;td&gt;构建期生成、前端全量加载、自己写匹配。没有任何外部依赖，索引随时可 &lt;code&gt;curl&lt;/code&gt; 出来看一眼&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;我选了第三条。判断依据是一个很粗糙的估算：现在 6 篇文章的索引是 51,653 字节，平均一篇约 8.6 KB。按这个斜率，100 篇文章的索引约 860 KB，gzip 之后约 390 KB——&lt;strong&gt;到那个量级就必须换方案&lt;/strong&gt;，但在 20 篇以内，全量索引的体验反而比按需加载更好（没有分片加载的延迟、没有索引构建服务）。&lt;/p&gt;
&lt;h2&gt;索引是构建期烘出来的&lt;/h2&gt;
&lt;p&gt;生成逻辑只有一个文件，32 行，&lt;code&gt;src/pages/search.json.ts&lt;/code&gt;。Astro 会把这类 API 路由在构建时执行一次，把返回的 Response 落成静态文件 &lt;code&gt;dist/search.json&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-static-search/02-search-json-source.webp&quot; alt=&quot;src/pages/search.json.ts 的完整源码，包含 toPlainText 清洗函数与 5 个字段的映射&quot; /&gt;&lt;/p&gt;
&lt;p&gt;关键就是那个 &lt;code&gt;toPlainText&lt;/code&gt;：把 Markdown 一条条削成单行纯文本（下面这段是原文，行尾注释是我为了说明加的）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function toPlainText(markdown: string): string {
	return markdown
		.replace(/!\[[^\]]*]\([^)]*\)/g, &quot; &quot;)   // 图片语法整段丢掉
		.replace(/\[([^\]]+)]\([^)]*\)/g, &quot;$1&quot;)  // 链接只留锚文本
		.replace(/```[\s\S]*?```/g, &quot; &quot;)         // 围栏代码块整段丢掉
		.replace(/`[^`]*`/g, &quot; &quot;)                // 行内代码也丢掉
		.replace(/&amp;lt;[^&amp;gt;]*&amp;gt;/g, &quot; &quot;)                // 残留的 HTML 标签
		.replace(/[#&amp;gt;*_\-~]/g, &quot; &quot;)              // Markdown 记号
		.replace(/\s+/g, &quot; &quot;)
		.trim();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;这个函数决定了搜索能搜到什么、搜不到什么。&lt;/strong&gt; 被它抹掉的内容不是小数——我拿本地 5 篇已上线文章（&lt;code&gt;hello-world&lt;/code&gt;、&lt;code&gt;security-headers-audit&lt;/code&gt;、&lt;code&gt;ssllabs-https-grade&lt;/code&gt;、&lt;code&gt;traceroute-packet-loss-misread&lt;/code&gt;、&lt;code&gt;workers-free-tier-cost&lt;/code&gt;）实测过。口径先说清楚：&lt;strong&gt;统计的是去掉 frontmatter 之后的正文 Markdown、按 UTF-8 编码的字节数&lt;/strong&gt;；行内代码按 &lt;code&gt;toPlainText&lt;/code&gt; 自己的替换顺序数，也就是在围栏代码块被抹掉之后再数。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;正文 Markdown 合计 60,863 字节&lt;/li&gt;
&lt;li&gt;其中围栏代码块 4,036 字节（6.6%）、行内代码 5,472 字节（9.0%）、图片语法 4,258 字节（7.0%）&lt;/li&gt;
&lt;li&gt;最后压出来的纯文本 45,391 字节，同时压掉了一部分体积&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;代价很明确：&lt;strong&gt;搜不到代码里的关键字&lt;/strong&gt;。这不是推测——&lt;a href=&quot;/posts/workers-free-tier-cost/&quot;&gt;《Cloudflare Workers 免费额度够不够》&lt;/a&gt;那篇正文里写过 &lt;code&gt;If-None-Match&lt;/code&gt;（在行内代码里），线上搜它是「无搜索结果」；我直接在索引文件里检索 &lt;code&gt;Cache-Control&lt;/code&gt;（有两篇文章在行内代码里写过它），同样一个字都没有。取舍是我不想让索引被代码撑大——一篇技术文章的代码块往往比正文还长，全留下的话索引体积翻倍不止。&lt;/p&gt;
&lt;h2&gt;索引里到底装了什么&lt;/h2&gt;
&lt;p&gt;直接看线上那份文件的真实输出：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-static-search/03-search-json-measured.webp&quot; alt=&quot;在 WSL 里用 curl 拉取 search.json 的真实输出：文件大小、响应头、gzip 体积、字段字节构成、单篇记录大小&quot; /&gt;&lt;/p&gt;
&lt;p&gt;一条记录长这样（&lt;code&gt;content&lt;/code&gt; 我手动截断了，其余是原文）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[
  {
    &quot;title&quot;: &quot;关于本站&quot;,
    &quot;description&quot;: &quot;这是本站的第一篇文章：介绍一下这个博客是做什么的、用了什么技术，以及怎么联系我。&quot;,
    &quot;content&quot;: &quot;欢迎来到我的博客 👋 这里是我的个人空间，主要记录技术折腾、开发笔记和一些生活碎片。 这个站点 域名 ： 技术栈 ：Astro + Svelte + Tailwind CSS，部署在 Cloudflare Workers 上 …（此处截断）&quot;,
    &quot;link&quot;: &quot;hello-world&quot;,
    &quot;published&quot;: &quot;2026-10-05T00:00:00.000Z&quot;
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只有 5 个字段，每一个都有理由：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;title&lt;/code&gt; / &lt;code&gt;description&lt;/code&gt;：直接来自 frontmatter，前端给它们最高权重&lt;/li&gt;
&lt;li&gt;&lt;code&gt;content&lt;/code&gt;：&lt;code&gt;toPlainText&lt;/code&gt; 处理后的正文，&lt;strong&gt;占了整份索引 95.3% 的字节&lt;/strong&gt;（49,237 / 51,653 字节）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;link&lt;/code&gt;：&lt;code&gt;post.id&lt;/code&gt;，也就是文章的 slug；前端拼成 &lt;code&gt;/posts/&amp;lt;link&amp;gt;/&lt;/code&gt; 当跳转地址&lt;/li&gt;
&lt;li&gt;&lt;code&gt;published&lt;/code&gt;：Date 被 &lt;code&gt;JSON.stringify&lt;/code&gt; 序列化成 UTC ISO 串，前端用 &lt;code&gt;new Date()&lt;/code&gt; 还原后排序。frontmatter 里写的 &lt;code&gt;+08:00&lt;/code&gt; 在这里被换算掉了——文章里写 22:20，索引里就是 &lt;code&gt;2026-10-05T14:20:00.000Z&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;没进去的东西同样是有意为之：tags（搜索里用不上，且会引入重复字符串）、封面图、HTML 渲染结果、目录结构。全部记录加起来 51,653 字节；用 &lt;code&gt;curl -H &quot;Accept-Encoding: gzip&quot;&lt;/code&gt; 拉一次，过网 23,335 字节（本地 &lt;code&gt;gzip -9&lt;/code&gt; 复算也是这个数），也就是&lt;strong&gt;约 23 KB&lt;/strong&gt;——这是整站里唯一一个「为了某个功能专门下载的数据文件」，其余请求都是样式、脚本和页面本身。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
「过网体积」有两个口径，别混着用。这里的 23,335 字节，是 curl 显式声明&lt;strong&gt;只要 gzip&lt;/strong&gt; 时拿到的压缩体；后面「代价」一节里浏览器实测的 25,316 字节，是 Chrome 的 &lt;code&gt;encodedBodySize&lt;/code&gt;——浏览器声明的是一整串编码（&lt;code&gt;gzip, deflate, br, zstd&lt;/code&gt;），具体用哪一种由服务端按协商结果决定，同一行的 &lt;code&gt;transferSize&lt;/code&gt; 还要再加上约 300 字节的响应头开销。差的这 1,981 字节不是谁算错了，是两份字节根本不是同一次压缩的结果；引用之前先想清楚说的是「我要 gzip 能拿到多少」还是「浏览器实际下了多少」。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;前端：没有 Fuse.js，也没有模糊匹配&lt;/h2&gt;
&lt;p&gt;先把一件事说清楚：这套搜索&lt;strong&gt;不是模糊匹配&lt;/strong&gt;，是大小写不敏感的子串精确匹配加权重打分。仓库里搜不到 &lt;code&gt;fuse&lt;/code&gt;（&lt;code&gt;git grep -i fuse&lt;/code&gt; 无命中，&lt;code&gt;package.json&lt;/code&gt; 里也没有这个依赖），从计数、加权到截摘要、排序，全在手写的一个 &lt;code&gt;searchPosts&lt;/code&gt; 函数里。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-static-search/04-search-svelte-scoring.webp&quot; alt=&quot;src/components/Search.svelte 的关键源码：取索引、逐字段计数命中、权重打分、按日期排序&quot; /&gt;&lt;/p&gt;
&lt;p&gt;核心是「数命中次数」：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const hits = (
	fieldLower.match(
		new RegExp(keywordLower.replace(/[.*+?^${}()|[\]\\]/g, &quot;\\$&amp;amp;&quot;), &quot;g&quot;),
	) || []
).length;

if (hits &amp;gt; 0) {
	totalHits += hits;
	const typeScore =
		type === &quot;title&quot;
			? 10
			: type === &quot;description&quot;
				? 5
				: type === &quot;link&quot;
					? 3
					: 1;
	totalScore += hits * typeScore;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;权重是这样定的：标题 10、简介 5、路径 3、正文 1，乘上命中次数累加成 &lt;code&gt;score&lt;/code&gt;。关键词先做正则元字符转义再用 &lt;code&gt;String.match&lt;/code&gt; 全局匹配，所以用户输入 &lt;code&gt;[&lt;/code&gt;、&lt;code&gt;*&lt;/code&gt; 这类字符不会炸正则。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;到这里有个我在读代码时才发现的事实：&lt;code&gt;score&lt;/code&gt; 算出来了，但没被用。&lt;/strong&gt; 结果列表最后是按 &lt;code&gt;published&lt;/code&gt; 排序的（默认最新优先，有个按钮切成最旧优先），界面上展示的是 &lt;code&gt;hitCount&lt;/code&gt;（「命中 N 次」），&lt;code&gt;score&lt;/code&gt; 只是被塞进结果对象里带了一路。也就是说打分的权重目前对搜索结果顺序毫无影响——它现在是个可以删掉的字段。我没动它，但这是个典型的「写的时候打算用来排序、后来改成按日期排就忘了删」。&lt;/p&gt;
&lt;p&gt;另外两个细节：高亮是把关键词包成 &lt;code&gt;&amp;lt;mark class=&quot;hl&quot;&amp;gt;&lt;/code&gt; 后用 &lt;code&gt;{@html}&lt;/code&gt; 注入的，&lt;code&gt;main.css&lt;/code&gt; 里把 &lt;code&gt;.search-panel mark&lt;/code&gt; 设成透明背景加主题色；摘要片段取命中位置前后各 40 个字符，两端补 &lt;code&gt;...&lt;/code&gt;，所以短关键词的上下文是够看的。&lt;/p&gt;
&lt;h2&gt;交互细节：中文输入法是最容易被忽略的那个&lt;/h2&gt;
&lt;p&gt;搜索框在顶栏，桌面端一个、手机端一个，都是 &lt;code&gt;bind:value&lt;/code&gt; 双向绑定。真正花心思的地方在输入法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;input placeholder=&quot;搜索&quot; bind:value={keyword} on:focus={() =&amp;gt; { void reopenPanelIfHasQuery(); }}
       on:input={sanitizeKeyword}
       on:compositionstart={() =&amp;gt; { isComposingDesktop = true; }}
       on:compositionend={() =&amp;gt; { isComposingDesktop = false; sanitizeKeyword(); }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;中文输入法在敲拼音的过程中会持续触发 &lt;code&gt;input&lt;/code&gt; 事件。如果不拦，输入「zhongwen」时拼音中间态会被当成关键词查一轮，白白拉几次索引。所以组件用 &lt;code&gt;compositionstart&lt;/code&gt; / &lt;code&gt;compositionend&lt;/code&gt; 标记组合态，组合期间不触发搜索。&lt;code&gt;sanitizeKeyword&lt;/code&gt; 则把关键词里的空白全删掉——实测输入 &lt;code&gt;cloud flare&lt;/code&gt;，输入框里会变成 &lt;code&gt;cloudflare&lt;/code&gt;，结果依然是 6 条。&lt;/p&gt;
&lt;p&gt;面板里还有一排类型筛选（标题 / 简介 / 正文 / 路径），默认是单选，勾上「多选」可以组合。实测选「标题」后结果从 6 条降到 3 条，正好是标题里含 Cloudflare 的那三篇。输入 &lt;code&gt;zzzznothing&lt;/code&gt; 时面板显示「无搜索结果」。&lt;/p&gt;
&lt;p&gt;面板的开合不靠 Svelte 状态，而是直接操作 class：&lt;code&gt;setClickOutsideToClose(&quot;search-panel&quot;, [...])&lt;/code&gt; 在 &lt;code&gt;document&lt;/code&gt; 上挂了点击监听，点到面板和三个搜索框以外的地方就加 &lt;code&gt;float-panel-closed&lt;/code&gt;（CSS 里是 &lt;code&gt;-translate-y-1 opacity-0 pointer-events-none&lt;/code&gt;）。实测点一下页面空白处，面板的 class 里立刻多出 &lt;code&gt;float-panel-closed&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-static-search/05-mobile-search-results.webp&quot; alt=&quot;手机视口（860×1400 像素，2 倍像素密度，折算成 CSS 视口是 430×700）下输入 cloudflare 的真实截图：6 条结果、命中次数与关键词高亮&quot; /&gt;&lt;/p&gt;
&lt;p&gt;手机端的搜索框在展示上和桌面端不一样：&lt;code&gt;#search-bar&lt;/code&gt; 在 430px 宽度下 &lt;code&gt;display:none&lt;/code&gt;，用的是 &lt;code&gt;#search-bar-mobile&lt;/code&gt;。顺带发现面板里那个 &lt;code&gt;#search-bar-inside&lt;/code&gt; 输入框是&lt;strong&gt;死代码&lt;/strong&gt;——它带着 &lt;code&gt;hidden&lt;/code&gt; 类，在桌面和手机两种视口下实测都是 &lt;code&gt;display:none&lt;/code&gt;，代码里只剩「点击外部时忽略它」这一处引用。这个我没清理，怕动布局出错。&lt;/p&gt;
&lt;p&gt;还有一件我确实没做的事：&lt;strong&gt;没有快捷键&lt;/strong&gt;。&lt;code&gt;git grep keydown&lt;/code&gt; 在整个 &lt;code&gt;src&lt;/code&gt; 下只有 fancybox 一处，搜索框没有 &lt;code&gt;Ctrl+K&lt;/code&gt;、也没有 &lt;code&gt;/&lt;/code&gt;。做静态博客的人很容易忘记「用户想搜的时候得先找到搜索框」，这个我记在待办里。&lt;/p&gt;
&lt;h2&gt;代价：每敲一个字都重下一次索引&lt;/h2&gt;
&lt;p&gt;这是整套方案里最实在的问题，我把它量化了：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-static-search/06-search-refetch-cost.webp&quot; alt=&quot;实测：逐字输入 cloud 触发 5 次索引请求、累计下载 258 KB；以及默认缓存与 no-store 两次取数的字节对比&quot; /&gt;&lt;/p&gt;
&lt;p&gt;在真实页面上用 350ms 间隔逐字敲 &lt;code&gt;cloud&lt;/code&gt;（5 次 &lt;code&gt;input&lt;/code&gt; 事件），&lt;code&gt;/search.json&lt;/code&gt; 被请求了 &lt;strong&gt;5 次&lt;/strong&gt;，累计下载 258,265 字节（decoded），过网 126,581 字节（encoded），单次耗时 84~101ms。关键词不变、只点一下类型筛选，请求数又从 5 变成 6。&lt;/p&gt;
&lt;p&gt;原因在 &lt;code&gt;fetchPosts&lt;/code&gt; 里这一行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const response = await fetch(&quot;/search.json&quot;, { cache: &quot;no-store&quot; });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以及那个响应式块：&lt;code&gt;keyword&lt;/code&gt; 或 &lt;code&gt;selectedTypes&lt;/code&gt; 一变，就重新 &lt;code&gt;fetchAndSearch&lt;/code&gt;。&lt;code&gt;no-store&lt;/code&gt; 明确禁止浏览器缓存这份响应，所以每次都得走网络重新拿全量。有意思的是，服务端其实是&lt;strong&gt;支持条件请求&lt;/strong&gt;的——带上 &lt;code&gt;If-None-Match&lt;/code&gt; 再请求一次，返回的是 &lt;code&gt;304&lt;/code&gt;，只要几百字节；而 &lt;code&gt;cache: &quot;no-store&quot;&lt;/code&gt; 让浏览器根本没机会发这个条件请求。&lt;/p&gt;
&lt;p&gt;同一份索引各取两次的对比更直观：默认缓存下浏览器本地已有副本，两次都只花 &lt;strong&gt;300 字节&lt;/strong&gt;（走 304 复验）；&lt;code&gt;no-store&lt;/code&gt; 下两次都是 &lt;strong&gt;25,616 字节&lt;/strong&gt;全量。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!TIP]
这就是这套实现里我准备先改的地方：去掉 &lt;code&gt;no-store&lt;/code&gt; 或者把已经拿到的 &lt;code&gt;allPosts&lt;/code&gt; 复用起来，两个改动都很小，能把「敲 5 个字符下 250 KB」压到几乎为零。写这篇的时候还是老样子，所以文章里的数字都是老的。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;踩过的坑&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. 在 API 路由里写 &lt;code&gt;Cache-Control&lt;/code&gt; 是没用的。&lt;/strong&gt; &lt;code&gt;search.json.ts&lt;/code&gt; 里明明写的是 &lt;code&gt;&quot;Cache-Control&quot;: &quot;public, max-age=600&quot;&lt;/code&gt;，线上 &lt;code&gt;curl -sI&lt;/code&gt; 拿到的是 &lt;code&gt;public, max-age=0, must-revalidate&lt;/code&gt; + &lt;code&gt;ETag&lt;/code&gt;。&lt;code&gt;/posts.json&lt;/code&gt;、&lt;code&gt;/rss.xml&lt;/code&gt; 三条一样，说明这是 Workers 静态资源层统一加的头——纯静态输出下，路由返回的响应头根本落不到文件上（这个话题在&lt;a href=&quot;/posts/security-headers-audit/&quot;&gt;《我的博客响应头缺了什么》&lt;/a&gt;里写过一次）。想改的话，最省事的是 &lt;code&gt;public/_headers&lt;/code&gt;（Workers 静态资源也支持这套规则），或者在 Worker 逻辑里自己改响应头；而仓库里目前没有 &lt;code&gt;_headers&lt;/code&gt; 这个文件。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. PowerShell 会把 ETag 的引号吃掉，导致误判「服务端不支持 304」。&lt;/strong&gt; 我第一次测条件请求，用 &lt;code&gt;curl.exe -H &quot;If-None-Match: $etag&quot;&lt;/code&gt; 发出去，请求头变成了不带引号的 &lt;code&gt;If-None-Match: 5601094e…&lt;/code&gt;，服务端当然认为不匹配，返回 200 加完整内容。换成在 bash 里跑、引号保留，同一个 ETag 立刻返回 304——那个 288,727 字节的 CSS 也一样返回 304。顺带说一句：&lt;a href=&quot;/posts/workers-free-tier-cost/&quot;&gt;《Cloudflare Workers 免费额度够不够》&lt;/a&gt;里「带 ETag 重发没有 304」那条结论，多半就是吃了这个亏（当时测的是同一类请求）。&lt;strong&gt;测量脚本本身出错，比被测对象出错更常见。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;results.sort()&lt;/code&gt; 原地排序把面板搞没了。&lt;/strong&gt; 这一条不是我自己踩出来的，是主题上游的历史修复：提交 &lt;code&gt;37dd9a3b&lt;/code&gt; 把 &lt;code&gt;return results.sort(...)&lt;/code&gt; 改成了 &lt;code&gt;return [...results].sort(...)&lt;/code&gt;，修的是「排序功能上线后第一次点排序按钮，面板消失」。这个提交的作者是主题原作者（二叉树树），不是本站主人。原地排序动的是同一个数组引用，上游没解释 Svelte 的响应式在这里具体怎么判定，我也没复现这个 bug，只能照着修法倒推原因。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. 排序条件不能靠重新搜索。&lt;/strong&gt; 点排序按钮如果重新走一遍 &lt;code&gt;fetchAndSearch&lt;/code&gt;，就又要下载一次索引。现在的写法是把第一次的结果存进 &lt;code&gt;allPosts&lt;/code&gt;，再用第二个响应式块只重算不重取。这也是前面那个「点筛选却多了一次请求」的来源——筛选条件变更走的是第一条路径。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Astro v6 升级后 &lt;code&gt;entry.slug&lt;/code&gt; 变成 &lt;code&gt;entry.id&lt;/code&gt;。&lt;/strong&gt; 这一条同样是上游留下的历史，不是本站主人的功劳：索引里的 &lt;code&gt;link&lt;/code&gt; 字段现在直接来自 &lt;code&gt;post.id&lt;/code&gt;，而把 &lt;code&gt;entry.slug&lt;/code&gt; 全部换成 &lt;code&gt;entry.id&lt;/code&gt; 的是上游提交 &lt;code&gt;a4332e21&lt;/code&gt;（作者同为主题原作者），那一次连带着 RSS、sitemap、卡片组件、&lt;code&gt;search.json.ts&lt;/code&gt; 一起替换，不然文章链接会渲染成 &lt;code&gt;undefined&lt;/code&gt;。这段经过我是从它的提交信息和 diff 里读出来的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. 索引的新鲜度等于部署的新鲜度。&lt;/strong&gt; 我本地刚删掉一篇文章，线上索引里照样能搜到它，因为它是在上一次部署时烘进去的。静态索引没有「数据过期」这回事，只有「没重新部署」。&lt;/p&gt;
&lt;h2&gt;什么情况下继续用它&lt;/h2&gt;
&lt;p&gt;判断标准我给自己列了三条：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;文章数在 50 篇以内、索引 gzip 后还在 300 KB 以下&lt;/strong&gt;：继续用。零依赖、可 &lt;code&gt;curl&lt;/code&gt; 验证、改一行代码就能调权重。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要拼音搜索、错字容错、或者搜代码块里的内容&lt;/strong&gt;：换方案。现在的实现是子串精确匹配，&lt;code&gt;k8s&lt;/code&gt; 搜不到 &lt;code&gt;Kubernetes&lt;/code&gt;，被 &lt;code&gt;toPlainText&lt;/code&gt; 抹掉的代码块也永远搜不到。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;文章超过 100 篇&lt;/strong&gt;：必须换。按现在平均 8.6 KB/篇推算，100 篇的索引约 860 KB，&lt;code&gt;no-store&lt;/code&gt; 下敲五个字符就是几 MB 流量，这对读者和免费额度都不友好。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;近期我会按这个顺序改：先去掉 &lt;code&gt;no-store&lt;/code&gt; 并把 &lt;code&gt;allPosts&lt;/code&gt; 复用起来（省掉绝大部分重复下载），再把没用上的 &lt;code&gt;score&lt;/code&gt; 删掉或者真正接进排序，最后才是考虑换掉整套方案。&lt;/p&gt;
&lt;p&gt;一份 JSON 索引加一个手写的匹配函数，能撑多久取决于你写多少字——它现在撑得住，但它不是那种「可以放着不管」的实现：每加一篇文章，索引就大一点，而每次搜索都要把它整个搬一遍。&lt;/p&gt;
&lt;p&gt;搜索索引和&lt;a href=&quot;/posts/blog-post-history/&quot;&gt;文章修订历史&lt;/a&gt;都依赖同一套内容集合，两者的构建期处理可以放在一起看。&lt;/p&gt;
</content:encoded></item><item><title>给每篇文章加「更新记录」：用 git 提交历史自动生成文章修改时间线</title><link>https://blog.peroe.cn/posts/blog-post-history/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-post-history/</guid><description>文章页底部那块「更新记录」不是手写的：构建前用脚本读 git log 生成 JSON，页面再按 slug 查表渲染。本文拆开这条链路，讲清 fetch-depth 0 为什么是硬前提、浅克隆会怎样把一条无关提交按到所有文章头上，以及行内 diff 编辑器的取舍。</description><pubDate>Mon, 05 Oct 2026 14:35:00 GMT</pubDate><content:encoded>&lt;p&gt;「这篇文章是什么时候改的？」——这是我做博客时第一个没解决的问题。frontmatter 里的 &lt;code&gt;updated&lt;/code&gt; 是我手填的：勤快的时候会更新，改个错别字的时候不会；而且读者没有任何办法验证它。最后我在文章页底部放了一块「更新记录」，列的是这篇文件在 git 里的真实提交，带日期、短哈希、提交信息，点哈希直接跳到 GitHub 的 commit 页。&lt;/p&gt;
&lt;p&gt;下面是线上渲染出来的样子。&lt;strong&gt;但先别信它&lt;/strong&gt;——图里那条提交跟截图里那篇文章毫无关系，第四节会讲清楚为什么。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-post-history/01-live-revision-history-bug.webp&quot; alt=&quot;线上文章页底部的编辑区，展开「查看变更记录」后只显示一条与文章无关的提交&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
站点环境：Astro 6 静态站，部署在 Cloudflare Worker 的静态资源模式，构建由 Workers Builds 从 GitHub 的 &lt;code&gt;main&lt;/code&gt; 分支跑 &lt;code&gt;pnpm update-diff &amp;amp;&amp;amp; pnpm astro build --force&lt;/code&gt;。本文所有命令输出都是 &lt;strong&gt;2026-10-05 在本机真实执行&lt;/strong&gt;的（Windows + Node v24.16.0 + git 2.53.0）。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;一、为什么用 git 历史，而不是别的数据源&lt;/h2&gt;
&lt;p&gt;做「更新时间线」有三条路，我都想过：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;手写 &lt;code&gt;updated&lt;/code&gt; 字段&lt;/strong&gt;。最省事，但它是自我声明，不是事实。我改一篇文章的时候经常只改正文忘了改字段，时间线立刻失真。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;读文件系统 mtime&lt;/strong&gt;。这个方案听起来很干净，实际完全不能用：CI 上克隆下来的文件，mtime 全是 checkout 那一刻的时间，跟文章的真实修改时间没有任何关系。本机看着正常、线上全是同一个时间戳，比不做还糟。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;读 git 提交历史&lt;/strong&gt;。时间、作者、说明、可点击的详情页，全都是现成的，零额外维护成本。代价是构建机上必须有完整的 git 历史——这正是第四节那个坑的来源。&lt;/p&gt;
&lt;p&gt;我选了第三条。它多出来的成本只有一项：构建前要多跑一个脚本。&lt;/p&gt;
&lt;h2&gt;二、上半段：构建前把 git log 压成一个 JSON&lt;/h2&gt;
&lt;p&gt;链路起点是 &lt;code&gt;scripts/update-diff.js&lt;/code&gt;，&lt;code&gt;pnpm update-diff&lt;/code&gt; 就是跑它。它干的事情非常直白：遍历 &lt;code&gt;src/content/posts&lt;/code&gt; 下的所有 &lt;code&gt;.md&lt;/code&gt; / &lt;code&gt;.mdx&lt;/code&gt;，对每个文件跑一次 &lt;code&gt;git log&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const git = spawn(&apos;git&apos;, [
    &apos;log&apos;,
    &apos;--follow&apos;,
    &apos;--pretty=format:%H|%ad|%s&apos;,
    &apos;--date=iso&apos;,
    &apos;--&apos;,
    filePath
]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--follow&lt;/code&gt; 是必须的：文章改过 slug、挪过目录之后，不带这个参数历史会从改名那一刻断掉。&lt;/p&gt;
&lt;p&gt;输出的每一行长这样：&lt;code&gt;&amp;lt;完整哈希&amp;gt;|&amp;lt;ISO 日期&amp;gt;|&amp;lt;提交信息&amp;gt;&lt;/code&gt;。解析部分有个细节值得看一眼——注释里写着「message might contain |」：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const firstPipe = line.indexOf(&apos;|&apos;);
const secondPipe = line.indexOf(&apos;|&apos;, firstPipe + 1);

if (firstPipe === -1 || secondPipe === -1) return null;

const hash = line.substring(0, firstPipe);
const date = line.substring(firstPipe + 1, secondPipe);
const message = line.substring(secondPipe + 1);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它没有用 &lt;code&gt;split(&apos;|&apos;)&lt;/code&gt;，而是手工定位前两个竖线再切三刀。这样即使提交信息里带竖线（我写提交信息时经常带表格或命令行片段），后面的内容也会原样落到 &lt;code&gt;message&lt;/code&gt; 里，不会被截断。&lt;/p&gt;
&lt;p&gt;文章一多，这里就是几十上百次子进程，所以脚本用了 &lt;code&gt;spawn&lt;/code&gt; 而不是 &lt;code&gt;exec&lt;/code&gt;，并且按 CPU 核数分块并发：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const MAX_CONCURRENCY = Math.max(1, os.cpus().length - 1); // Leave one core free
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;本机 16 核，所以运行时打印的是 &lt;code&gt;Using concurrency: 15&lt;/code&gt;。这里刻意留一个核给主进程，避免脚本自己把机器占满。&lt;/p&gt;
&lt;p&gt;还有一个设计我很喜欢——&lt;strong&gt;单篇失败不拖垮整体&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (code !== 0) {
    console.warn(`Failed to retrieve git history for ${filePath}: ${error}`);
    resolve([]); // Resolve empty on error to keep going
    return;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;git 命令非零退出、或者 spawn 本身报错，都只是 &lt;code&gt;resolve([])&lt;/code&gt;。一篇文章查不到历史（比如刚 &lt;code&gt;git add&lt;/code&gt; 还没提交），页面上少一块时间线而已，不该让整站构建挂掉。&lt;/p&gt;
&lt;p&gt;最后，键名用的是文件相对 &lt;code&gt;src/content/posts&lt;/code&gt; 的路径，并强制转成正斜杠：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const relativePath = path.relative(CONTENT_DIR, file).replace(/\\/g, &apos;/&apos;);
const history = await getGitHistoryAsync(file);
historyMap[relativePath] = history;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这行 &lt;code&gt;replace(/\\/g, &apos;/&apos;)&lt;/code&gt; 是 Windows 上开发必须要的：不然本机生成 &lt;code&gt;ssllabs-https-grade.md&lt;/code&gt;，Linux 构建机上生成 &lt;code&gt;posts\ssllabs-https-grade.md&lt;/code&gt;，页面里查表全落空。&lt;/p&gt;
&lt;p&gt;跑完之后产出 &lt;code&gt;src/json/git-history.json&lt;/code&gt;：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-post-history/02-update-diff-terminal.webp&quot; alt=&quot;本机真实执行 node scripts/update-diff.js 的终端输出，以及生成的 git-history.json 前 11 行&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这个文件是&lt;strong&gt;生成物&lt;/strong&gt;，已经写进 &lt;code&gt;.gitignore&lt;/code&gt;（仓库最后一行就是 &lt;code&gt;src/json/git-history.json&lt;/code&gt;），不进版本控制，每台机器构建时现算。代价是构建顺序变成了硬约束：&lt;code&gt;src/utils/git-history.ts&lt;/code&gt; 第 4 行是静态 import，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import gitHistory from &quot;../json/git-history.json&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;文件不存在时 Vite 直接在解析阶段报 &lt;code&gt;Could not resolve &quot;../json/git-history.json&quot;&lt;/code&gt;。仓库的 &lt;code&gt;docs/workers-deploy.md&lt;/code&gt; 专门为此写了一条：Build command 里的 &lt;code&gt;pnpm update-diff&lt;/code&gt; 必须保留。我的看法是：&lt;strong&gt;让构建失败比让页面悄悄少一块好&lt;/strong&gt;——所以这个「硬约束」我认。&lt;/p&gt;
&lt;p&gt;每篇文章起一个 git 进程，文章数上去以后这一步会是构建里最先变慢的环节。我还没到那个量级，暂时按 &lt;code&gt;cpus - 1&lt;/code&gt; 并发扛着，不提前优化。&lt;/p&gt;
&lt;h2&gt;三、下半段：页面按 slug 查表&lt;/h2&gt;
&lt;p&gt;文章页要拿历史，只有一行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const history = getPostHistory(entry.id);
const lastUpdateTime =
	history[0]?.date || entry.data.updated || entry.data.published;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;getPostHistory&lt;/code&gt; 做的事就是「补扩展名 + 查表」，但这个补扩展名是整条链路里最容易断的一环：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function getPostHistory(postId: string): Commit[] {
	try {
		// Normalize ID to match keys in JSON (forward slashes)
		let normalizedId = postId.replace(/\\/g, &quot;/&quot;);

		// Add .md extension if not present (entry.id doesn&apos;t include extension)
		if (!normalizedId.endsWith(&apos;.md&apos;) &amp;amp;&amp;amp; !normalizedId.endsWith(&apos;.mdx&apos;)) {
			normalizedId += &apos;.md&apos;;
		}

		// Look up in the pre-generated history map
		const historyMap = gitHistory as Record&amp;lt;string, Commit[]&amp;gt;;

		if (historyMap?.[normalizedId]) {
			return historyMap[normalizedId];
		}

		return [];
	} catch (e) {
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;JSON 的键是&lt;strong&gt;带扩展名的文件名&lt;/strong&gt;，而 Astro 内容集合给的 &lt;code&gt;entry.id&lt;/code&gt; 是&lt;strong&gt;不带扩展名的&lt;/strong&gt;（&lt;code&gt;ssllabs-https-grade&lt;/code&gt;）。两边靠这里补一个 &lt;code&gt;.md&lt;/code&gt; 对齐——代码注释里也是这么写的。哪天换成别的 loader、或者文章改成 &lt;code&gt;.mdx&lt;/code&gt; 以外的后缀，这里对不上，函数返回空数组，页面只是&lt;strong&gt;不渲染那一块&lt;/strong&gt;，不抛错、不报警。这类静默退化是我后来加自检的原因。&lt;/p&gt;
&lt;p&gt;渲染部分是普通的折叠块，但有几处体现了取舍：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{history.length &amp;gt; 0 &amp;amp;&amp;amp; (
    &amp;lt;div id=&quot;revision-history&quot; class=&quot;flex items-start gap-3 mt-4 scroll-mt-20&quot;&amp;gt;
        &amp;lt;div class=&quot;h-5 w-5 bg-[var(--primary)] flex items-center justify-center&quot;&amp;gt;
            &amp;lt;Icon name=&quot;material-symbols:history-rounded&quot; class=&quot;text-[0.875rem] text-black/70&quot;&amp;gt;&amp;lt;/Icon&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;flex-1&quot;&amp;gt;
            &amp;lt;p class=&quot;text-white/80 text-sm mb-1&quot;&amp;gt;文章修订历史 ({history.length} 次)&amp;lt;/p&amp;gt;
            &amp;lt;details class=&quot;group&quot;&amp;gt;
                &amp;lt;summary class=&quot;cursor-pointer text-sm text-[var(--primary)] font-medium hover:underline select-none&quot;&amp;gt;
                    查看变更记录
                &amp;lt;/summary&amp;gt;
                &amp;lt;div class=&quot;mt-2 space-y-2 max-h-48 overflow-y-auto&quot;&amp;gt;
                    {history.map(commit =&amp;gt; (
                        &amp;lt;div class=&quot;flex flex-col text-xs bg-white/5 p-2&quot;&amp;gt;
                            &amp;lt;div class=&quot;flex justify-between items-center mb-1&quot;&amp;gt;
                                &amp;lt;span class=&quot;font-bold text-white/70&quot;&amp;gt;{formatPostDateForDisplay(new Date(commit.date))}&amp;lt;/span&amp;gt;
                                &amp;lt;a href={getCommitUrl(commit.hash)} target=&quot;_blank&quot; class=&quot;text-[var(--primary)] hover:underline font-mono&quot;&amp;gt;
                                    {commit.hash.substring(0, 7)}
                                &amp;lt;/a&amp;gt;
                            &amp;lt;/div&amp;gt;
                            &amp;lt;p class=&quot;text-white/60 break-all&quot;&amp;gt;{commit.message}&amp;lt;/p&amp;gt;
                        &amp;lt;/div&amp;gt;
                    ))}
                &amp;lt;/div&amp;gt;
            &amp;lt;/details&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几点说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;默认折叠 + &lt;code&gt;max-h-48 overflow-y-auto&lt;/code&gt;&lt;/strong&gt;。改过几十次的文章不会把页面撑开，代价是读者必须多点一次才能看到内容。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;history[0]&lt;/code&gt; 被当成「最后修改时间」&lt;/strong&gt;。这里依赖 &lt;code&gt;git log&lt;/code&gt; 默认的「新提交在前」，脚本没有额外排序，页面也没排。约定是靠 git 的默认行为撑着的，没有断言保护。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;lastUpdateTime&lt;/code&gt; 的兜底顺序是「git 历史 → frontmatter 的 updated → published」&lt;/strong&gt;。这个值会喂给 &lt;code&gt;PostUpdateNotice&lt;/code&gt;，用来算「文章初次发布于 N 天前，最后修改于 M 天前」。所以 git 历史不只是装饰，它还参与页面上的时效性提示。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;短哈希 + &lt;code&gt;getCommitUrl&lt;/code&gt;&lt;/strong&gt;：函数从 &lt;code&gt;gitHubEditConfig.baseUrl&lt;/code&gt;（&lt;code&gt;https://github.com/juluowork/fuwari/blob/main/src/content/posts&lt;/code&gt;）里截掉 &lt;code&gt;/blob/&lt;/code&gt; 之后的部分，拼成 &lt;code&gt;/commit/&amp;lt;hash&amp;gt;&lt;/code&gt;。只要「编辑本文」那个配置是对的，commit 链接就自动对，不用配第二个地址。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一个我不太满意的耦合&lt;/strong&gt;：整块（编辑入口 + 修订历史）被 &lt;code&gt;{gitHubEditConfig.enable &amp;amp;&amp;amp; (...)}&lt;/code&gt; 包着。也就是说关掉「在 GitHub 上编辑此页」，修订历史会一起消失。想只关编辑链接、保留历史，得先拆这层结构。我暂时留着——两个功能指向同一个仓库，一起开关也说得通。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;四、浅克隆会把别人的提交按到你头上&lt;/h2&gt;
&lt;p&gt;上面这条链路在本机跑得好好的。但线上是错的。&lt;/p&gt;
&lt;p&gt;写作时线上挂着 6 篇文章，我把它们的页面全抓了一遍。那 6 篇里有一篇当晚就被我删掉了（选题没价值），所以下表只列现在还挂着的 5 篇：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;线上文章&lt;/th&gt;
&lt;th&gt;显示的提交&lt;/th&gt;
&lt;th&gt;显示的提交信息&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hello-world&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;64bbdda&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fix: 首篇文章发布时间补上 +08:00 时区，避免显示成次日&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;security-headers-audit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;64bbdda&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同上&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ssllabs-https-grade&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;64bbdda&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同上&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;traceroute-packet-loss-misread&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;64bbdda&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同上&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;workers-free-tier-cost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;64bbdda&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;同上&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;五篇文章，同一条提交。而这条提交在本地仓库里只动过一个文件——正是当晚被我删掉的那篇，跟上面这五篇一篇都不沾边：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ git show --stat --oneline 64bbdda
64bbddad fix: 首篇文章发布时间补上 +08:00 时区，避免显示成次日
 src/content/posts/【已删除的那篇】.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;（上面那行文件名我换成了占位符：那篇已经不在仓库里了，&lt;code&gt;git show&lt;/code&gt; 的原始输出是它的完整路径。）&lt;/p&gt;
&lt;p&gt;换句话说：&lt;strong&gt;这五篇没有一篇是对的&lt;/strong&gt;，全被贴了一条跟自己无关的提交。&lt;/p&gt;
&lt;p&gt;原因不在脚本，在&lt;strong&gt;克隆深度&lt;/strong&gt;。线上那次构建时，远端 &lt;code&gt;main&lt;/code&gt; 的顶端就是 &lt;code&gt;64bbdda&lt;/code&gt;，而构建机拿到的是一个浅克隆——整个克隆里只有那一个提交。在浅克隆里，唯一被 fetch 到的提交被当成&lt;strong&gt;根提交&lt;/strong&gt;，而 &lt;code&gt;git log -- &amp;lt;path&amp;gt;&lt;/code&gt; 对根提交的判定会退化成「这个路径存在于它的树里吗」。所有文章都在那棵树里，于是每篇文章都拿到了同一条提交。&lt;/p&gt;
&lt;p&gt;我在本机复现了一遍：先 &lt;code&gt;git clone --depth 1&lt;/code&gt; 建一个浅克隆，再往里补上两次父提交、把浅边界退到 &lt;code&gt;64bbdda&lt;/code&gt;（也就是线上那次构建看到的顶端），然后 checkout 过去——这样克隆里依然只有一个提交，正好复现出线上那次构建的状态。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-post-history/03-git-log-shallow-vs-full.webp&quot; alt=&quot;完整克隆与浅克隆下，同一条 git log --follow 命令的输出对比&quot; /&gt;&lt;/p&gt;
&lt;p&gt;写作时本机这个完整克隆有 2359 个提交，&lt;code&gt;--follow&lt;/code&gt; 只返回 &lt;code&gt;f374f125&lt;/code&gt; 那条——那才是 &lt;a href=&quot;/posts/ssllabs-https-grade/&quot;&gt;SSL Labs 那篇&lt;/a&gt;真正的提交；浅克隆只有 1 个提交，同一个命令在两篇不同文章上返回了同一条 &lt;code&gt;64bbdda&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这里有个容易误判的地方：&lt;strong&gt;浅克隆的症状不是「历史为空」，而是「所有文章共享同一条最新提交」&lt;/strong&gt;。脚本对每条 git 命令只看退出码，浅克隆里 &lt;code&gt;git log&lt;/code&gt; 退出码是 0、输出格式也完全合法，JSON 生成得漂漂亮亮，页面渲染得整整齐齐——错的只有内容。这种静默错误，只有把线上页面和本地 &lt;code&gt;git log&lt;/code&gt; 对一遍才抓得出来。&lt;/p&gt;
&lt;p&gt;顺带说明：现在每篇文章只有 1 条记录，不是因为功能坏了，而是站点刚建起来——我拿 &lt;code&gt;git log --oneline --follow -- src/content/posts/&amp;lt;slug&amp;gt;.md&lt;/code&gt; 把当时仓库里现役的 5 篇文章挨个查了一遍，每篇都只有 1 条，就是各自的发布提交。历史要等后续修改才会变长。&lt;/p&gt;
&lt;p&gt;（顺带一提，「每篇只被提交过一次」这话有个反例：当晚被我删掉的那篇，在历史里有两条改到文件内容的提交——一条发布、一条修正，而那条修正提交正是上面的 &lt;code&gt;64bbdda&lt;/code&gt;；删掉它之后，历史里还多出一条删除记录。所以这个「一次」的统计口径只能限定在现役文章上。）&lt;/p&gt;
&lt;p&gt;那为什么没配 &lt;code&gt;fetch-depth: 0&lt;/code&gt;？因为&lt;strong&gt;现在没有地方配&lt;/strong&gt;。构建跑在 Cloudflare Workers Builds 上，它的 &lt;a href=&quot;https://developers.cloudflare.com/workers/ci-cd/builds/configuration/&quot;&gt;Build settings&lt;/a&gt; 里能填的只有 Git 仓库 / 分支、Build command、Deploy command、Preview command、Root directory 和构建期环境变量，没有克隆深度这一项；同一页文档也明确说它不读 wrangler 配置里的 Custom Builds。&lt;code&gt;actions/checkout&lt;/code&gt; 时代那种 &lt;code&gt;with: fetch-depth: 0&lt;/code&gt; 在这里没处可写。&lt;/p&gt;
&lt;p&gt;仓库里其实留着一条老路。停用的 &lt;code&gt;.github/workflows/deploy.yml-nouse&lt;/code&gt; 里，构建步骤是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Build
  run: git clone https://github.com/juluowork/fuwari temp &amp;amp;&amp;amp; cd temp &amp;amp;&amp;amp; pnpm update-diff &amp;amp;&amp;amp; cd .. &amp;amp;&amp;amp; mkdir -p src/json &amp;amp;&amp;amp; mv -f temp/src/json/git-history.json src/json/git-history.json &amp;amp;&amp;amp; rm -rf temp &amp;amp;&amp;amp; pnpm build:cdn
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它没有给 &lt;code&gt;actions/checkout&lt;/code&gt; 传 &lt;code&gt;fetch-depth&lt;/code&gt;，而是另外完整克隆了一份到 &lt;code&gt;temp/&lt;/code&gt;、在里面跑 &lt;code&gt;update-diff&lt;/code&gt;、再把 JSON 搬回来。不管当时作者是怎么想的，效果就是&lt;strong&gt;用一次全量克隆绕开浅检出&lt;/strong&gt;。这条思路今天依然有效，只是执行位置从 Actions 挪到了 Build command 里。&lt;/p&gt;
&lt;p&gt;我本机验证过的修法是先补历史再生成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git fetch --unshallow &amp;amp;&amp;amp; pnpm update-diff &amp;amp;&amp;amp; pnpm astro build --force
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在刚才那个 &lt;code&gt;--depth 1&lt;/code&gt; 的克隆里跑 &lt;code&gt;git fetch --unshallow&lt;/code&gt;，浅边界消失、历史整段补回来（提交数从 1 回到两千多；写作时本机 &lt;code&gt;HEAD&lt;/code&gt; 是 2359 个提交），同一条 &lt;code&gt;--follow&lt;/code&gt; 立刻返回正确结果；再跑 &lt;code&gt;update-diff&lt;/code&gt;，生成的 JSON 里每篇文章都拿到了各自那条提交。&lt;/p&gt;
&lt;p&gt;补完历史后，本地渲染出来的正确结果是这样的——还是第一张图那个页面，也就是《中间跳丢包 90% 需要管吗》那篇；哈希 &lt;code&gt;b12e281&lt;/code&gt;，对应的提交是 &lt;code&gt;b12e2810&lt;/code&gt;「posts:发布新文章《中间跳丢包 90% 需要管吗：一次 traceroute 误读的完整拆解》」，正是这篇文件自己的发布提交：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-post-history/04-local-revision-history.webp&quot; alt=&quot;本地完整克隆下同一篇文章的更新记录，显示的是这篇文件真正的提交&quot; /&gt;&lt;/p&gt;
&lt;p&gt;对比第一张图就能看出差别：&lt;strong&gt;数据链路一行没改，只换了克隆深度&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;五、顺带做的行内 diff 编辑器&lt;/h2&gt;
&lt;p&gt;摸到「文章版本」这件事之后，我顺手加了一个只在调试时出现的功能：&lt;code&gt;?__diff_debug=1&lt;/code&gt; 打开一篇文章，页面左下角会浮出一个面板。面板是&lt;strong&gt;单列上下排列&lt;/strong&gt;的：标题、简介、正文三组字段各自「旧在上、新在下」——&lt;code&gt;DiffDebugEditor.astro&lt;/code&gt; 里就是 &lt;code&gt;oldArea&lt;/code&gt; 紧接着 &lt;code&gt;newArea&lt;/code&gt; 两个 textarea，没有左右对照那一栏。点「保存并生成 DIFF」，差异会&lt;strong&gt;直接画在正文上&lt;/strong&gt;——新增的段落套绿框，被删掉的段落划红线。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-post-history/05-diff-debug-panel.webp&quot; alt=&quot;本机 ?__diff_debug=1 打开的文章页，左下角的 DIFF 调试面板&quot; /&gt;&lt;/p&gt;
&lt;p&gt;它的数据来源很有意思：&lt;strong&gt;旧内容来自 &lt;code&gt;/rss.xml&lt;/code&gt;&lt;/strong&gt;。&lt;code&gt;DiffDebugEditor.astro&lt;/code&gt; 会 fetch 当前线上部署的 RSS，从 &lt;code&gt;content:encoded&lt;/code&gt; 里取出渲染后的正文 HTML，存进 IndexedDB（&lt;code&gt;fuwari-diff-debug-old&lt;/code&gt;）。也就是说，一个本来是给阅读器用的公开 XML，在这里被当成了「线上正文快照」的免费存储——不需要任何服务端接口、不需要额外部署。我实测过：写作时那次部署的 &lt;code&gt;rss.xml&lt;/code&gt; 里，当时那 6 篇文章的完整正文都在，一共 101 KB（删掉一篇文章并重新部署之后，这个数会跟着变）。&lt;/p&gt;
&lt;p&gt;生成完 diff 之后，结果写进 &lt;code&gt;sessionStorage&lt;/code&gt; 的 &lt;code&gt;fuwari-diff-debug-state&lt;/code&gt;，再派发一个 &lt;code&gt;fuwari:diff-debug-updated&lt;/code&gt; 事件；&lt;code&gt;src/scripts/post-inline-diff.ts&lt;/code&gt; 接住事件，把 diff 的每一行拿去和正文里的 &lt;code&gt;p / li / blockquote / pre / h1~h6&lt;/code&gt; 做&lt;strong&gt;文本比对&lt;/strong&gt;，找到对应节点再加 class。&lt;/p&gt;
&lt;p&gt;取舍要老实讲：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;匹配是按文本，不是按源码行&lt;/strong&gt;。我改了措辞、动了标点，旧行在正文里找不到，就只能退化成一整行红色的「已删除」块插在上下文旁边。图里效果好看，前提是「新旧文本大部分能对上」。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;全局只插一个 &lt;code&gt;#post-diff&lt;/code&gt; 锚点&lt;/strong&gt;，多个改动块共用它，所以它只能告诉你「这里变了」，没有 GitHub 那种逐块跳转。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;成本落在所有读者身上&lt;/strong&gt;。面板要带参数才显示，但它的脚本是文章页的固定模块。我从线上抓了一下：&lt;code&gt;DiffDebugEditor...js&lt;/code&gt; 是 12.5 KB（brotli 传输 4.8 KB），文章页 6 个 JS 模块的 brotli 传输量加起来 34.2 KB——也就是约 14% 的文章页 JS 预算，花在一个绝大多数读者永远不会打开的面板上。&lt;/li&gt;
&lt;li&gt;还有一个没做完的地方：&lt;code&gt;post-inline-diff.ts&lt;/code&gt; 里 &lt;code&gt;applyInlineTextDiff&lt;/code&gt; 和 &lt;code&gt;tryApplyInlineReplace&lt;/code&gt; 两个函数直接 &lt;code&gt;return false&lt;/code&gt;，全文件也没有任何地方调用它们。字符级的行内 diff（只把改动的那几个字标红）留了位置但没实现，现在是块级效果。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我暂时选择留着这个功能：4.8 KB 换「改完稿立刻看到差异」，对一个月更几篇的个人博客是划算的。但如果哪天文章页 JS 预算变成硬约束，第一个该砍的就是它——把面板改成 &lt;code&gt;?__diff_debug=1&lt;/code&gt; 时再动态 &lt;code&gt;import()&lt;/code&gt; 即可，不需要动数据结构。&lt;/p&gt;
&lt;h2&gt;六、如果你也要做，按这个清单来&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;数据源选 git，前提是克隆深度&lt;/strong&gt;。&lt;code&gt;git log --follow&lt;/code&gt; 是性价比最高的「更新时间线」数据源，但它在构建机上能不能用，取决于克隆深度——这是唯一的前提，也是唯一会静默失败的地方。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;浅克隆的症状是「所有文章共享同一条最新提交」&lt;/strong&gt;，不是「历史为空」。看到这个特征基本可以确诊；&lt;code&gt;git fetch --unshallow&lt;/code&gt; 是验证和修复都最快的一步。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;生成物就别进仓库&lt;/strong&gt;。&lt;code&gt;git-history.json&lt;/code&gt; 放 &lt;code&gt;.gitignore&lt;/code&gt;，让构建前现算；同时接受「构建必须先跑脚本」这个硬约束——静态 import 的报错很明确，比页面悄悄少一块好得多。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;给键名约定加一道自检&lt;/strong&gt;。我这套是「相对路径 + &lt;code&gt;.md&lt;/code&gt;」，对不上时函数返回 &lt;code&gt;[]&lt;/code&gt;、页面静默不渲染。我打算在 &lt;code&gt;update-diff.js&lt;/code&gt; 结尾加一个检查：如果多篇文章拿到同一个哈希、而那篇文章只有一条记录，就让脚本以非零码退出。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;调试功能要算进所有读者的预算&lt;/strong&gt;。能用 URL 参数触发的 UI，不代表它的代码不用下载。上线前抓一次产物里的 chunk 大小，心里有数再决定留不留。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果更关心内容侧怎么变成可检索的产物，&lt;a href=&quot;/posts/blog-static-search/&quot;&gt;站内搜索&lt;/a&gt;那篇讲了索引是怎么切出来的。&lt;/p&gt;
</content:encoded></item><item><title>Workers 免费额度够不够：算一笔真实账</title><link>https://blog.peroe.cn/posts/workers-free-tier-cost/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/workers-free-tier-cost/</guid><description>「Workers 免费套餐每天 10 万次请求」这句话对静态站几乎无效——静态资源请求根本不参与计数。我把官方计费口径拆开，再抓包统计自己博客每次访问的真实请求构成，算出免费额度能撑多少 PV、什么情况下会先撞墙。</description><pubDate>Mon, 05 Oct 2026 14:20:00 GMT</pubDate><content:encoded>&lt;p&gt;「Cloudflare Workers 免费套餐每天 10 万次请求」——这句话几乎每篇介绍 Cloudflare 的文章都会写一遍。但它对一个静态博客来说几乎是无效信息：&lt;strong&gt;静态资源请求压根不参与这个计数&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;真正决定你能撑多少 PV 的，不是总请求数，而是&lt;strong&gt;每次访问里有多少请求会落到 Worker 脚本上&lt;/strong&gt;。这篇文章不搬文档，只做两件事：把免费额度的口径拆清楚，然后用我自己博客的实测数据算一遍。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
前提：本站是 Astro 静态站，部署在 Cloudflare Worker &lt;code&gt;peroe-blog&lt;/code&gt;（纯静态资源模式，&lt;code&gt;wrangler.jsonc&lt;/code&gt; 里只有 &lt;code&gt;assets.directory&lt;/code&gt; 和自定义域，没有 &lt;code&gt;main&lt;/code&gt;、没有 &lt;code&gt;run_worker_first&lt;/code&gt;），Cloudflare 免费套餐，没买 Argo 或 Pro。账号下还跑着另外两个 Worker：&lt;code&gt;cf-umami&lt;/code&gt;（t.juluo.work，浏览量统计 + D1）和 &lt;code&gt;link-card&lt;/code&gt;（icon.juluo.work）。本文所有数字来自公开计费规则 + 本机实测，我没有登录 Cloudflare 后台看计量数据（那需要账号权限，也看不到历史曲线）。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;免费额度的口径：三个数字，只有一个跟静态站有关&lt;/h2&gt;
&lt;p&gt;先看官方定价页给免费套餐列的三项：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/workers-free-tier-cost/01-cloudflare-workers-pricing-free-row.webp&quot; alt=&quot;Cloudflare Workers 定价页：免费套餐每天 10 万次请求、时长不计费、每次调用 10 毫秒 CPU；脚注 3 明确写着静态资源请求免费且不限量&quot; /&gt;&lt;/p&gt;
&lt;p&gt;表格下面那几个小脚注才是真正的规则，比表格本身重要得多：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;脚注 3：&lt;code&gt;Requests to static assets are free and unlimited.&lt;/code&gt;&lt;/strong&gt; —— 静态资源请求免费且不限量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;脚注 1：Cloudflare 不为你 Worker 发出的子请求（subrequests）计费。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;定价页开头还有一句：Workers 不额外收流量（egress）和带宽（throughput）费用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说，「每天 10 万次」这个额度只对&lt;strong&gt;会调用到 Worker 脚本的入站请求&lt;/strong&gt;生效。对纯静态站来说：HTML、CSS、JS、图片、甚至自定义 404 页面，全部走静态资源路径，一次都不计。&lt;/p&gt;
&lt;p&gt;Static Assets 自己的 Billing 页把这条讲得更直白：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/workers-free-tier-cost/02-cloudflare-static-assets-billing.webp&quot; alt=&quot;Cloudflare Static Assets 的 Billing and Limitations 页：请求要么命中静态资源、要么调用 Worker 脚本，前者免费不限量；并警告用了 run_worker_first 之后超限会直接返回 429&quot; /&gt;&lt;/p&gt;
&lt;p&gt;请求要么命中静态资源、要么调用 Worker 脚本，只有后者按 Workers 定价计费；连「存储资产」都不额外收钱。另外还有两条跟套餐无关的平台限制要注意：&lt;strong&gt;每个 Worker 版本最多 20000 个文件、单文件最大 25 MiB&lt;/strong&gt;——把图片全塞进仓库的话，这两条会比请求数更早成为约束。&lt;/p&gt;
&lt;p&gt;还有一点常被忽略：10 万/天是&lt;strong&gt;账号级&lt;/strong&gt;额度，不是每个 Worker 分一份。我账号下三个 Worker 共用同一个池子。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
这里有个容易翻车的例外：一旦用了 &lt;code&gt;run_worker_first&lt;/code&gt;（让 Worker 脚本优先处理请求），匹配到的请求就&lt;strong&gt;一定&lt;/strong&gt;会进脚本、一定计费；免费额度用完之后，这些请求直接返回 &lt;code&gt;429 Too Many Requests&lt;/code&gt;，&lt;strong&gt;不会退回静态资源&lt;/strong&gt;。所以「静态站随便跑」成立的前提，是你的配置里没有 &lt;code&gt;run_worker_first&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;我的站一次访问产生多少请求&lt;/h2&gt;
&lt;p&gt;口径清楚了，接下来是数数。我用 Chrome + Playwright 记录冷缓存加载过程中的全部网络请求（每次都用全新的浏览器 profile，避免本地缓存干扰），分别测了首页和一篇带评论区的文章页：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/workers-free-tier-cost/04-requests-per-pageview-table.webp&quot; alt=&quot;Chrome + Playwright 抓包实测：首页 62 个请求里发往自己站点的 29 个都不经过 Worker 脚本，唯一计费的是发往 t.juluo.work 的那 1 个 /batch&quot; /&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;首页&lt;/th&gt;
&lt;th&gt;文章页&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;总请求数&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;发往 blog.juluo.work&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;发往 t.juluo.work（自建统计）&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;传输体积&lt;/td&gt;
&lt;td&gt;1153.7 KB&lt;/td&gt;
&lt;td&gt;1513.9 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;拆开看：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;发往自己站点的 29 个请求里，没有一个是 Worker 脚本调用&lt;/strong&gt;：1 个 HTML、2 个 CSS、18 个 JS，其余是页面加载后 &lt;code&gt;astro:prefetch&lt;/code&gt; 预取的链接请求（RSS、归档、友链、站点地图这些）。它们全部命中静态资源，按上面的规则不计费，带宽也不计费。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;真正计费的只有 1 个请求&lt;/strong&gt;：&lt;code&gt;POST https://t.juluo.work/batch&lt;/code&gt;。这是主题自带的浏览量排序脚本，首页和文章页各调一次，用来一次性拉回全站和各篇文章的阅读数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;剩下的一大半请求发去了别人家的域名&lt;/strong&gt;：首页 32 个、文章页 57 个。图标走 &lt;code&gt;api.iconify.design&lt;/code&gt;（一次首页 22 个、文章页 28 个 SVG，全站最「话痨」的一环），评论区走 &lt;code&gt;giscus.app&lt;/code&gt;（文章页 17 个请求），统计走 &lt;code&gt;cloud.umami.is&lt;/code&gt;，还有 QQ 头像、Cookie 同意横幅、浏览器升级提示。这些跟我的额度无关——&lt;strong&gt;花的是别人家的钱&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;所以对&lt;strong&gt;我这个站&lt;/strong&gt;来说，结论很干净：&lt;strong&gt;一次浏览 = 1 个 Workers 请求&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;那 10 万/天能撑多少 PV&lt;/h2&gt;
&lt;p&gt;一个除法：&lt;code&gt;100000 ÷ 1 = 100000 PV/天&lt;/code&gt;，约每月 300 万 PV。&lt;/p&gt;
&lt;p&gt;这个量级对个人博客是什么概念？本站上线到现在，自建统计里的浏览量还是 &lt;code&gt;0&lt;/code&gt;（下一节会讲为什么）。哪怕按「一天一千 PV」这种对个人博客已经算热闹的量来算，也只用到额度的 1%。&lt;/p&gt;
&lt;p&gt;但这里有个更重要的推论：&lt;strong&gt;先撞墙的不是博客本体，是统计服务&lt;/strong&gt;。博客本体跑在静态资源模式上，理论上请求数无限；而 t.juluo.work 每被浏览一次就消耗一个额度。真到额度见底那天，博客还能照常打开，只是浏览量数字不动了——这其实是个挺健康的失效模式。&lt;/p&gt;
&lt;p&gt;再往后才是 D1 的账。D1 免费套餐是「每天 500 万行读、10 万行写、5 GB 存储」，跟 Workers 请求数是两套独立池子。如果 &lt;code&gt;POST /send&lt;/code&gt; 每次写入 1 行，那 10 万行写/天对应的正好也是 10 万 PV/天，两块额度会一起见底。真正需要盯的是&lt;strong&gt;行读&lt;/strong&gt;：官方对「行读」的定义是「查询扫描了多少行」，全表扫描时表里有多少行就算多少行。如果 &lt;code&gt;/batch&lt;/code&gt; 随着文章变多退化成扫描，表越大、每次浏览消耗的行读越多，天花板就会从 10 万 PV 一路往下掉。这一条我在外部看不到计量数字（要登录后台看 D1 的 Row Metrics），所以只能当提醒，不当结论。&lt;/p&gt;
&lt;h2&gt;什么情况下会撞墙&lt;/h2&gt;
&lt;p&gt;把「什么时候免费额度不够用」列成一张判断表，比背数字有用：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;你往站里加了什么&lt;/th&gt;
&lt;th&gt;每 PV 计费请求&lt;/th&gt;
&lt;th&gt;理论上限&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;现在这样（纯静态 + 统计 &lt;code&gt;/batch&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;10 万 PV/天&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;真正接上 &lt;code&gt;tracker.js&lt;/code&gt; 上报（每 PV 多 1 次 &lt;code&gt;POST /send&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;5 万 PV/天&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;再加上 &lt;code&gt;tracker.js&lt;/code&gt; 不吃缓存（&lt;code&gt;/send&lt;/code&gt; + &lt;code&gt;tracker.js&lt;/code&gt;）&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3.3 万 PV/天&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;开 &lt;code&gt;run_worker_first&lt;/code&gt;，静态请求也进脚本&lt;/td&gt;
&lt;td&gt;+30 左右&lt;/td&gt;
&lt;td&gt;3 千 PV/天&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;加 SSR 页面 / 实时接口&lt;/td&gt;
&lt;td&gt;每个动态请求 1 个&lt;/td&gt;
&lt;td&gt;看动态请求占比&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;最后两行是重点：&lt;strong&gt;静态站之所以「不花钱」，不是因为请求少，而是因为请求走对了路径&lt;/strong&gt;。一旦引入 SSR、搜索接口、点赞、动态 OG 图这类东西，数字会立刻变得敏感。&lt;/p&gt;
&lt;p&gt;另外两个限制跟容量无关，但会先把你卡住，值得单独记住：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CPU 时间 10 毫秒/次调用&lt;/strong&gt;。注意 CPU 时间只算真正在跑的代码，等 D1、等上游 &lt;code&gt;fetch&lt;/code&gt; 的 I/O 等待不算——所以「查一次数据库」通常不等于「烧掉 CPU」。真正吃 CPU 的是密码学运算、大 JSON 序列化、图片处理这一类。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;超限之后的行为&lt;/strong&gt;，官方在限制页写得很清楚：&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/workers-free-tier-cost/05-cloudflare-daily-limit-1027.webp&quot; alt=&quot;Cloudflare Workers 限制页 Daily requests 段落：免费套餐每天 10 万次请求、UTC 零点重置，超出返回 Error 1027，路由分 Fail open 与 Fail closed 两种行为&quot; /&gt;&lt;/p&gt;
&lt;p&gt;免费套餐的 10 万/天按 UTC 零点重置，超出后返回 &lt;code&gt;Error 1027&lt;/code&gt;；路由配成 Fail open 会绕过 Worker（当作没配 Worker 处理），Fail closed 则直接返回 1027 错误页。对静态站来说这是好消息：静态资源那条路根本不经过这个判断。&lt;/p&gt;
&lt;h2&gt;我踩到的坑&lt;/h2&gt;
&lt;h3&gt;1. 统计接口返回的全是 0&lt;/h3&gt;
&lt;p&gt;我用 curl 直接问自建统计服务：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/workers-free-tier-cost/03-umami-share-batch-curl.webp&quot; alt=&quot;本机 curl 实测：/share 与 /batch 返回的浏览量全是 0，以及 blog.juluo.work 上 282 KB CSS 的响应头和 If-None-Match 重发结果&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 单个路径查询
curl -s &quot;https://t.juluo.work/share?pathname=/&quot;

# 批量查询（正文是裸数组，不是对象）
curl -s -X POST &quot;https://t.juluo.work/batch&quot; \
  -H &quot;Content-Type: application/json&quot; \
  --data-binary &apos;[&quot;/&quot;,&quot;/archive/&quot;]&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;GET /share?pathname=/&lt;/code&gt; 返回 &lt;code&gt;{&quot;pathname&quot;:&quot;/&quot;,&quot;views&quot;:0}&lt;/code&gt;，&lt;code&gt;POST /batch&lt;/code&gt; 返回 &lt;code&gt;[0,0]&lt;/code&gt;，换七八个路径全都是 0。一开始我以为 D1 挂了，查下来是另一回事：&lt;strong&gt;接口是好的，只是没人往里写&lt;/strong&gt;。站点前台引的是 Umami Cloud 的 &lt;code&gt;script.js&lt;/code&gt;，自建的 &lt;code&gt;tracker.js&lt;/code&gt; 没有被任何页面引用——这套自建统计目前「只有读、没有写」，读接口还跑得挺勤。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!TIP]
踩这个坑的时候顺手发现：&lt;code&gt;/batch&lt;/code&gt; 的请求体是&lt;strong&gt;裸 JSON 数组&lt;/strong&gt;（&lt;code&gt;[&quot;/&quot;,&quot;/archive/&quot;]&lt;/code&gt;），不是 &lt;code&gt;{&quot;pathnames&quot;:[...]}&lt;/code&gt;。用对象去请求会返回 &lt;code&gt;{&quot;ok&quot;:false,&quot;error&quot;:&quot;expected_array&quot;}&lt;/code&gt;——错误码其实直说了要数组，但如果你像我一样先写成 &lt;code&gt;{&quot;pathnames&quot;:[...]}&lt;/code&gt;，还是得愣一下才反应过来。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这个坑跟计费直接相关，也是我最想吐槽的一点：&lt;strong&gt;我现在每次浏览都在白花一个请求额度&lt;/strong&gt;——首页那个 &lt;code&gt;/batch&lt;/code&gt; 吭哧读半天，读出来全是 0。修法很简单（把 &lt;code&gt;tracker.js&lt;/code&gt; 引到 Layout 里），但我打算先留着：一是现在没什么读者不值得上报，二是想先观察 D1 行读的增长曲线，等有真实流量了再接。&lt;/p&gt;
&lt;h3&gt;2. 用 &lt;code&gt;curl -I&lt;/code&gt; 探活会误判&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;curl -I https://t.juluo.work/tracker.js&lt;/code&gt;（HEAD 请求）返回 &lt;strong&gt;404&lt;/strong&gt;，而换成 &lt;code&gt;curl -s -D - -o NUL&lt;/code&gt;（GET）返回 &lt;strong&gt;200&lt;/strong&gt;。这个 Worker 只处理 GET，HEAD 直接落到 404 分支。我差点据此判断「tracker.js 不存在」，实际上它好好地在那儿：&lt;code&gt;Content-Length: 801&lt;/code&gt;、&lt;code&gt;Cache-Control: public, max-age=3600&lt;/code&gt;。&lt;strong&gt;探活请用 GET。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;3. 282 KB 的 CSS 每次都会重发一遍&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;/_astro/MainGridLayout.*.css&lt;/code&gt; 有 &lt;strong&gt;288727 字节&lt;/strong&gt;（282 KB），响应头是 &lt;code&gt;Cache-Control: public, max-age=0, must-revalidate&lt;/code&gt;。我拿着 ETag 用 &lt;code&gt;If-None-Match&lt;/code&gt; 再请求一次，返回的仍然是 &lt;code&gt;200 OK&lt;/code&gt; + 完整的 288727 字节，&lt;strong&gt;没有 304&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;从计费角度看这无所谓——静态请求和带宽都免费，多传几次 Cloudflare 也不会多收一分钱。但对国内读者来说，每次站内跳转都要重新拉一遍 282 KB 的 CSS，体验损耗是实打实的。真要优化，方向是给 &lt;code&gt;_astro/*&lt;/code&gt; 加长缓存（Assets 支持 &lt;code&gt;_headers&lt;/code&gt; 文件），而不是去省请求数——&lt;strong&gt;在免费额度这件事上，带宽和静态请求是最不该被优化的东西&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;「每天 10 万次请求」这个口径对静态站基本无效：静态资源请求免费且不限量，带宽也不计费。要数的只有&lt;strong&gt;落到 Worker 脚本上的请求&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;实测本站：首页 62 个请求、文章页 90 个，其中计费的都只有 1 个（自建统计的 &lt;code&gt;/batch&lt;/code&gt;）。理论上限 10 万 PV/天，而且先见底的是统计服务，不是博客本体。&lt;/li&gt;
&lt;li&gt;判断自己的站会不会撞墙，一个式子就够：&lt;code&gt;PV 上限 ≈ 100000 ÷ 每 PV 落到 Worker 的请求数&lt;/code&gt;，分子还要被账号下其它 Worker 分掉。&lt;/li&gt;
&lt;li&gt;最容易翻车的不是流量涨了，而是你往静态站里塞了动态的东西：&lt;code&gt;run_worker_first&lt;/code&gt;、SSR、实时接口。摸清请求路径，比记住额度数字有用得多。&lt;/li&gt;
&lt;li&gt;免费额度之外，先卡住我的更可能是「每个版本 2 万个文件」和「单文件 25 MiB」这两条静态资源平台限制——这一点等配图多起来再单独验证。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;额度之外，另一个常被低谷的成本是流量：&lt;a href=&quot;/posts/blog-cache-layers/&quot;&gt;三层缓存&lt;/a&gt;里把哈希资源改成永久缓存，就是为了少发请求。&lt;/p&gt;
</content:encoded></item><item><title>静态博客怎么显示每篇文章的浏览量：从 D1 读数接口到前端排序的完整链路</title><link>https://blog.peroe.cn/posts/blog-view-counter/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/blog-view-counter/</guid><description>静态站没有后端，文章卡片上的浏览量到底从哪来？这篇拆我自己这套链路：D1 里只有 pathname + views 两列、两个读接口的分工、前端如何一次批量取数并先藏后显避免抖动、列表页按热度排序的索引对齐，以及防刷与缓存口径的取舍。</description><pubDate>Mon, 05 Oct 2026 14:15:00 GMT</pubDate><content:encoded>&lt;p&gt;静态博客最难的不是写页面，是&lt;strong&gt;页面之外的数字&lt;/strong&gt;：每篇文章有多少人看过、全站累计多少。这些数字不在文件系统里，也不在构建产物里——你得先有东西把它写进某个地方，再把它读回来。&lt;/p&gt;
&lt;p&gt;我这套的分工是：&lt;strong&gt;访客画像&lt;/strong&gt;交给 Umami Cloud 的官方脚本（有后台、有地域和设备维度），&lt;strong&gt;页面计数&lt;/strong&gt;用自己写的一套接口（&lt;code&gt;t.juluo.work&lt;/code&gt; + D1 里一张两列的表）——页面引 &lt;code&gt;tracker.js&lt;/code&gt; 往 &lt;code&gt;/send&lt;/code&gt; 报一次，卡片再把 &lt;code&gt;/batch&lt;/code&gt; 读回来渲染。本文按数据流讲一遍：从那两列的表，到文章页 meta 行里的「N 次」，再到列表页点一下眼睛图标就按热度重排。代码都在这个仓库里，接口行为全部用本机 curl 和 Chrome 实测过。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
两处后记，都是同一晚发生的事：&lt;strong&gt;写入端&lt;/strong&gt;（&lt;code&gt;tracker.js&lt;/code&gt;）原本忘了接，见第一篇补记；&lt;strong&gt;数据源&lt;/strong&gt;后来改成优先读 Umami、自建退成兜底，见第二篇补记。正文里过时的说法我都标了「改造前」。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
前置：本站是 Astro 静态站，跑在 Cloudflare Worker 的静态资源模式上（没有 &lt;code&gt;main&lt;/code&gt;、没有服务端逻辑，构建产物直接由边缘节点分发），域名 &lt;code&gt;blog.juluo.work&lt;/code&gt;；计数接口是另一个 Worker &lt;code&gt;cf-umami&lt;/code&gt;（源码在公开仓库 &lt;code&gt;juluowork/cf-umami&lt;/code&gt;），数据存在 D1 的 &lt;code&gt;pageviews&lt;/code&gt; 表。展示侧的开关就两个字段，写在 &lt;code&gt;src/config.ts&lt;/code&gt; 里：&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;// 文章/页面浏览量：自建统计服务（Cloudflare Worker + D1，部署在 t.juluo.work）
export const viewCounterConfig: ViewCounterConfig = {
	enable: true,
	endpoint: &quot;https://t.juluo.work&quot;,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;先看这套东西现在的样子&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-view-counter/01-post-page-view-count.webp&quot; alt=&quot;线上文章页标题下方的 meta 行：发布时间旁边是浏览量图标和「0 次」，这个数字是页面加载后由客户端从 t.juluo.work 读回来的。&quot; /&gt;&lt;/p&gt;
&lt;p&gt;文章页的标题下面那行 meta：发布时间、浏览量图标加一个「0 次」。这个数字不是构建时写死的——&lt;strong&gt;看组件模板就知道&lt;/strong&gt;（下面贴的是 &lt;code&gt;PostMeta.astro&lt;/code&gt; 的源码，不是渲染后的 HTML），它渲染出来就是个空壳，&lt;code&gt;slug&lt;/code&gt; 是文章 id：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div class=&quot;flex items-center&quot; id={`page-views-wrapper-${slug}`} style=&quot;display: none;&quot;&amp;gt;
    &amp;lt;div class=&quot;meta-icon&quot;&amp;gt;
        &amp;lt;Icon name=&quot;material-symbols:visibility-outline-rounded&quot; class=&quot;text-xl&quot;&amp;gt;&amp;lt;/Icon&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;span class=&quot;text-50 text-sm font-medium&quot; id={`page-views-${slug}`}&amp;gt;&amp;lt;/span&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-view-counter/02-home-list-sort-and-site-views.webp&quot; alt=&quot;线上首页：每张文章卡片下面都有一个「0 次」，右上角是按日期／按访问量切换的排序控件，左侧栏的「访问量」显示 0。&quot; /&gt;&lt;/p&gt;
&lt;p&gt;首页同样，每张卡片下挂着一个「0 次」，右上角多了排序控件。&lt;strong&gt;顺便注意侧栏&lt;/strong&gt;：首页有「访问量 0」，文章页没有——这不是设计，是实现细节，放在「踩坑」里说（后来修掉了，见文末补记）。&lt;/p&gt;
&lt;p&gt;真正值得在意的不是那一刻的 0，而是&lt;strong&gt;为什么会是 0&lt;/strong&gt;：读数接口一路通着（下面有实测），却没有任何页面在往计数表里写。本文写作时（2026-10-05 晚）这套系统处于「只读不写」的状态，这个坑当天深夜补上了，改动集中在文末补记里；正文保留的代码与截图是当时的样子，我会在对应位置标出哪些已经变了。&lt;/p&gt;
&lt;h2&gt;为什么不用第三方统计的 API&lt;/h2&gt;
&lt;p&gt;最省事的做法是前端直接去第三方统计的分享页拉数字。我没选，理由都跟「展示」这个场景的特殊性有关：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;分享页是给人看的页面，不是给浏览器 fetch 的接口。&lt;/strong&gt; 它属于统计后台的一部分，渲染方式随时可能变，而文章卡片要长期依赖它的响应结构——绑得越紧越难拆。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;跨域和配额都不在我手里。&lt;/strong&gt; 读数请求要从 &lt;code&gt;blog.juluo.work&lt;/code&gt; 打到别人的域上，失败时我连「为什么失败」都看不到——是 CORS、是限流，还是接口改了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;展示只需要一个整数。&lt;/strong&gt; 来源、设备、停留时长这些留在统计后台看就好，为了一个数字去拉一个通用分析接口，是拿大炮打蚊子。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;自建的代价也很实在：多一个 Worker、多一张表、多一处要维护的东西。但它的&lt;strong&gt;失效模式是干净的&lt;/strong&gt;：接口挂了，博客照常打开，只是数字不显示（我实测过，见后面的失败兜底）。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
这段写于当晚 22 点，两个多小时后我自己把它推翻了：展示的数字改成优先读 Umami 的分享接口，自建那份退成兜底。三条理由本身没被推翻——&lt;strong&gt;变的是多了兜底&lt;/strong&gt;，所以可以冒「接口随时会变」这个险。具体见文末「再补一刀」。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;数据表：只有两列，是刻意的&lt;/h2&gt;
&lt;p&gt;D1 里的建表语句就一行（来自 &lt;code&gt;cf-umami&lt;/code&gt; 仓库的 &lt;code&gt;src/index.ts&lt;/code&gt;，我用 curl 核对过部署版本返回的错误字符串，行为与它一致）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE TABLE IF NOT EXISTS pageviews (pathname TEXT PRIMARY KEY, views INTEGER NOT NULL);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写入也只有一句 UPSERT：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;INSERT INTO pageviews(pathname, views) VALUES(?, 1) ON CONFLICT(pathname) DO UPDATE SET views = views + 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pathname&lt;/code&gt; 直接当主键，&lt;code&gt;views&lt;/code&gt; 就是个整数计数。&lt;strong&gt;没有 IP、没有 UA、没有 session、没有时间戳。&lt;/strong&gt; 这不是偷懒，是取舍：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;好处：表小，查询就是一次主键查，不存任何可识别信息，连「匿名化」这一步都省了。&lt;/li&gt;
&lt;li&gt;代价：UV、来源、停留时长、趋势曲线一概没有，这些交给 Umami。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;另一个直接后果是&lt;strong&gt;路径必须规范化&lt;/strong&gt;。同一个页面只要能通过两个 URL 打开，计数就会裂成两条。我的文章页只有 &lt;code&gt;/posts/&amp;lt;slug&amp;gt;/&lt;/code&gt; 一种形态，工具页统一用组件里写死的 &lt;code&gt;pathname&lt;/code&gt;（比如 &lt;code&gt;/tools/cover/&lt;/code&gt; 这个页面，实际读写的路径是 &lt;code&gt;/cover/&lt;/code&gt;——实测抓到的请求就是 &lt;code&gt;GET /share?pathname=%2Fcover%2F&lt;/code&gt;），所以这件事暂时没炸。&lt;/p&gt;
&lt;h2&gt;接口契约：两个读接口，一个写接口&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-view-counter/03-curl-share-batch.webp&quot; alt=&quot;本机 curl 实测：/share 单读返回 views、/batch 的裸数组请求体与同序返回、传对象时 400，以及 CORS 只对本站来源回 ACAO、/send 对外域来源静默 204。&quot; /&gt;&lt;/p&gt;
&lt;p&gt;三个端点用 curl 跑一遍，下面是线上真实响应：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GET /share?pathname=/posts/xxx/&lt;/code&gt; → &lt;code&gt;{&quot;pathname&quot;:&quot;/posts/xxx/&quot;,&quot;views&quot;:0}&lt;/code&gt;，单个页面读数；参数缺失返回 &lt;code&gt;{&quot;ok&quot;:false,&quot;error&quot;:&quot;invalid_pathname&quot;}&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /batch&lt;/code&gt;，请求体是&lt;strong&gt;裸 JSON 数组&lt;/strong&gt; &lt;code&gt;[&quot;/&quot;,&quot;/posts/xxx/&quot;]&lt;/code&gt;，返回&lt;strong&gt;同序数组&lt;/strong&gt; &lt;code&gt;[0,0]&lt;/code&gt;；传对象进去会被拒：&lt;code&gt;{&quot;ok&quot;:false,&quot;error&quot;:&quot;expected_array&quot;}&lt;/code&gt;（HTTP 400）。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /send&lt;/code&gt;，单个写入，请求体是 &lt;code&gt;{&quot;pathname&quot;:&quot;/&quot;}&lt;/code&gt;，只接受来自本站 Origin/Referer 的请求，其他来源直接 &lt;strong&gt;HTTP 204 静默丢弃&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
Windows PowerShell 会把命令行里内联 JSON 的双引号吃掉。&lt;code&gt;--data-raw &apos;[&quot;/&quot;]&apos;&lt;/code&gt; 实际只发出了 3 个字节，接口回 &lt;code&gt;{&quot;ok&quot;:false,&quot;error&quot;:&quot;invalid_json&quot;}&lt;/code&gt;；把请求体写进文件、用 &lt;code&gt;--data-binary &quot;@file&quot;&lt;/code&gt; 才能正常测通。我第一次测 &lt;code&gt;/batch&lt;/code&gt; 就被它坑了，差点以为接口不认数组。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;为什么读数用 POST 而不是 GET。&lt;/strong&gt; 语义洁癖会说「读操作该用 GET」，但这套接口要一次读 N 个路径：GET 只能靠 &lt;code&gt;?pathname=a&amp;amp;pathname=b&lt;/code&gt; 或者逗号分隔，URL 长度、编码、去重规则全得自己定；POST + JSON 数组能把「顺序」表达得最清楚——&lt;strong&gt;顺序就是契约&lt;/strong&gt;，讲排序时你会看到它有多关键。副作用是 POST 天然不可缓存，每次浏览都真的打一次查询，好处是数字永远新鲜。&lt;/p&gt;
&lt;h2&gt;前端：一次批量读，先藏后显&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-view-counter/04-views-counter-code.webp&quot; alt=&quot;仓库里 src/components/ViewsCounter.astro 的关键代码：wrapper 默认 display:none，fetch /share 成功后写入「N 次」并改成 flex。&quot; /&gt;&lt;/p&gt;
&lt;p&gt;上图是单页读数组件 &lt;code&gt;src/components/ViewsCounter.astro&lt;/code&gt;，用在工具页、课程表这类没有文章卡的页面上：&lt;code&gt;/share&lt;/code&gt; 读一个路径、写进 span、把 wrapper 从 &lt;code&gt;display: none&lt;/code&gt; 改成 &lt;code&gt;flex&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;关键在「藏」这个动作上。&lt;/strong&gt; 组件渲染出来的 wrapper 带 &lt;code&gt;style=&quot;display: none;&quot;&lt;/code&gt;，服务端渲染阶段它就是个不占位的空壳，数字是客户端拿到之后才亮的。如果先渲染一个「0 次」占位、等接口回来再换成「1234 次」，用户会看到数字跳变，数字宽度变化还会把整行 meta 往前推一下——比晚半秒出现难看得多。代价是这一行从无到有时，下面的内容会有一次小位移，但至少&lt;strong&gt;不会有错误的数字被看到&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;实测：工具页整页只发出 &lt;strong&gt;1 个&lt;/strong&gt;请求 &lt;code&gt;GET /share?pathname=%2Fcover%2F&lt;/code&gt;，然后 span 里出现「0 次」。&lt;/p&gt;
&lt;p&gt;文章页和列表页走的是另一条路——批量读。一页最多 8 张卡片（&lt;code&gt;src/constants/constants.ts&lt;/code&gt; 里的 &lt;code&gt;PAGE_SIZE = 8&lt;/code&gt;），每张各发一个 &lt;code&gt;/share&lt;/code&gt; 就是 8 个请求，既慢又费额度。所以这里的机制是「先登记、再一次性取数」——&lt;strong&gt;不是并发发起 N 个请求，而是把 N 个路径压进同一个请求&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;登记这一步做在卡片组件里（&lt;code&gt;src/components/PostMeta.astro&lt;/code&gt; 的内联脚本，页面上有几张卡片就有几份）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;window.__VIEWS_QUEUE__ = window.__VIEWS_QUEUE__ || [];
if (!window.__VIEWS_QUEUE__.includes(slug)) {
    window.__VIEWS_QUEUE__.push(slug);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真正发请求的是独立的读取端 &lt;code&gt;src/scripts/view-counter-runtime.ts&lt;/code&gt;（挂在 &lt;code&gt;Layout.astro&lt;/code&gt; 上，所以每个页面都有，而且每页只跑一次）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const slugs = collectSlugs(); // 列表页读 __PAGE_POSTS_DATA__，其它页面读 __VIEWS_QUEUE__
const pathnames = [&quot;/&quot;, ...slugs.map((slug) =&amp;gt; `/posts/${slug}/`)];
const res = await fetch(`${endpoint}/batch`, {
    method: &quot;POST&quot;,
    headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
    body: JSON.stringify(pathnames),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;请求体&lt;strong&gt;永远以 &lt;code&gt;/&lt;/code&gt; 打头&lt;/strong&gt;，所以不管在哪一页，侧栏那个全站访问量都能顺带取回来。抓包实测：线上首页是 &lt;strong&gt;9 条路径&lt;/strong&gt;（&lt;code&gt;/&lt;/code&gt; 加 8 篇文章），发往 &lt;code&gt;t.juluo.work&lt;/code&gt; 的请求&lt;strong&gt;只有 1 条&lt;/strong&gt;——&lt;code&gt;POST /batch&lt;/code&gt;。代价也在这句话里：只要其中&lt;strong&gt;任意一个&lt;/strong&gt; pathname 不合法，整个 &lt;code&gt;/batch&lt;/code&gt; 就返回 400（实测：数组里混进一个不以 &lt;code&gt;/&lt;/code&gt; 开头的路径，整批请求直接失败），那一页的数字会全部不显示——「全对才给」的严格口径，换来的是前端永远不用处理&quot;部分成功&quot;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
「9 条路径」只是那一刻的快照，别当成固定值：请求体长度永远等于&lt;strong&gt;线上文章数 + 1&lt;/strong&gt;（多出来的 &lt;code&gt;/&lt;/code&gt; 是侧栏那个全站访问量）。文章数一变，这个数字就跟着变。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;列表页按热度排序：索引对齐才是真难点&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-view-counter/05-post-list-sort-code.webp&quot; alt=&quot;改造前的 src/scripts/post-list-sort.ts：取数（第 44-59 行）和排序挤在同一个类里；这段取数现在已经搬去 view-counter-runtime.ts，比较器部分没变。&quot; /&gt;&lt;/p&gt;
&lt;p&gt;列表页的排序完全在客户端做（&lt;code&gt;src/scripts/post-list-sort.ts&lt;/code&gt;）。文章数据在构建时就被塞进页面：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;window.__PAGE_POSTS_DATA__ = pagePostsData;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这段在 &lt;code&gt;src/pages/[...page].astro&lt;/code&gt; 里，&lt;code&gt;pagePostsData&lt;/code&gt; 是 &lt;code&gt;id / title / published / pinned&lt;/code&gt; 四个字段的精简对象。排序脚本拿到它之后一次读完，再靠&lt;strong&gt;下标&lt;/strong&gt;把数字分回去：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;views[0]&lt;/code&gt; 是全站访问量，写进侧栏的 &lt;code&gt;#site-views&lt;/code&gt;——这一步由读取端直接做，排序脚本不掺和；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;views[index + 1]&lt;/code&gt; 才是第 &lt;code&gt;index&lt;/code&gt; 篇文章的量，填进卡片上那个 span，同时通过 &lt;code&gt;post-views-loaded&lt;/code&gt; 事件广播出去，排序脚本收到后存进 &lt;code&gt;viewsData&lt;/code&gt; 这个 Map。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;排序在 &lt;code&gt;getSortedIndices()&lt;/code&gt; 里：日期模式按 &lt;code&gt;published&lt;/code&gt; 排（置顶优先），访问量模式按 Map 里的数字排；&lt;code&gt;render()&lt;/code&gt; 不做 diff，直接把 &lt;code&gt;article&lt;/code&gt; 元素按新顺序 &lt;code&gt;appendChild&lt;/code&gt; 回容器——元素复用，只是顺序变了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这套实现的脆弱点恰恰是它的简洁&lt;/strong&gt;：请求数组、响应数组、&lt;code&gt;__PAGE_POSTS_DATA__&lt;/code&gt; 三个顺序必须完全一致。任何一边多一个、少一个、乱一个，数字就会串到别的文章上，而且&lt;strong&gt;不会报错&lt;/strong&gt;，你只会觉得数字&quot;有点怪&quot;。换成对象数组（&lt;code&gt;[{pathname, views}]&lt;/code&gt;）当然更稳，但接口契约已经定了，眼下改它的收益不值这个工。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/blog-view-counter/06-home-sorted-by-views.webp&quot; alt=&quot;把 /batch 的响应体替换成模拟数据后（页面代码未改），列表页按访问量排序的真实表现：6 张卡片排成 500/300/200/100/50/10，侧栏全站访问量 1234。&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这里有个尴尬的现实：写作当时线上所有计数都是 0，点「按访问量排序」看不出任何变化——每个比较都返回 0，而 V8 的 &lt;code&gt;sort&lt;/code&gt; 是稳定的，顺序原样保留（只有按钮的 &lt;code&gt;active&lt;/code&gt; 样式变了）。为了验证逻辑本身，我把 &lt;strong&gt;&lt;code&gt;/batch&lt;/code&gt; 的响应体在浏览器里换成了模拟数据&lt;/strong&gt;（页面代码一行没改，也没有往 D1 写任何东西）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;6 张卡片（就是当时首页那几张）按模拟计数从大到小重排：500 / 300 / 200 / 100 / 50 / 10，侧栏全站数变成 1234；&lt;/li&gt;
&lt;li&gt;再点一次顺序按钮（「倒序」→「正序」），顺序整个反过来：10 / 50 / 100 / 200 / 300 / 500。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也就是说批量取数、下标映射、排序三件事都是通的。&lt;strong&gt;唯一没验证的是真实非零数据&lt;/strong&gt;——那需要有人往表里写，而这正是当时缺的那一环；接入上报之后我用真实数据又跑了一遍，见文末补记。&lt;/p&gt;
&lt;h2&gt;防刷与缓存口径&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;防刷。&lt;/strong&gt; 先说清楚：这套守卫&lt;strong&gt;不是防刷，是防误写&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;读接口（&lt;code&gt;/share&lt;/code&gt;、&lt;code&gt;/batch&lt;/code&gt;）完全公开，任何人 curl 都能拿到数字。它只做了 CORS 白名单：带外域 Origin 请求时响应里根本没有 &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;（实测），浏览器会拦掉读取——但这拦不住 curl。&lt;/li&gt;
&lt;li&gt;写接口 &lt;code&gt;/send&lt;/code&gt; 会检查 Origin/Referer 的 host 是否等于配置的目标站点，不等就 204 静默丢弃。我拿一个没人访问的路径试过：带外域 Origin 时返回 204，随后用 &lt;code&gt;/share&lt;/code&gt; 查该路径仍然是 &lt;code&gt;views: 0&lt;/code&gt;，说明它确实没写进去。它拦的是「别的站把上报误打过来」，同样拦不住伪造 Origin 的脚本。&lt;/li&gt;
&lt;li&gt;真要防刷得靠边缘的 WAF / 限速规则。而我这里是&lt;strong&gt;读接口被刷&lt;/strong&gt;，代价只是 D1 的行读，真正要担心的是有人拿它刷用量，不是数据被篡改。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;缓存。&lt;/strong&gt; 实测两个读接口的响应头里&lt;strong&gt;都没有 &lt;code&gt;Cache-Control&lt;/code&gt;&lt;/strong&gt;（我用 &lt;code&gt;Select-String&lt;/code&gt; 过滤响应头，一条都过滤不出来），而 POST 本身也不可缓存。所以每次浏览都会实打实打一次查询：数字永远新鲜，也意味着没有「缓存穿透」这类问题需要额外机制去补。&lt;/p&gt;
&lt;p&gt;顺带一个反面样本：上报脚本 &lt;code&gt;/tracker.js&lt;/code&gt; 是&lt;strong&gt;有缓存&lt;/strong&gt;的（&lt;code&gt;Cache-Control: public, max-age=3600&lt;/code&gt;），而它当时在页面上&lt;strong&gt;一次都没被引用&lt;/strong&gt;——我 curl 了首页、文章页、归档、友链、赞助、隐私、工具几个页面，&lt;code&gt;tracker.js&lt;/code&gt; 出现次数都是 0。所以那会儿的状态是：读接口跑得很勤，写接口准备好了但没人调用。写接口接上去之后，缓存反而是好事：脚本每页只下载一次的量级，被浏览器缓存住就不会反复消耗请求额度。&lt;/p&gt;
&lt;h2&gt;踩坑与注意事项&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;文章页看不到侧栏的「全站访问量」，首页能看到。&lt;/strong&gt; 因为全站数字是排序脚本顺带取回来的（&lt;code&gt;views[0]&lt;/code&gt;），而排序脚本只在列表页加载；文章页只有 PostMeta，它只取自己那一条。实测：首页 &lt;code&gt;#site-views-wrapper&lt;/code&gt; 的 &lt;code&gt;display&lt;/code&gt; 是 &lt;code&gt;grid&lt;/code&gt;，文章页是 &lt;code&gt;none&lt;/code&gt;。同一个侧栏组件，两个页面表现不一致。&lt;strong&gt;（已修：读取端统一后，任何页面都会把 &lt;code&gt;/&lt;/code&gt; 带上，见补记。）&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;0 数据下的排序&quot;看起来失效&quot;。&lt;/strong&gt; 点按钮时 &lt;code&gt;active&lt;/code&gt; 样式会变（说明事件绑上了），但顺序纹丝不动，因为比较器对所有组合都返回 0。&lt;strong&gt;测排序逻辑不能只看线上表现&lt;/strong&gt;，得先把数据换成非零的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;接口挂了要能安静地不显示。&lt;/strong&gt; 我把 &lt;code&gt;/batch&lt;/code&gt; 分别换成 500 和「200 + 空响应体」测了一遍：列表页、文章页都不显示任何数字，页面其他部分完全正常，控制台的 &lt;code&gt;pageerror&lt;/code&gt; 里也没有浏览量相关的报错。空响应体当时靠一句 &lt;code&gt;throw new Error(&apos;Empty response&apos;)&lt;/code&gt; 拦下，统一读取端之后换成了 &lt;code&gt;Array.isArray(views)&lt;/code&gt; 检查。这个「宁可不显示，也不显示错的」兜底是免费的——因为渲染前就检查了 &lt;code&gt;res.ok&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/batch&lt;/code&gt; 是 N 次串行查询。&lt;/strong&gt; Worker 源码里就是一个 &lt;code&gt;for&lt;/code&gt; 循环，每个 pathname 单独 &lt;code&gt;SELECT&lt;/code&gt; 一次，没有用 &lt;code&gt;IN&lt;/code&gt; 一次查回来；而且每次请求进来还会先跑一遍 &lt;code&gt;CREATE TABLE IF NOT EXISTS&lt;/code&gt;。写作时首页那 7 个路径就是 7 次串行查询。现在文章少看不出差别，等文章上百篇，这就是最该先改的一处（一次 &lt;code&gt;IN&lt;/code&gt; 查询 + 把建表检查挪出热路径）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;与本文无关但顺手发现的一个报错&lt;/strong&gt;：线上页面控制台里一直有 &lt;code&gt;cookieConsentConfig is not defined&lt;/code&gt;（Cookie 同意横幅那个脚本）。它不影响浏览量，但它说明客户端脚本的错误最容易长期潜伏——因为很少有人天天开着控制台看自己的博客。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;收尾：给想抄这套的人一份清单&lt;/h2&gt;
&lt;p&gt;判断先放在前面：&lt;strong&gt;静态博客要显示浏览量，下限方案是「一个只读接口 + 一次批量读 + 服务端渲染留空位」，而不是去引第三方统计的公开接口。&lt;/strong&gt; 展示和统计是两件事，混在一起做，哪天统计服务改个接口，你就得跟着改文章卡片。&lt;/p&gt;
&lt;p&gt;但更值得记住的是这条：&lt;strong&gt;显示链路和写入链路是两件独立的事。&lt;/strong&gt; 我这套当时读得又快又稳，数字却全是 0——因为没有页面上报。它不是读数接口的毛病，是「先把读的那半截跑通、写的那半截还没接」的状态；接上 &lt;code&gt;tracker.js&lt;/code&gt; 会让每次 PV 多 1 个请求额度（这笔账在&lt;a href=&quot;/posts/workers-free-tier-cost/&quot;&gt;上一篇&lt;/a&gt;里算过），所以我先留着了——留到当天深夜，看着满屏 0 实在难受，还是接了，过程见下面的补记。&lt;strong&gt;所以：如果你在自己站点上看到每篇都是 0 次，先别怀疑接口，去抓一次包看有没有 &lt;code&gt;/send&lt;/code&gt;。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;要抄的话，按这个顺序做：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;定一张两列的表（&lt;code&gt;pathname&lt;/code&gt; 主键 + &lt;code&gt;views&lt;/code&gt; 计数），别急着存 UA/IP——那些需求九成不会实现，真要实现也该换一套表。&lt;/li&gt;
&lt;li&gt;接口分三个：单读（&lt;code&gt;/share&lt;/code&gt;）、批量读（&lt;code&gt;/batch&lt;/code&gt;，请求体就用裸数组，&lt;strong&gt;顺序即契约&lt;/strong&gt;）、写（&lt;code&gt;/send&lt;/code&gt;，带 Origin 守卫并按需静默丢弃）。&lt;/li&gt;
&lt;li&gt;前端先渲染空壳（&lt;code&gt;display: none&lt;/code&gt;），拿到数据再亮——不要先渲染 0 再跳数。&lt;/li&gt;
&lt;li&gt;一页上多个组件要读数时，用「队列登记 + 一个统一读取端」，别让每个组件各自发请求，也别让「只有某个页面才取得到某个数字」。&lt;/li&gt;
&lt;li&gt;依赖下标的排序逻辑，一定要用非零的模拟数据测一遍，别拿线上的全 0 当&quot;测过了&quot;。&lt;/li&gt;
&lt;li&gt;失败路径要测全：500、空响应体、CORS 被拦，三种情况下页面都应该安静地不显示数字。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;读和写都要验&lt;/strong&gt;：读接口返回 0 不等于&quot;接口坏了&quot;，先 curl 一次写接口，确认是「写不进去」还是「没人写」。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;补记（2026-10-05 深夜）：把写入端接上，顺手统一了读取端&lt;/h2&gt;
&lt;p&gt;上面这篇发出不到两小时，我就把「只读不写」这半截补上了——满屏的 0 摆在首页上，比多花一个请求额度难受得多。动手之后才发现问题不止一个。&lt;/p&gt;
&lt;h3&gt;现象与定位&lt;/h3&gt;
&lt;p&gt;先用 curl 把两半截分开验：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 读：接口活着，只是表里没数据
curl -s &quot;https://t.juluo.work/share?pathname=/posts/hello-world/&quot;
# {&quot;pathname&quot;:&quot;/posts/hello-world/&quot;,&quot;views&quot;:0}

# 写：拿本站 Origin 手动打一次，立刻就进去了
curl -s -X POST &quot;https://t.juluo.work/send&quot; \
  -H &quot;content-type: application/json&quot; -H &quot;Origin: https://blog.juluo.work&quot; \
  -d &apos;{&quot;pathname&quot;:&quot;/__selftest&quot;}&apos;
# {&quot;ok&quot;:true}
curl -s &quot;https://t.juluo.work/share?pathname=/__selftest&quot;
# {&quot;pathname&quot;:&quot;/__selftest&quot;,&quot;views&quot;:1}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;服务端两半截都是好的，缺的是「让浏览器去调它」。&lt;/strong&gt; Chrome 抓包也印证了这一点：每个页面只发出 1 条 &lt;code&gt;POST /batch&lt;/code&gt;（读），&lt;code&gt;/send&lt;/code&gt; 一条都没有。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
&lt;code&gt;pathname&lt;/code&gt; 是我自己写的测试路径，它会永久留在 D1 里（这张表只增不减）。清理命令是 &lt;code&gt;npx wrangler d1 execute cf-umami --remote --command &quot;DELETE FROM pageviews WHERE pathname LIKE &apos;/__selftest%&apos;&quot; -y&lt;/code&gt;，需要 Cloudflare 登录态。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;改动一：把 tracker.js 引进页面&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;src/components/layout/BodyThirdPartyScripts.astro&lt;/code&gt; 里加一行，跟着 &lt;code&gt;viewCounterConfig&lt;/code&gt; 走：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{viewCounterConfig.enable &amp;amp;&amp;amp; viewCounterConfig.endpoint.trim() !== &quot;&quot; &amp;amp;&amp;amp; (
	&amp;lt;script is:inline defer src={`${viewCounterConfig.endpoint.replace(/\/+$/, &quot;&quot;)}/tracker.js`}&amp;gt;&amp;lt;/script&amp;gt;
)}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;tracker.js&lt;/code&gt; 自己会用 &lt;code&gt;document.currentScript.src&lt;/code&gt; 推出 &lt;code&gt;t.juluo.work&lt;/code&gt;，再按 &lt;code&gt;location.pathname&lt;/code&gt; 报一次，不需要额外参数。实测现在每次页面浏览固定 3 个请求：&lt;code&gt;GET /tracker.js&lt;/code&gt;（可缓存 1 小时）、&lt;code&gt;POST /send&lt;/code&gt;、&lt;code&gt;POST /batch&lt;/code&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
下面两小节（改动二、实测）写的是&lt;strong&gt;第一版&lt;/strong&gt;：读取端只问自建接口。同一夜更晚的那次改造把数据源换成了「优先 Umami、自建兜底」，读取端结构没变，但请求数从 3 条变成了 &lt;code&gt;tracker.js + /send + (1 + N) 条 Umami 查询&lt;/code&gt;，细节见文末「再补一刀」。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;改动二：读取端收拢成一个脚本&lt;/h3&gt;
&lt;p&gt;原来取数逻辑散在两处（&lt;code&gt;PostMeta.astro&lt;/code&gt; 的内联脚本 + &lt;code&gt;src/scripts/post-list-sort.ts&lt;/code&gt;），靠 &lt;code&gt;__VIEWS_FETCHED__&lt;/code&gt; 和 &lt;code&gt;__PAGE_POSTS_DATA__&lt;/code&gt; 两个全局标志互相让位。能用，但有个硬伤：&lt;strong&gt;&lt;code&gt;/&lt;/code&gt; 这个路径只有排序脚本会拼进去&lt;/strong&gt;，而排序脚本只挂在列表页——所以文章页、归档页的侧栏「访问量」永远是隐藏的，同一个组件在不同页面表现不一致。&lt;/p&gt;
&lt;p&gt;现在收拢成 &lt;code&gt;src/scripts/view-counter-runtime.ts&lt;/code&gt;：卡片只往 &lt;code&gt;__VIEWS_QUEUE__&lt;/code&gt; 里登记 slug，读取端每页跑一次、请求体固定以 &lt;code&gt;/&lt;/code&gt; 开头；回来之后 &lt;code&gt;views[0]&lt;/code&gt; 写侧栏，其余按 slug 填卡片，最后广播一个 &lt;code&gt;post-views-loaded&lt;/code&gt; 事件给排序脚本消费。排序脚本里那段 fetch 和「数据没到就 &lt;code&gt;setInterval&lt;/code&gt; 轮询」的代码也一起删了，它现在只管排序。（&lt;code&gt;__VIEWS_FETCHED__&lt;/code&gt; 这个标志当天夜里也一并删掉了——收拢之后没有任何地方读它，留着就是死代码。）&lt;/p&gt;
&lt;h3&gt;实测（本机构建 + Chrome 抓包，第一版：读取只走自建接口）&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;页面&lt;/th&gt;
&lt;th&gt;发往 &lt;code&gt;t.juluo.work&lt;/code&gt; 的请求&lt;/th&gt;
&lt;th&gt;侧栏 &lt;code&gt;#site-views&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;首页 &lt;code&gt;/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/tracker.js&lt;/code&gt;、&lt;code&gt;/send {&quot;pathname&quot;:&quot;/&quot;}&lt;/code&gt;、&lt;code&gt;/batch [&quot;/&quot;, 8 篇文章]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;显示&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文章页 &lt;code&gt;/posts/xxx/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/tracker.js&lt;/code&gt;、&lt;code&gt;/send {&quot;pathname&quot;:&quot;/posts/xxx/&quot;}&lt;/code&gt;、&lt;code&gt;/batch [&quot;/&quot;, &quot;/posts/xxx/&quot;]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;显示（改造前是 &lt;code&gt;display: none&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;归档页 &lt;code&gt;/archive/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/tracker.js&lt;/code&gt;、&lt;code&gt;/send {&quot;pathname&quot;:&quot;/archive/&quot;}&lt;/code&gt;、&lt;code&gt;/batch [&quot;/&quot;]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;显示（改造前连请求都没有）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;上报用的 pathname 和读接口用的 key 完全一致，都取自带尾斜杠的 &lt;code&gt;location.pathname&lt;/code&gt;——这点值得特意确认一次：不带斜杠的 &lt;code&gt;/posts/hello-world&lt;/code&gt; 会被静态资源 307 跳到带斜杠的地址，浏览器里最终就是带斜杠的形态，两边天然对齐。&lt;/p&gt;
&lt;p&gt;再把 &lt;code&gt;/batch&lt;/code&gt; 的响应体换成非零数据跑一遍排序：8 张卡片按 85 / 78 / 78 / 78 / 72 / 36 / 16 / 2 从大到小重排，再点一次顺序按钮整个反过来，和前面用模拟数据得到的结论一致。&lt;/p&gt;
&lt;h3&gt;遗留&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;侧栏那个「访问量」是首页 &lt;code&gt;/&lt;/code&gt; 的 PV，不是全站所有路径求和。&lt;/strong&gt; 想要真·全站合计，得给 Worker 加一个 &lt;code&gt;SUM(views)&lt;/code&gt; 的接口——那是另一个仓库，这套前端改不动它。（&lt;strong&gt;这条下一节就绕过去了&lt;/strong&gt;：改用 Umami 之后，全站数字直接读它的总 PV。）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/batch&lt;/code&gt; 仍是 N 次串行查询&lt;/strong&gt;（踩坑 4），文章上百篇之前得先改成一次 &lt;code&gt;IN&lt;/code&gt; 查询。&lt;/li&gt;
&lt;li&gt;踩坑 5 那个 &lt;code&gt;cookieConsentConfig is not defined&lt;/code&gt; 还在：它跟浏览量无关，但确实说明客户端报错最容易长期潜伏——毕竟没人天天开着自己的控制台。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;再补一刀（同一夜更晚）：数据源改成「优先 Umami，自建退成兜底」&lt;/h2&gt;
&lt;p&gt;上面那套修完，数字确实是真的了，但全是&lt;strong&gt;从零开始&lt;/strong&gt;的真：D1 里第一行数据诞生于今晚，而 Umami 从 2025 年 8 月就在记这个站。读者点开一篇文章看到「1 次」，不会觉得「这站刚上线」，只会觉得坏了。&lt;/p&gt;
&lt;p&gt;所以数据源换了个顺序：&lt;strong&gt;先读 Umami，读不到再用自建的 D1 补&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;先说清楚：这篇前面写过「不用第三方统计的 API」&lt;/h3&gt;
&lt;p&gt;第三节我列了三条理由不用它。现在等于自己打脸，所以把话说透——&lt;strong&gt;这次能用，是因为兜底还在&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;我用的不是统计后台的通用分析接口，而是&lt;strong&gt;分享页自己在用的那两个只读接口&lt;/strong&gt;，拿回来的就是两个整数（&lt;code&gt;pageviews&lt;/code&gt;），没有来源、设备、地区这些维度；&lt;/li&gt;
&lt;li&gt;shareId 本来就是公开的（导航栏「统计」直接指向分享页），不需要任何密钥、不泄露账号；&lt;/li&gt;
&lt;li&gt;最关键的一条：&lt;strong&gt;它随时可能变&lt;/strong&gt;。这是无文档的内部接口，参数是我照着分享页抓包 + 对着 umami 源码对出来的。所以 D1 那条链路一行不删，&lt;code&gt;tracker.js&lt;/code&gt; 继续上报——Umami 挂了，数字照常显示，只是换成自建那份。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;怎么读：一条分享 token + 两个请求头&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;GET  {gateway}/api/share/{shareId}                      -&amp;gt; {&quot;websiteId&quot;:&quot;842d…&quot;,&quot;token&quot;:&quot;&amp;lt;JWT&amp;gt;&quot;}
GET  {gateway}/api/websites/{websiteId}/stats?startAt=0&amp;amp;endAt=&amp;lt;毫秒&amp;gt;          -&amp;gt; 全站全时段 PV
GET  {gateway}/api/websites/{websiteId}/stats?…&amp;amp;path=eq.%2Fposts%2Fxxx%2F     -&amp;gt; 该路径全时段 PV
     header: x-umami-share-token: &amp;lt;token&amp;gt;      # 10 分钟有效
     header: x-umami-share-context: &amp;lt;shareId&amp;gt;  # 少这个一律 401
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个坑都是抓包抓出来的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;少了 &lt;code&gt;x-umami-share-context&lt;/code&gt; 就是 401。&lt;/strong&gt; 我一开始只带了 token，怎么调都是 &lt;code&gt;{&quot;error&quot;:{&quot;message&quot;:&quot;Unauthorized&quot;}}&lt;/code&gt;，换成浏览器里发也一样。翻 umami 源码才看到那段判断：拿到了 share token 还要检查分享上下文头，否则打日志 &lt;code&gt;Share token used outside share context&lt;/code&gt; 直接拒掉——它防的是把别处签发的 token 拿来读分析数据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;网关是分区的。&lt;/strong&gt; 后台在 &lt;code&gt;cloud.umami.is/analytics/us/&lt;/code&gt;，数据接口却在 &lt;code&gt;gateway-us.umami.is&lt;/code&gt;。换区部署的话这个域名要跟着换，&lt;code&gt;src/config.ts&lt;/code&gt; 里的 &lt;code&gt;umamiConfig.shareApiBase&lt;/code&gt; 就是给它留的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;网关填错不会报错，只会静默全 0。&lt;/strong&gt; 这条是事后审计时才补测的：同一个 shareId，&lt;code&gt;gateway-us&lt;/code&gt; 和 &lt;code&gt;gateway-eu&lt;/code&gt; &lt;strong&gt;都能换到 token&lt;/strong&gt;（这个接口跨区），但数据只认网站所在的区——查到别的区返回的是 &lt;strong&gt;HTTP 200 + 全 0&lt;/strong&gt;，不是错误码。而 &lt;code&gt;0&lt;/code&gt; 在前端眼里是合法数字，所以它不会触发兜底，页面就安安静静显示 0。换区时务必打开自己的分享页、在 Network 里核对域名，别凭记忆填。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;代码怎么改的：一个读取端，两个数据源，按缺口回落&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;src/scripts/view-counter-runtime.ts&lt;/code&gt; 现在做三件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;收集本页要显示的路径（文章 slug + &lt;code&gt;ViewsCounter&lt;/code&gt; 登记的任意路径，比如 &lt;code&gt;/cover/&lt;/code&gt;）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;并发&lt;/strong&gt;问 Umami：全站 1 条、每个路径 1 条；token 缓存在 &lt;code&gt;sessionStorage&lt;/code&gt;（8 分钟），401 时丢掉重取一次；&lt;/li&gt;
&lt;li&gt;只把 Umami &lt;strong&gt;没给到数字&lt;/strong&gt;的那些路径，凑成一个数组发给自建 &lt;code&gt;POST /batch&lt;/code&gt; 补齐。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;合并时是 &lt;code&gt;{...自建, ...Umami}&lt;/code&gt;——&lt;strong&gt;Umami 优先，自建只填缺口，不做加法&lt;/strong&gt;。两边数的是同一批访问，加起来就重复了；这也意味着 D1 里那条从零开始的曲线不会污染 Umami 的历史数字。&lt;/p&gt;
&lt;p&gt;顺便把侧栏的体感调了一下：全站那一次请求单独接出来先画，不用等最慢的那篇文章。&lt;/p&gt;
&lt;h3&gt;实测（本机构建 + Chrome 抓包，全是线上真数据）&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;场景&lt;/th&gt;
&lt;th&gt;结果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;首页&lt;/td&gt;
&lt;td&gt;侧栏 &lt;strong&gt;2229&lt;/strong&gt;（Umami 全时段 PV，+2.2s 出现）；8 张卡片 +3.5s 全部亮起：12 / 12 / 7 / 1 / 1 / 1 / 0 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文章页&lt;/td&gt;
&lt;td&gt;侧栏 2229 + 该文 PV，只有 2 条 Umami 请求（全站 + 本篇）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;掐断 &lt;code&gt;gateway-us.umami.is&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;自动回落到 &lt;code&gt;POST /batch [&quot;/&quot;, &quot;/posts/…&quot;]&lt;/code&gt;，栏目照常显示（我用 111 的假数据肉眼确认回落生效）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;强制所有 stats 返回 401&lt;/td&gt;
&lt;td&gt;换 token 重试一次仍失败 → 回落自建接口，页面无报错、数字不跳 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;有一处得说清楚：&lt;strong&gt;Umami 那个站是 2025-08 建的，路径表里还留着 &lt;code&gt;/peroe&lt;/code&gt;、&lt;code&gt;/posts/fullstack-ssr&lt;/code&gt; 这类旧站路径&lt;/strong&gt;，所以全站 2200+ 里有一部分不是现在这个站的访问。介意的话在 Umami 后台重置统计数据，或者把侧栏改成只认某段时间。&lt;/p&gt;
&lt;h3&gt;更新后的遗留&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;每页要打 N+1 个请求给 Umami&lt;/strong&gt;（首页 9 条：全站 1 + 8 篇），比自建那一次 &lt;code&gt;/batch&lt;/code&gt; 重；换来的是「数字和 Umami 后台对得上」。要降请求数，可以换成一条 &lt;code&gt;metrics?type=path&amp;amp;limit=100&lt;/code&gt; 拿全部路径——但那个口径是**访客数（UV）**不是浏览量（PV），和卡片上的「N 次」对不上，所以我没选。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数字出得慢一点&lt;/strong&gt;：Umami 这边冷启动约 2～4 秒才亮全（自建是 200 毫秒级）。数字是「先藏后显」的，所以代价只是晚一点出现，不会先给个错的。&lt;/li&gt;
&lt;li&gt;自建那份现在只剩兜底价值，但&lt;strong&gt;必须留着&lt;/strong&gt;：&lt;code&gt;tracker.js&lt;/code&gt; 继续上报，Umami 一变脸它立刻顶上。&lt;/li&gt;
&lt;li&gt;踩坑 5 那个 &lt;code&gt;cookieConsentConfig is not defined&lt;/code&gt; 依然在。&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>中间跳丢包 90% 需要管吗：一次 traceroute 误读的完整拆解</title><link>https://blog.peroe.cn/posts/traceroute-packet-loss-misread/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/traceroute-packet-loss-misread/</guid><description>给博客跑路由追踪，中间好几跳丢包率 87%~99%，看着像骨干网炸了，站点却照样能开。本文用 tcptest.cn 的多节点数据和本机实测，讲清 ICMP 限速、控制平面与转发平面的区别，并给出一张&quot;什么时候该管&quot;的判据表。</description><pubDate>Mon, 05 Oct 2026 14:05:00 GMT</pubDate><content:encoded>&lt;p&gt;上一篇&lt;a href=&quot;/posts/blog-ping-tcping-checkup/&quot;&gt;给博客做全国 Ping 体检&lt;/a&gt;测出：Cloudflare 免费套餐在国内访问平均 233ms。这次我想知道得更细一点——这 200 多毫秒，到底花在路径上的哪些路由器了？&lt;/p&gt;
&lt;p&gt;于是我在 tcptest.cn 上跑了一次从福建泉州电信到 &lt;code&gt;blog.juluo.work&lt;/code&gt; 的在线路由追踪。结果第一眼挺吓人：&lt;strong&gt;中间连续几跳的丢包率是 99%、87%、97%&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;只看这三个数字，结论应该是&quot;电信骨干网崩了&quot;。可博客当时打开得好好的，多节点 Tcping 也显示 443 端口正常（数据见上一篇）。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
本文数据均为 2026-10-05 晚实测：多节点数据来自 tcptest.cn 的在线路由追踪（福建泉州电信节点，每跳 100 个探测包）；本机数据来自 Windows 上的 &lt;code&gt;tracert&lt;/code&gt;、&lt;code&gt;ping&lt;/code&gt; 和一段 TCP 连接测试脚本。所有截图都来自真实执行结果。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;那张追踪表到底显示了什么&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/traceroute-packet-loss-misread/01-tcptest-traceroute.webp&quot; alt=&quot;tcptest.cn 在线路由追踪结果：福建泉州电信到 blog.juluo.work，第 11 至 14 跳显示 87%~99% 丢包，末跳只有 10%&quot; /&gt;&lt;/p&gt;
&lt;p&gt;把关键几跳摘出来（丢包率一列是按 100 个探测包统计的）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;跳&lt;/th&gt;
&lt;th&gt;IP&lt;/th&gt;
&lt;th&gt;归属&lt;/th&gt;
&lt;th&gt;丢包率&lt;/th&gt;
&lt;th&gt;平均延迟&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;117.24.4.1&lt;/td&gt;
&lt;td&gt;中国福建泉州/电信&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;10.254.94.5&lt;/td&gt;
&lt;td&gt;运营商内网&lt;/td&gt;
&lt;td&gt;14%&lt;/td&gt;
&lt;td&gt;2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;117.24.2.161&lt;/td&gt;
&lt;td&gt;泉州电信 AS133776&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;7ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;27.152.191.5&lt;/td&gt;
&lt;td&gt;泉州电信 AS133776&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;202.97.57.145&lt;/td&gt;
&lt;td&gt;骨干网/电信 AS4134&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;87%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;21ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;202.97.12.206&lt;/td&gt;
&lt;td&gt;骨干网/电信 AS4134&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;97%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;202.97.59.62&lt;/td&gt;
&lt;td&gt;骨干网/电信 AS4134&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;td&gt;207ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;81.173.18.74&lt;/td&gt;
&lt;td&gt;欧洲/电信 AS4134&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;td&gt;215ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;104.21.6.247&lt;/td&gt;
&lt;td&gt;Cloudflare AS13335（目标）&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;196ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这张表里有三处&quot;自相矛盾&quot;的地方，正是它们救了这次判断：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;丢包 99% 的那一跳，延迟反而特别漂亮&lt;/strong&gt;。第 11 跳最快 8ms、最慢 8ms、平均 8ms；第 14 跳更整齐，四个值全是 24ms。链路真丢包时伴随的是排队和延迟抖动，不会是&quot;漏回来的包一个比一个准时&quot;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第 12 跳 100% 丢包，可第 13、14 跳还在回话&lt;/strong&gt;。如果路径真在第 12 跳断了，后面的跳不可能有回包。100% 只代表&quot;这台设备压根不打算理你&quot;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;末跳只丢 10%&lt;/strong&gt;。第 19 跳的 IP &lt;code&gt;104.21.6.247&lt;/code&gt; 就是目标本身，它丢 10%、平均 196ms——站点是通的。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;真正值得留意的其实是第 15 跳：延迟从 24ms 一步跳到 207ms，而且是离开电信骨干网（AS4134）之后。这不是丢包，是&lt;strong&gt;出境绕路&lt;/strong&gt;——报文从泉州走到电信国际出口，再绕经电信的境外节点（表里标注为&quot;欧洲/电信&quot;，地理位置仅供参考），最后交给 Cloudflare 的 Anycast 边缘。上一篇测到的&quot;全国平均 233ms&quot;就是这么来的：绕路，而不是链路损坏。&lt;/p&gt;
&lt;h2&gt;为什么中间跳会&quot;丢包&quot;：控制平面 vs 转发平面&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/traceroute-packet-loss-misread/02-plane-diagram.webp&quot; alt=&quot;控制平面与转发平面示意：转发平面用 ASIC 线速转发真实流量，控制平面用 CPU 生成 ICMP 并被限速&quot; /&gt;&lt;/p&gt;
&lt;p&gt;要读懂这些数字，得先接受一个事实：&lt;strong&gt;路由器不是一台电脑，它至少有两套干活的方式。&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;转发平面（Forwarding Plane）&lt;/strong&gt;：拆包头、查路由表、把包从某个口扔出去。这件事由专用芯片（ASIC / NP）完成，线速运行，你的网页请求、API 调用、视频流全走这条路。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;控制平面（Control Plane）&lt;/strong&gt;：跑路由协议、维护转发表，以及处理各种&quot;需要 CPU 想一想&quot;的杂事。&lt;strong&gt;生成 ICMP 错误报文，就属于这一类。&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;traceroute 的原理是发出 TTL 依次为 1、2、3… 的探测包，逼路径上第 N 台路由器把 TTL 减到 0，然后&lt;strong&gt;由它生成一个 ICMP Time Exceeded 报文&lt;/strong&gt;（&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc792&quot;&gt;RFC 792&lt;/a&gt; 定义）回给你。关键在于：这个 ICMP 属于&quot;额外劳动&quot;，和转发你的数据没有任何关系。&lt;/p&gt;
&lt;p&gt;而一台骨干路由器面对的是整个互联网。全世界每时每刻都有人在探测它，如果它对每个 TTL 超时的包都老老实实生成 ICMP，CPU 会被这种毫无价值的活打满。所以运营商和设备厂商的通行做法是&lt;strong&gt;给 ICMP 生成限速&lt;/strong&gt;：Cisco 上有 &lt;code&gt;ip icmp rate-limit unreachable&lt;/code&gt; 这类命令，运营商侧普遍部署 CoPP（Control Plane Policing）策略；IETF 的 &lt;a href=&quot;https://datatracker.ietf.org/doc/draft-ietf-opsec-icmp-filtering/02/&quot;&gt;ICMP 过滤建议&lt;/a&gt; 也把&quot;限速&quot;列为推荐手段。&lt;/p&gt;
&lt;p&gt;结果就是：&lt;strong&gt;traceroute 里的&quot;丢包率&quot;，统计的是这台路由器愿意回 ICMP 的比例，而不是它转发你数据时丢包的比例。&lt;/strong&gt; 第 11 跳的 99% 只是说&quot;100 个探测包它只回了 1 个&quot;；第 14 跳的 97% 也只是说&quot;它只回了 3 个&quot;。&lt;/p&gt;
&lt;p&gt;区分限速和真丢包，最省事的办法是&lt;strong&gt;看被限速那一跳漏回来的包的延迟&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;控制平面限速&lt;/strong&gt;：漏回来的包延迟低且稳（本例 8ms / 21ms / 24ms，最快最慢几乎一样）。因为链路根本不堵，只是路由器懒得理你。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;真拥塞&lt;/strong&gt;：延迟明显抬高，而且最快 / 最慢 / 平均三个值会拉开很大差距——排队是随机的。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;我自己电脑上那次更离谱的误读&lt;/h2&gt;
&lt;p&gt;讲完别人的数据，说说我在自己机器上踩的坑。为了给这篇文章配一张本机 &lt;code&gt;tracert&lt;/code&gt; 的图，我在 Windows 上跑了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tracert -d blog.juluo.work
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/traceroute-packet-loss-misread/03-local-tracert-one-hop.webp&quot; alt=&quot;本机 tracert -d blog.juluo.work 的真实输出：只有一跳，而且第一跳直接就是目标 IP&quot; /&gt;&lt;/p&gt;
&lt;p&gt;只出来一跳，而且第一跳就是目标 IP。&lt;/p&gt;
&lt;p&gt;换 &lt;code&gt;tracert -d 8.8.8.8&lt;/code&gt;、&lt;code&gt;tracert -d www.baidu.com&lt;/code&gt;，一样只有一跳。原因不在运营商，在我自己机器上：我开着 TUN 模式的代理，&lt;code&gt;route print -4&lt;/code&gt; 里两条默认路由，走 TUN 网卡（198.18.0.0/15 那段，metric 0）的那条优先级更高，所有流量——包括 ICMP 探测包——都在本地被代理栈接管了。&lt;strong&gt;测量点在代理里面，测出来的自然不是真实路径。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;顺手做的另外两个测试同样不可信：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ping -n 20 blog.juluo.work
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/traceroute-packet-loss-misread/04-local-ping-loss.webp&quot; alt=&quot;本机 ping -n 20 blog.juluo.work：20 个包丢 9 个，45% 丢包，但成功的那 11 个延迟稳定在 275~287ms&quot; /&gt;&lt;/p&gt;
&lt;p&gt;20 个包丢 9 个，45% 丢包。拿着这张图去问人，大概率会得到&quot;你的服务器在丢包&quot;的答案。但看仔细一点：成功的 11 个包，最快 275ms、最慢 287ms，极差只有 12ms。这种&quot;丢得厉害、延迟却极其稳定&quot;的组合，和前面第 11 跳是同一类现象——限速，而不是拥塞。&lt;/p&gt;
&lt;p&gt;最能说明问题的是第三个测试。我写了个二十来行的 Python 脚本，对 &lt;code&gt;blog.juluo.work:443&lt;/code&gt; 连续建立 20 次 TCP 连接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import socket
import statistics
import time

HOST = &quot;blog.juluo.work&quot;
PORT = 443
N = 20

ok = 0
times = []
for i in range(1, N + 1):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(5)
    t0 = time.perf_counter()
    try:
        s.connect((HOST, PORT))          # 只做三次握手，不发业务数据
        dt = (time.perf_counter() - t0) * 1000
        times.append(dt)
        ok += 1
        print(f&quot;{i:2d}  TCP {PORT} connect OK   {dt:7.1f} ms&quot;)
    except Exception as e:
        print(f&quot;{i:2d}  TCP {PORT} connect FAIL {e}&quot;)
    finally:
        s.close()
    time.sleep(0.2)

print(f&quot;\nsuccess = {ok}/{N}, failed = {N - ok}&quot;)
print(f&quot;min = {min(times):.1f} ms, avg = {statistics.mean(times):.1f} ms, max = {max(times):.1f} ms&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/traceroute-packet-loss-misread/05-local-tcp-connect.webp&quot; alt=&quot;本机对 blog.juluo.work:443 连 20 次的真实输出：20/20 全部成功，最快 1.0ms、平均 12.4ms&quot; /&gt;&lt;/p&gt;
&lt;p&gt;20 次全部成功，最快 &lt;strong&gt;1.0ms&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;1.0ms 是什么概念？从本机到最近的中国香港机房，物理距离在 1000 公里以上，光在光纤里跑一趟就要 5ms 起步，往返一次 10ms 打底，一次 TCP 握手（SYN → SYN-ACK → ACK）不可能 1ms 完成。这个数字唯一合理的解释是：&lt;strong&gt;连接被本机代理栈直接终结了&lt;/strong&gt;——代理先替远端回掉 SYN，再去慢慢建真正的连接。也就是说，这场测试测的是我的代理，不是我的博客。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
这三组本机数据我照样放进文章，但它们的结论是反的：它们证明的是&quot;在带 TUN 代理的机器上做网络测量，结果基本不可用&quot;。排查网络问题时，先确认自己的测量点在哪一层，比多测几次更重要。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;判据表：什么时候该管&lt;/h2&gt;
&lt;p&gt;把上面这些现象整理成一张表，下次再遇到&quot;中间跳丢包&quot;可以直接对照：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;你看到的现象&lt;/th&gt;
&lt;th&gt;大概率是什么&lt;/th&gt;
&lt;th&gt;要不要管&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;中间某跳 90%+ 丢包，但漏回来的包延迟低且稳定&lt;/td&gt;
&lt;td&gt;控制平面对 ICMP 限速&lt;/td&gt;
&lt;td&gt;不用管&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;中间跳 100% 丢包（连 IP 都不显示）&lt;/td&gt;
&lt;td&gt;设备被配置为不响应 TTL 超时（沉默设备）&lt;/td&gt;
&lt;td&gt;不用管，看它后面的跳有没有回包&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;中间跳丢包，同时该跳延迟明显抬高、最快最慢差距很大&lt;/td&gt;
&lt;td&gt;可能是真拥塞，也可能是限速叠了偶发排队&lt;/td&gt;
&lt;td&gt;先观察，最终按末跳定论&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;末跳（目标 IP 自己）持续丢包 &amp;gt; 5%，且延迟抖动明显&lt;/td&gt;
&lt;td&gt;真丢包：边缘节点异常或链路拥塞&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;要管&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;从某一跳开始延迟阶梯式上升（如 24ms → 207ms）&lt;/td&gt;
&lt;td&gt;路由绕路 / 出境，不是丢包&lt;/td&gt;
&lt;td&gt;想优化延迟才管&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;只有 ICMP 丢包，TCP 业务正常（页面能开、SSH 不断）&lt;/td&gt;
&lt;td&gt;ICMP 被策略丢弃或限速&lt;/td&gt;
&lt;td&gt;不用管&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ICMP 和 TCP 同时丢，并且有用户报障&lt;/td&gt;
&lt;td&gt;真故障&lt;/td&gt;
&lt;td&gt;立刻管&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;配套的三条实操建议：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;以末跳为准&lt;/strong&gt;。中间跳只提供&quot;路径长什么样&quot;的信息；能不能通，只看最后一跳是不是目标 IP、有没有回包。Windows 自带的 &lt;code&gt;tracert&lt;/code&gt; 每跳只发 3 个包，样本太少，看丢包率基本没有意义。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;多打几次、多换源&lt;/strong&gt;。每跳发 100 个包的 mtr 式工具（tcptest.cn 的路由追踪就是这种）比 &lt;code&gt;tracert&lt;/code&gt; 可靠得多。换一个检测点再看：如果只有部分中间跳丢、末跳一致正常，基本可以定性为限速。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;直接问业务端口&lt;/strong&gt;。TCP 握手能否成功、TTFB 多少，才是用户真正感知到的东西。ICMP 只是尽力而为的旁证，从来不是指标。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;那这次到底要不要管&lt;/h2&gt;
&lt;p&gt;不必。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;末跳丢包 10%、平均 196ms，目标可达，业务侧 TCP 连接正常；&lt;/li&gt;
&lt;li&gt;87%~99% 的那几跳，延迟只有 21~24ms，是最典型的控制平面限速特征，没有任何拥塞迹象；&lt;/li&gt;
&lt;li&gt;唯一真实存在的问题，是第 15 跳之后从 24ms 涨到 207ms 的出境绕路——这属于 Cloudflare 免费套餐 + Anycast 的固有代价，和&quot;丢包&quot;是两件事，&lt;a href=&quot;/posts/blog-ping-tcping-checkup/&quot;&gt;上一篇&lt;/a&gt;已经算过这笔账，暂时不动它。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;traceroute 的&quot;丢包率&quot;是 ICMP 回包率，不是转发丢包率。中间跳丢 99%，和你的网页能不能打开没有直接关系。&lt;/li&gt;
&lt;li&gt;区分限速与真丢包的关键：&lt;strong&gt;漏回来的包延迟是否正常&lt;/strong&gt;。延迟低而稳 = 限速；延迟高而抖 = 拥塞。&lt;/li&gt;
&lt;li&gt;只有末跳（目标自己）持续丢包，才轮到&quot;该管&quot;这两个字。&lt;/li&gt;
&lt;li&gt;测量前先看自己的测量点在哪一层：一个 TUN 代理就能让 traceroute 只剩一跳，让 TCP 握手变成 1ms。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
数据与截图说明：多节点路由追踪数据为 2026-10-05 晚在 tcptest.cn 实测（福建泉州电信节点，每跳 100 个包，完整结果见上面的 tcptest.cn 路由追踪结果）；本机 &lt;code&gt;tracert&lt;/code&gt;、&lt;code&gt;ping -n 20&lt;/code&gt; 与 TCP 连接测试均为同一晚在本机执行的真实输出，未做任何改动。由于本机 TUN 代理会让 &lt;code&gt;tracert&lt;/code&gt; 只剩一跳，原计划做的&quot;本机多目标 tracert 对比&quot;拿不到任何中间跳数据，中间跳的分析因此改用 tcptest.cn 的节点侧追踪结果。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;同属「工具输出怎么读」的另一篇是 &lt;a href=&quot;/posts/ssllabs-https-grade/&quot;&gt;SSL Labs 评分&lt;/a&gt;：一个看网络路径，一个看 HTTPS 配置。&lt;/p&gt;
</content:encoded></item><item><title>SSL Labs 给我博客打了个 B：HTTPS 配置到底被扣在哪</title><link>https://blog.peroe.cn/posts/ssllabs-https-grade/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/ssllabs-https-grade/</guid><description>用 SSL Labs 给 blog.juluo.work 做真实体检：四个边缘节点全部评 B，唯一扣分项是服务端仍接受 TLS 1.0/1.1。本文记录评分怎么读、怎么用 openssl 在本机复现结论，以及逐项判断哪些扣分值得修。</description><pubDate>Mon, 05 Oct 2026 13:50:00 GMT</pubDate><content:encoded>&lt;p&gt;「你的 HTTPS 配置能打几分？」——以前我对这个问题的回答是「Cloudflare 托管的，能有什么问题」。直到把域名丢进 SSL Labs，四个边缘节点整整齐齐，全是 &lt;strong&gt;B&lt;/strong&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
站点环境：Astro 静态站，Cloudflare Worker &lt;code&gt;peroe-blog&lt;/code&gt;（静态资源模式），免费套餐，域名 &lt;code&gt;blog.juluo.work&lt;/code&gt;。本文所有评分、协议版本、证书信息都是 &lt;strong&gt;2026-10-05 晚本机实测&lt;/strong&gt;：SSL Labs 报告时间 &lt;code&gt;Mon, 05 Oct 2026 13:17:03 UTC&lt;/code&gt;，引擎版本 &lt;code&gt;2.4.3&lt;/code&gt;，评级规则版本 &lt;code&gt;2009q&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;先说清楚 SSL Labs 在测什么&lt;/h2&gt;
&lt;p&gt;搞清楚工具的边界，比拿到分数重要。&lt;/p&gt;
&lt;p&gt;SSL Labs 只做一件事：模拟几十种客户端去跟你的服务器握手，然后根据「你支持哪些协议版本、哪些密码套件、证书链可不可信、有没有已知漏洞」打分。它测的是&lt;strong&gt;传输层&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;它&lt;strong&gt;不看&lt;/strong&gt;这些东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HSTS 有没有开（只影响你能不能从 A 爬到 A+）&lt;/li&gt;
&lt;li&gt;明文 HTTP 会不会跳转 HTTPS&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Content-Security-Policy&lt;/code&gt;、&lt;code&gt;X-Frame-Options&lt;/code&gt;、&lt;code&gt;Referrer-Policy&lt;/code&gt; 这些响应头&lt;/li&gt;
&lt;li&gt;Cookie 的 &lt;code&gt;Secure&lt;/code&gt; / &lt;code&gt;HttpOnly&lt;/code&gt; 属性、页面里的混合内容&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 B 不等于不安全，A+ 也不等于安全。我这个站的安全响应头体检是 &lt;strong&gt;F&lt;/strong&gt;（写在&lt;a href=&quot;/posts/security-headers-audit/&quot;&gt;另一篇&lt;/a&gt;里），跟这份 B 分说的是两件完全不同的事。两份报告放在一起看才有意义。&lt;/p&gt;
&lt;p&gt;不过有一点是可以先下判断的：对个人博客来说，TLS 这一层基本是&lt;strong&gt;白送&lt;/strong&gt;的——证书是 Cloudflare 的 Universal SSL 自动签发续期，协议栈是 Cloudflare 的，我一行配置都没写过。既然我这边成本为零，那分数低就只可能是某个开关没拨。&lt;/p&gt;
&lt;h2&gt;第一步：让 SSL Labs 跑一遍&lt;/h2&gt;
&lt;p&gt;直接怼 URL：&lt;a href=&quot;https://www.ssllabs.com/ssltest/analyze.html?d=blog.juluo.work&quot;&gt;https://www.ssllabs.com/ssltest/analyze.html?d=blog.juluo.work&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;几个实测出来的细节：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;有缓存。&lt;/strong&gt; 同一个域名 24 小时内的结果会直接从缓存给你，页面顶部会写 &lt;code&gt;Assessed on&lt;/code&gt; 的时间。所以第一次跑完要等，后面再打开就是秒出。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;慢。&lt;/strong&gt; 报告里每个节点的 &lt;code&gt;Duration&lt;/code&gt; 分别是 &lt;code&gt;104.78 / 103.948 / 104.164 / 104.147 sec&lt;/code&gt;，也就是单节点 100 秒出头。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;节点是 4 个。&lt;/strong&gt; 两个 IPv4（&lt;code&gt;172.67.135.130&lt;/code&gt;、&lt;code&gt;104.21.6.247&lt;/code&gt;）加两个 IPv6（&lt;code&gt;2606:4700:3033:0:0:0:6815:6f7&lt;/code&gt;、&lt;code&gt;2606:4700:3035:0:0:0:ac43:8782&lt;/code&gt;），串行扫完 5 分钟以上。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;结果是这样：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/ssllabs-https-grade/01-ssllabs-grade-b.webp&quot; alt=&quot;SSL Labs 对 blog.juluo.work 的报告首页，四个边缘节点（两个 IPv4、两个 IPv6）评级全部是 B&quot; /&gt;&lt;/p&gt;
&lt;p&gt;网页版的柱状图只能目测，想要结构化数据可以直接打它的公开 API——不需要注册，也不需要 key：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s &quot;https://api.ssllabs.com/api/v3/analyze?host=blog.juluo.work&amp;amp;all=done&quot; \
  | jq &apos;.endpoints[] | {ip: .ipAddress, grade: .grade}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回里三个关键字段：&lt;code&gt;grade = B&lt;/code&gt;、&lt;code&gt;gradeTrustIgnored = B&lt;/code&gt;、&lt;code&gt;hasWarnings = false&lt;/code&gt;。最后一个说明它不是靠「有警告」压下来的，是硬扣。&lt;/p&gt;
&lt;h2&gt;第二步：点开端点详情，看它到底扣在哪&lt;/h2&gt;
&lt;p&gt;报告首页只给结论，点进任意一个 IP 才是明细。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/ssllabs-https-grade/02-ssllabs-summary-cap.webp&quot; alt=&quot;SSL Labs 端点详情的 Summary 区块：Overall Rating 是 B，四根柱子里 Protocol Support 明显最短，黄色横幅写着这台服务器支持 TLS 1.0 和 TLS 1.1、评级被压到 B&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Summary 那一栏用四条横幅把话讲得很直白：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;横幅&lt;/th&gt;
&lt;th&gt;颜色&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;This server supports TLS 1.0 and TLS 1.1. Grade capped to B.&lt;/td&gt;
&lt;td&gt;黄&lt;/td&gt;
&lt;td&gt;唯一的扣分项：老协议还开着，直接封顶 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;This site works only in browsers with SNI support.&lt;/td&gt;
&lt;td&gt;蓝&lt;/td&gt;
&lt;td&gt;提示信息，不扣分&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;This server supports TLS 1.3.&lt;/td&gt;
&lt;td&gt;绿&lt;/td&gt;
&lt;td&gt;加分项&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;This server supports PQC (Post-Quantum Cryptography) key exchange.&lt;/td&gt;
&lt;td&gt;绿&lt;/td&gt;
&lt;td&gt;加分项&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;再看它上面那四根柱子（Certificate / Protocol Support / Key Exchange / Cipher Strength）：Certificate 是满格，Key Exchange 和 Cipher Strength 目测都在九成上下，&lt;strong&gt;只有 Protocol Support 明显凹下去一截&lt;/strong&gt;。柱子图形状和黄条说的完全一致——A 掉到 B，中间没有第二个原因。&lt;/p&gt;
&lt;h2&gt;第三步：用 openssl 在本机复现一遍&lt;/h2&gt;
&lt;p&gt;第三方报告的结论我不太愿意直接抄，得能自己复现。Windows 上 Git for Windows 自带 openssl（&lt;code&gt;C:\Program Files\Git\usr\bin\openssl.exe&lt;/code&gt;，我这儿是 3.5.5）。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/ssllabs-https-grade/03-openssl-sclient.webp&quot; alt=&quot;在本机 PowerShell 里用 openssl s_client 实测：正常握手协商到 TLSv1.3 + TLS_AES_256_GCM_SHA384 + X25519MLKEM768；显式指定 -tls1_1 时服务端同样完成了握手&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 1. 正常握手：看协商到哪个版本、哪个套件
openssl s_client -connect blog.juluo.work:443 -servername blog.juluo.work -brief

# 2. 指定老协议：看服务端拒不拒（SECLEVEL 的坑见后文）
openssl s_client -connect blog.juluo.work:443 -servername blog.juluo.work \
  -tls1_1 -cipher &apos;DEFAULT@SECLEVEL=0&apos; -brief

# 3. 看证书本身
openssl s_client -connect blog.juluo.work:443 -servername blog.juluo.work 2&amp;gt;/dev/null \
  | openssl x509 -noout -dates -subject -issuer -ext subjectAltName
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实测结果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;正常握手&lt;/strong&gt;：&lt;code&gt;Protocol version: TLSv1.3&lt;/code&gt;、&lt;code&gt;Ciphersuite: TLS_AES_256_GCM_SHA384&lt;/code&gt;、&lt;code&gt;Negotiated TLS1.3 group: X25519MLKEM768&lt;/code&gt;、&lt;code&gt;Verification: OK&lt;/code&gt;。另外单独用 &lt;code&gt;-alpn h2,http/1.1&lt;/code&gt; 测，ALPN 协商出来的结果是 &lt;code&gt;h2&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;指定 &lt;code&gt;-tls1_1&lt;/code&gt;：握手成功&lt;/strong&gt;。&lt;code&gt;Protocol version: TLSv1.1&lt;/code&gt;、&lt;code&gt;Ciphersuite: ECDHE-RSA-AES128-SHA&lt;/code&gt;、&lt;code&gt;Signature type: rsa_pkcs1_md5_sha1&lt;/code&gt;。指定 &lt;code&gt;-tls1&lt;/code&gt; 同样能握上。作为对照，&lt;code&gt;-tls1_2&lt;/code&gt; 走的是 &lt;code&gt;ECDHE-ECDSA-CHACHA20-POLY1305&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;证书&lt;/strong&gt;：&lt;code&gt;subject=CN=juluo.work&lt;/code&gt;、&lt;code&gt;issuer=C=US, O=Google Trust Services, CN=WE1&lt;/code&gt;，有效期 &lt;code&gt;Oct 5 08:44:00 2026 GMT&lt;/code&gt; → &lt;code&gt;Jan 3 09:43:41 2027 GMT&lt;/code&gt;，SAN 覆盖 &lt;code&gt;juluo.work&lt;/code&gt;、&lt;code&gt;blog.juluo.work&lt;/code&gt;、&lt;code&gt;*.blog.juluo.work&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两条一对比，SSL Labs 的结论就落地了：&lt;strong&gt;新协议没问题，是老协议没关。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;顺手再用 Cloudflare 文档里给的命令对照一次（这条测的是「有没有被拦住」）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl https://blog.juluo.work -svo /dev/null --tls-max 1.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;文档说如果被 Cloudflare 拦下，会报 &lt;code&gt;error:1400442E:SSL routines:CONNECT_CR_SRVR_HELLO:tlsv1 alert&lt;/code&gt;。我这儿返回的是 &lt;code&gt;HTTP/1.1 200 OK&lt;/code&gt;——没拦。&lt;/p&gt;
&lt;h2&gt;逐项过一遍：哪些分值得修&lt;/h2&gt;
&lt;h3&gt;1. TLS 1.0 / 1.1 还开着 —— 唯一的真扣分，值得修&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;为什么它扣分。&lt;/strong&gt; TLS 1.0（1999 年）和 1.1（2006 年）的 CBC 模式有 BEAST、Lucky13 这一类问题，而且不支持现代 AEAD 套件。SSL Labs 的规则很干脆：只要服务端还接受这两个版本，总分封顶 B，其它项再漂亮也没用。API 里还能看到 &lt;code&gt;vulnBeast: true&lt;/code&gt;——它其实是「TLS 1.0 开着」的副产品，不是第二个独立问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;关掉的代价是什么。&lt;/strong&gt; 会连不上的是不支持 TLS 1.2 的客户端。2026 年这类客户端基本只剩：Android 5.0 以前的老系统浏览器（对 TLS 1.2 的支持不完整）、Windows XP 和没打过补丁的 Windows 7 上的老 IE、一些工业与嵌入式设备。对一个技术博客的读者群体来说，这个比例约等于零。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;怎么改。&lt;/strong&gt; Cloudflare 面板 → SSL/TLS → Edge Certificates → Minimum TLS Version，选 TLS 1.2。文档里的 Availability 表写得很清楚，&lt;strong&gt;免费套餐就能用&lt;/strong&gt;，不需要 Advanced Certificate Manager。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/ssllabs-https-grade/04-cloudflare-minimum-tls.webp&quot; alt=&quot;Cloudflare 文档 Minimum TLS Version 页面，Availability 表显示 Free 套餐即可使用，并给出「How to disable TLS 1.0」的步骤&quot; /&gt;&lt;/p&gt;
&lt;p&gt;也可以用 API 一次改掉：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl &quot;https://api.cloudflare.com/client/v4/zones/$ZONE_ID/settings/min_tls_version&quot; \
  --request PATCH \
  --header &quot;Authorization: Bearer $CLOUDFLARE_API_TOKEN&quot; \
  --json &apos;{&quot;id&quot;: &quot;min_tls_version&quot;, &quot;value&quot;: &quot;1.2&quot;}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
诚实交代两句：&lt;strong&gt;第一，我没有真的去拨这个开关&lt;/strong&gt;，因为改的是线上 zone 的配置，我不想在写文章的过程中动生产环境。所以「改完就是 A」是按 SSL Labs 的封顶规则推的，不是我实测出来的，别当成已验证结论。&lt;strong&gt;第二，我也解释不了这个 zone 的最低版本为什么是 1.0&lt;/strong&gt;——面板里看不到这个设置的历史，我只能确认现状是服务端接受 TLS 1.0/1.1，别看到自己的站也这样就以为配置被人动过。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;我的判断：修。&lt;/strong&gt; 不是因为「TLS 1.0 很危险」——浏览器早就不用它了，真实风险很低——而是因为这是一个开关、十秒钟、零可感知代价的事。&lt;/p&gt;
&lt;h3&gt;2. 「This site works only in browsers with SNI support」—— 不用管&lt;/h3&gt;
&lt;p&gt;说的是服务端强制要求 SNI。没有 SNI 的客户端（Windows XP 上的 IE6、老 Java 之类）拿不到证书。这是 Cloudflare 的固定行为，免费套餐也改不了，而那些客户端本来就该淘汰了。&lt;/p&gt;
&lt;h3&gt;3. OCSP Stapling: No —— 报告里列了，但没有扣分&lt;/h3&gt;
&lt;p&gt;端点详情里 &lt;code&gt;OCSP Stapling&lt;/code&gt; 是 &lt;code&gt;No&lt;/code&gt;，&lt;code&gt;DNS CAA&lt;/code&gt; 是 &lt;code&gt;No (more info)&lt;/code&gt;。我特意回头核对了一遍：&lt;strong&gt;评级横幅里没有任何一条提到它们，柱状图里也没有对应的扣分条。&lt;/strong&gt; 这两项是列给你看的信息，不是扣分点，不值得为了分数去折腾。&lt;/p&gt;
&lt;p&gt;CAA 那条更跟 TLS 握手无关——它管的是「授权哪些 CA 能给 &lt;code&gt;juluo.work&lt;/code&gt; 签证书」，属于证书治理，不是配置错误。&lt;/p&gt;
&lt;h3&gt;4. 密钥交换和套件强度已经很好了，别乱动&lt;/h3&gt;
&lt;p&gt;从 API 拿到的结构化数据：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;namedGroups&lt;/code&gt; 的第一项是 &lt;code&gt;X25519MLKEM768&lt;/code&gt;，&lt;code&gt;namedGroupType&lt;/code&gt; 标着 &lt;code&gt;PQC&lt;/code&gt;——抗量子的混合密钥交换，免费套餐默认就开。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;forwardSecrecy: 2&lt;/code&gt;，现代套件全部支持前向保密。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;supportsRC4: null&lt;/code&gt;，RC4 没开；&lt;code&gt;compressionMethods: 0&lt;/code&gt;，没有 CRIME 风险。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zeroRTTEnabled: 0&lt;/code&gt;，0-RTT 是关的（0-RTT 有重放风险，关着是好事）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;结论：这四项别碰。&lt;/strong&gt; 看到「可自定义密码套件」就手痒去关掉一堆，是典型的负收益操作——Cloudflare 的默认套件列表比大多数人手写的都更合理。&lt;/p&gt;
&lt;h2&gt;那些 SSL Labs 根本不看的东西&lt;/h2&gt;
&lt;p&gt;这一节大概是全文最该记住的部分。&lt;/p&gt;
&lt;p&gt;前面提过它只测传输层，具体到我这个站：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HSTS 没开。&lt;/strong&gt; API 里 &lt;code&gt;hstsPolicy.status&lt;/code&gt; 是 &lt;code&gt;absent&lt;/code&gt;，&lt;code&gt;curl -D -&lt;/code&gt; 拉下来的响应头里也确实没有 &lt;code&gt;Strict-Transport-Security&lt;/code&gt;。但这事跟 B 分毫无关系，它只在你已经拿到 A 之后决定你能不能到 A+。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;明文 HTTP 不跳转。&lt;/strong&gt; 实测 &lt;code&gt;http://blog.juluo.work/&lt;/code&gt; 直接返回 &lt;code&gt;200 OK&lt;/code&gt;，没有任何 301。SSL Labs 不管这个，因为它是个纯 HTTPS 扫描器。但从「防降级攻击」的角度看，这一条比 TLS 1.0 值钱得多。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;安全响应头一个都没有。&lt;/strong&gt; CSP、X-Frame-Options、Referrer-Policy 全是缺的，那是另一份体检报告的 F 分，跟 TLS 两码事。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以「冲 SSL Labs A+」这个目标本身就要打个问号：它只覆盖了 HTTPS 配置里的一条线。&lt;/p&gt;
&lt;h2&gt;普通博客值不值得折腾 A+&lt;/h2&gt;
&lt;p&gt;我的判断分三档：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;B → A（关掉 TLS 1.0/1.1）：值得，可以立刻做。&lt;/strong&gt; 一个开关、零代价。哪怕不为分数，少支持两个 1999 / 2006 年的协议也没有坏处。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A → A+（加 HSTS）：可以做，但要先理解代价。&lt;/strong&gt; A+ 的条件是在 A 的基础上开 HSTS 且 &lt;code&gt;max-age&lt;/code&gt; 足够长。HSTS 的真实风险不是「被攻击」，而是&lt;strong&gt;你自己被锁在门外&lt;/strong&gt;：浏览器把策略缓存了，万一证书链出问题（Universal SSL 续期失败、你临时换成自签证书测试），访问者看到的是一个跳不过去的错误页。所以我建议的顺序还是「先开 Always Use HTTPS → 加短 &lt;code&gt;max-age&lt;/code&gt; 的 HSTS 观察 → 稳定后再逐步爬坡」，&lt;code&gt;preload&lt;/code&gt; 不用碰，展开写在&lt;a href=&quot;/posts/security-headers-audit/&quot;&gt;那篇响应头的文章&lt;/a&gt;里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;为了 A+ 去手改密码套件、折腾 OCSP、加一堆用不上的东西：不值。&lt;/strong&gt; 分数上去了，实际风险没变，却很容易搞出「某些客户端连不上」这种只有自己不知道的故障。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;一句话：&lt;strong&gt;TLS 分数低，通常不是配置差，而是某个默认开关没拨。&lt;/strong&gt; 花十分钟把 B 修成 A 是划算的；为了从 A 爬 A+ 反复折腾，对一个个人博客来说就是自娱自乐了。&lt;/p&gt;
&lt;h2&gt;踩坑与注意事项&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;坑 1（差点被误导）：OpenSSL 3.x 默认 &lt;code&gt;SECLEVEL=1&lt;/code&gt;，直接测 TLS 1.1 会得到「假阴性」。&lt;/strong&gt; 我第一次跑 &lt;code&gt;openssl s_client -tls1_1&lt;/code&gt; 拿到的是 &lt;code&gt;no protocols available&lt;/code&gt;，看起来就像「服务端拒绝了老协议」。其实那是&lt;strong&gt;客户端自己&lt;/strong&gt;不肯用——OpenSSL 3.x 把 TLS 1.2 以下的协议划归过低的 &lt;code&gt;SECLEVEL&lt;/code&gt;。必须显式降级才行：&lt;code&gt;-cipher &apos;DEFAULT@SECLEVEL=0&apos;&lt;/code&gt;。不加这个参数，我会得出「我的站最低已经是 TLS 1.2」的错误结论，然后跟 SSL Labs 的 B 分对不上，再去怀疑 SSL Labs 报错了。&lt;strong&gt;看到工具报错，先确认报错的是哪一方。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;坑 2：SSL Labs 的网页对自动化很不友好。&lt;/strong&gt; &lt;code&gt;analyze.html&lt;/code&gt; 会不停自我导航（URL 后面反复挂 &lt;code&gt;&amp;amp;latest&lt;/code&gt;）去轮询进度，用一个固定选择器等结果很可能永远等不到——我按 &lt;code&gt;#rating_grade&lt;/code&gt; 等了整整 5 分钟也没出现。要结构化数据就直接用 API；要截图就等缓存落定后再重新打开一次页面，第二次是读缓存，秒出。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;坑 3：同一个域名会返回两张证书，别以为数据对不上。&lt;/strong&gt; Cloudflare 同时部署了 RSA（&lt;code&gt;WR1&lt;/code&gt;）和 ECDSA（&lt;code&gt;WE1&lt;/code&gt;）两张证书，服务端按客户端能力挑一张。所以 &lt;code&gt;openssl&lt;/code&gt; 协商到的是 &lt;code&gt;WE1&lt;/code&gt;（ECDSA P-256），而 SSL Labs 报告里排第一的是 RSA 2048 的 &lt;code&gt;WR1&lt;/code&gt;，两张的 &lt;code&gt;notAfter&lt;/code&gt; 还差了几分钟（&lt;code&gt;09:43:41&lt;/code&gt; 对 &lt;code&gt;09:40:49&lt;/code&gt;）。谁都没错。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;坑 4：PowerShell 里调 openssl，&lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; 之后拿到的不是字符串。&lt;/strong&gt; &lt;code&gt;openssl s_client&lt;/code&gt; 把 &lt;code&gt;Connecting to ...&lt;/code&gt; 写在 stderr 上，我用 &lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; 合并后直接 &lt;code&gt;Out-File&lt;/code&gt;，PowerShell 顺手把 &lt;code&gt;NativeCommandError&lt;/code&gt; 的整块报错（&lt;code&gt;所在位置 行:4 字符:15&lt;/code&gt; 那一坨）也写进了文件，第一版终端配图直接报废。要么走 &lt;code&gt;cmd /c &quot;... &amp;lt; NUL 2&amp;gt;&amp;amp;1&quot;&lt;/code&gt;，要么在管道里显式 &lt;code&gt;&quot;$_&quot;&lt;/code&gt; 转成字符串。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;坑 5：Worker 静态资源模式的站，「SSL/TLS 加密模式」是个伪命题。&lt;/strong&gt; 几乎每篇教程都让你把加密模式设成 &lt;code&gt;Full (strict)&lt;/code&gt;。但这一项管的是 &lt;strong&gt;Cloudflare → 源站&lt;/strong&gt; 那一段：&lt;code&gt;Flexible&lt;/code&gt; 是回源走明文，&lt;code&gt;Full&lt;/code&gt; 是不校验证书，&lt;code&gt;Full (strict)&lt;/code&gt; 才校验。我的博客是 Worker 静态资源模式，资源就在 Cloudflare 边缘，&lt;strong&gt;根本不存在「源站」这一段&lt;/strong&gt;，所以 Off / Flexible / Full / Full (strict) 选哪个，对我的站不产生任何实际影响。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/ssllabs-https-grade/05-cloudflare-encryption-modes.webp&quot; alt=&quot;Cloudflare 文档 Encryption modes 页面，Off / Flexible / Full / Full (strict) 四种模式描述的都是 Cloudflare 与源站之间的那段连接&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;实测结论&lt;/strong&gt;：SSL Labs 对 &lt;code&gt;blog.juluo.work&lt;/code&gt; 的四个边缘节点全部评 &lt;strong&gt;B&lt;/strong&gt;，唯一扣分项是服务端仍接受 &lt;strong&gt;TLS 1.0 和 TLS 1.1&lt;/strong&gt;，评级被 capped to B。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;其余项都是好的&lt;/strong&gt;：TLS 1.3 + AEAD 套件（&lt;code&gt;TLS_AES_256_GCM_SHA384&lt;/code&gt;）、抗量子混合密钥交换（&lt;code&gt;X25519MLKEM768&lt;/code&gt;）、前向保密、证书链受信（Google Trust Services，SAN 覆盖 &lt;code&gt;blog.juluo.work&lt;/code&gt; 与 &lt;code&gt;*.blog.juluo.work&lt;/code&gt;）——这些 Cloudflare 免费套餐默认就给。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;值得修的只有一个&lt;/strong&gt;：Minimum TLS Version 改成 1.2，免费套餐可用，代价接近零。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不值得修的&lt;/strong&gt;：OCSP Stapling、CAA、SNI 提示——要么不影响评级，要么跟 TLS 体检本身无关；更不要为了分数去手改密码套件列表。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;别忘了 SSL Labs 覆盖不到的地方&lt;/strong&gt;：HSTS、明文 HTTP 强制跳转、安全响应头，它一样都不测，而这几项对真实安全的影响不比 TLS 版本小。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>我的博客响应头缺了什么：Cloudflare _headers 补课记</title><link>https://blog.peroe.cn/posts/security-headers-audit/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/security-headers-audit/</guid><description>用 securityheaders.com 体检博客，结果是 F：六个安全头全缺。这篇记录实测现状，讲清每个头的作用与副作用，并给出 Cloudflare Workers 静态资源用 public/_headers 补头的方案。</description><pubDate>Mon, 05 Oct 2026 13:35:00 GMT</pubDate><content:encoded>&lt;p&gt;博客迁到 Cloudflare Workers 之后，我一直只关心两件事：能不能打开、快不快。直到有人看了我的站说了一句「你这响应头挺干净的」——干净有时候不是夸奖。去 &lt;code&gt;securityheaders.com&lt;/code&gt; 跑了一遍，评分直接是 &lt;strong&gt;F&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这篇文章把体检结果、六个缺失的头分别管什么、以及我准备怎么补，一次讲完。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
站点环境：Astro 静态站，部署在 Cloudflare Worker &lt;code&gt;peroe-blog&lt;/code&gt;（静态资源模式，免费套餐，没买 Argo / Pro），域名 &lt;code&gt;blog.juluo.work&lt;/code&gt;，构建由 Workers Builds 从 GitHub 仓库拉 main 分支跑 &lt;code&gt;pnpm update-diff &amp;amp;&amp;amp; pnpm astro build --force&lt;/code&gt;。
所有检测数据都是 &lt;strong&gt;2026-10-05 晚本机实测&lt;/strong&gt;。&lt;strong&gt;本文发布时这些响应头还没有应用&lt;/strong&gt;，所以文中只有 before 的真实数据；after 部分给的是方案和验证方法，需要照步骤做完自己复检。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;为什么静态站最容易漏掉这一环&lt;/h2&gt;
&lt;p&gt;静态站很容易让人产生一种错觉：我只是一堆 HTML 和图片，没有数据库、没有登录、没有后端逻辑，能有什么安全问题？&lt;/p&gt;
&lt;p&gt;问题在于，&lt;strong&gt;安全响应头管的从来不是服务器，而是浏览器&lt;/strong&gt;。服务器已经正确返回了内容，浏览器接下来怎么解释这些内容、允不允许别人嵌套、要不要带着 Referer 跑去外站——这些决定权在响应头手里，而 Cloudflare 不会替你写。&lt;/p&gt;
&lt;p&gt;三条常见路线，我对比过：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare 控制台的 Transform Rules&lt;/strong&gt;（Modify Response Header）。不用改代码，点点鼠标就行。但配置只活在控制台里，不进仓库、不能 review、换账号就没了；免费套餐的动态规则条数也紧张。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在 Worker 里包一层 fetch，手动 &lt;code&gt;headers.set()&lt;/code&gt;&lt;/strong&gt;。最灵活，但对一个纯静态站来说，为了加几个响应头把整个静态资源链路接管一遍并不划算。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;在静态资源目录放一个 &lt;code&gt;_headers&lt;/code&gt; 纯文本文件&lt;/strong&gt;。跟仓库走、跟构建走、可 diff、可回滚，Workers 静态资源原生支持。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我选第 3 条，理由很简单：这个站的部署方式是「push 到 main → Cloudflare 构建 → wrangler deploy」，配置也应该在这个链路里，而不是散落在控制台。&lt;/p&gt;
&lt;h2&gt;实测：securityheaders.com 给了 F&lt;/h2&gt;
&lt;p&gt;直接看结果：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/security-headers-audit/01-securityheaders-grade-f.webp&quot; alt=&quot;securityheaders.com 对 https://blog.juluo.work 的完整报告，评级 F，六个安全头全部缺失&quot; /&gt;&lt;/p&gt;
&lt;p&gt;报告时间是 05 Oct 2026 13:10:38 UTC，扫的是 &lt;code&gt;https://blog.juluo.work/&lt;/code&gt;。六个红叉分别是：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;缺失的头&lt;/th&gt;
&lt;th&gt;报告里的建议值&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Strict-Transport-Security&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;max-age=31536000; includeSubDomains&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Content-Security-Policy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;按站点内容自行声明&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Frame-Options&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SAMEORIGIN&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;X-Content-Type-Options&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;nosniff&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Referrer-Policy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;no-referrer&lt;/code&gt; / &lt;code&gt;strict-origin-when-cross-origin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Permissions-Policy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;按需关闭不用的浏览器特性&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;报告底部还有一块 &lt;strong&gt;Upcoming Headers&lt;/strong&gt;，列着 &lt;code&gt;Cross-Origin-Embedder-Policy&lt;/code&gt;、&lt;code&gt;Cross-Origin-Opener-Policy&lt;/code&gt;、&lt;code&gt;Cross-Origin-Resource-Policy&lt;/code&gt; 三个头。它们目前不计入扣分，我也没打算上：COEP / COOP 一开严格模式，站外图片和第三方 iframe 很容易一起挂掉，收益（启用 &lt;code&gt;SharedArrayBuffer&lt;/code&gt; 之类）个人博客用不上。&lt;/p&gt;
&lt;p&gt;再用 curl 自己确认一遍，毕竟第三方工具的结论我得自己复现：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/security-headers-audit/02-curl-response-headers.webp&quot; alt=&quot;在本机 PowerShell 里用 curl 查看 blog.juluo.work 的真实响应头，并用 Select-String 统计安全头命中数为 0&quot; /&gt;&lt;/p&gt;
&lt;p&gt;真实输出里跟安全相关的头命中数是 &lt;strong&gt;0&lt;/strong&gt;。响应里确实有一堆头，但仔细看全是 Cloudflare 自己的东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Server: cloudflare&lt;/code&gt;、&lt;code&gt;CF-RAY: ...&lt;/code&gt;、&lt;code&gt;CF-Cache-Status: HIT&lt;/code&gt; —— 这是「谁在处理请求」，不是安全策略。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Nel&lt;/code&gt; / &lt;code&gt;Report-To&lt;/code&gt; —— 网络错误上报（Network Error Logging），属于&lt;strong&gt;可观测性&lt;/strong&gt;头。它们让浏览器把网络故障上报给 Cloudflare，跟「防不防 XSS / 点击劫持」没有半点关系。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Cache-Control: public, max-age=0, must-revalidate&lt;/code&gt; —— 这是 Workers 静态资源的默认缓存策略，跟安全无关（后面还会用到它）。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;alt-svc: h3=&quot;:443&quot;&lt;/code&gt; —— 告诉浏览器可以走 HTTP/3。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以「响应头列表很长」这件事本身很有欺骗性。&lt;/p&gt;
&lt;p&gt;另外我顺手测了一下明文 HTTP：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl.exe -s -o NUL -w &quot;final_code=%{http_code} redirects=%{num_redirects} url=%{url_effective}`n&quot; -L --noproxy &quot;*&quot; http://blog.juluo.work/
# final_code=200 redirects=0 url=http://blog.juluo.work/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;redirects=0&lt;/code&gt;，明文 HTTP 直接返回 200，站点&lt;strong&gt;没有强制跳转 HTTPS&lt;/strong&gt;。这一条在后面的 HSTS 部分很关键。&lt;/p&gt;
&lt;h2&gt;逐个头过一遍：作用、副作用、我的取舍&lt;/h2&gt;
&lt;h3&gt;Strict-Transport-Security（HSTS）：最容易误伤的一个&lt;/h3&gt;
&lt;p&gt;HSTS 的作用是告诉浏览器「这个域以后只准走 HTTPS」，浏览器会把这个策略缓存下来，在有效期内把所有 &lt;code&gt;http://&lt;/code&gt; 请求在本地就改写成 &lt;code&gt;https://&lt;/code&gt;。它挡的是降级攻击和 Cookie 劫持——一个中间人可以篡改明文 HTTP 响应里的跳转，把你按回 HTTP，而 HSTS 让浏览器压根不发那次明文请求。&lt;/p&gt;
&lt;p&gt;但 HSTS 有个前提常被忽略：&lt;strong&gt;它只管「以后的访问」，不管「第一次访问」&lt;/strong&gt;。第一次访问如果还是明文，攻击者照样有机会。所以 HSTS 必须和「强制跳转」配合用——先让服务器 301 到 HTTPS，再加 HSTS 头。我现在的状态正好是两样都缺，去 &lt;code&gt;hstspreload.org&lt;/code&gt; 查 &lt;code&gt;juluo.work&lt;/code&gt; 的结果很直白：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/security-headers-audit/03-hsts-preload-status.webp&quot; alt=&quot;hstspreload.org 查询 juluo.work 的结果：未进入预加载列表，两条错误分别是缺少 HSTS 头和 HTTP 不跳转 HTTPS&quot; /&gt;&lt;/p&gt;
&lt;p&gt;两条错误：&lt;code&gt;No HSTS header&lt;/code&gt;，以及 &lt;code&gt;HTTP does not redirect to HTTPS&lt;/code&gt;。这跟我上面 curl 测到的 &lt;code&gt;redirects=0&lt;/code&gt; 完全对得上。&lt;/p&gt;
&lt;p&gt;副作用和坑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;includeSubDomains&lt;/code&gt; 是一个「域级」承诺&lt;/strong&gt;。给 &lt;code&gt;blog.juluo.work&lt;/code&gt; 加，覆盖的是 &lt;code&gt;blog.juluo.work&lt;/code&gt; 及其子域，我这边没有别的子域，安全。但如果直接给裸域 &lt;code&gt;juluo.work&lt;/code&gt; 加 &lt;code&gt;includeSubDomains&lt;/code&gt;，那么 &lt;code&gt;t.juluo.work&lt;/code&gt;（自建统计）、&lt;code&gt;icon.juluo.work&lt;/code&gt;（链接卡片）必须都能正常走 HTTPS，否则会一起被打死——而且是浏览器缓存级别的打死，你改回来用户也进不去。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;preload&lt;/code&gt; 千万不要随手加&lt;/strong&gt;。一旦提交进 Chrome 的预加载列表，撤销要走 removal form 并等好几个月；而且预加载是硬失败：证书一过期，浏览器直接报错，没有「继续访问」的按钮。&lt;code&gt;hstspreload.org&lt;/code&gt; 页面自己也写着 Preloading Should Be Opt-In，还专门劝项目维护者别默认给用户开。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;max-age&lt;/code&gt; 分级爬&lt;/strong&gt;。官方建议 300 秒 → 86400 秒 → 31536000 秒这样往上加，每一级观察一阵。我打算先在 &lt;code&gt;_headers&lt;/code&gt; 里写 &lt;code&gt;max-age=300&lt;/code&gt; 跑一天，确认没有意外再改大。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我的结论：HSTS 该上，但顺序是「先在 Cloudflare 开 Always Use HTTPS（SSL/TLS → Edge Certificates）→ 观察一天 → 再加 &lt;code&gt;max-age=300&lt;/code&gt; 的 HSTS → 稳定后爬到一年」。&lt;code&gt;preload&lt;/code&gt; 暂时不碰。&lt;/p&gt;
&lt;h3&gt;Content-Security-Policy：唯一需要认真做功课的&lt;/h3&gt;
&lt;p&gt;CSP 是这六个头里最强大的，也是最容易把站点搞坏的。它用一组指令限定页面能加载哪些来源的脚本、样式、图片、iframe，是 XSS 的最后一道防线——就算某天引用的第三方脚本被投毒，浏览器也会拒绝执行不在白名单里的东西。&lt;/p&gt;
&lt;p&gt;我为什么不直接抄一份「满分 CSP」？因为 &lt;strong&gt;Astro 生成的页面里有大量内联脚本&lt;/strong&gt;。我数了一下自己的文章页，内联 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 有 21 个（主题切换、代码高亮、评论加载、统计初始化都是内联的）。这意味着 &lt;code&gt;script-src &apos;self&apos;&lt;/code&gt; 一加下去，页面立刻半死。&lt;/p&gt;
&lt;p&gt;摆在面前的是两条路：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;加 &lt;code&gt;&apos;unsafe-inline&apos;&lt;/code&gt;：页面能跑，但 CSP 对 XSS 的防护基本归零，等于花钱买了个装饰品。&lt;/li&gt;
&lt;li&gt;上 nonce / hash：静态站的页面是构建期生成的，nonce 需要运行时注入，得改成 Worker 渲染或者加构建插件，为一个响应头重构整站，不划算。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以我选&lt;strong&gt;分期&lt;/strong&gt;：第一期只上「结构性」指令，这几条不涉及内联脚本，几乎零误伤：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Content-Security-Policy-Report-Only: default-src &apos;self&apos;; object-src &apos;none&apos;; base-uri &apos;self&apos;; frame-ancestors &apos;self&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;object-src &apos;none&apos;&lt;/code&gt; 挡 &lt;code&gt;&amp;lt;object&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;embed&amp;gt;&lt;/code&gt; 注入，&lt;code&gt;base-uri &apos;self&apos;&lt;/code&gt; 挡 &lt;code&gt;&amp;lt;base&amp;gt;&lt;/code&gt; 标签劫持，&lt;code&gt;frame-ancestors &apos;self&apos;&lt;/code&gt; 挡 iframe 嵌套。先用 &lt;code&gt;Report-Only&lt;/code&gt; 跑一周，在浏览器 Console 里看有没有误报，确认干净了再换成强制模式、再逐步往 &lt;code&gt;script-src&lt;/code&gt; / &lt;code&gt;connect-src&lt;/code&gt; 收紧。&lt;/p&gt;
&lt;p&gt;顺便记一下我扒自己页面得到的外域清单，将来收紧 CSP 时要用：&lt;code&gt;cloud.umami.is&lt;/code&gt;（Umami 统计脚本 + &lt;code&gt;&amp;lt;link rel=&quot;preconnect&quot;&amp;gt;&lt;/code&gt;）、&lt;code&gt;giscus.app&lt;/code&gt;（评论 iframe 和 &lt;code&gt;client.js&lt;/code&gt;）、&lt;code&gt;t.juluo.work&lt;/code&gt;（自建浏览量接口）、&lt;code&gt;q2.qlogo.cn&lt;/code&gt;（头像）、&lt;code&gt;cdn.jsdelivr.net&lt;/code&gt; 等。也就是说真要写全量 CSP，&lt;code&gt;connect-src&lt;/code&gt; 里必须有 &lt;code&gt;t.juluo.work&lt;/code&gt;，&lt;code&gt;frame-src&lt;/code&gt; 里必须有 &lt;code&gt;giscus.app&lt;/code&gt;，&lt;code&gt;img-src&lt;/code&gt; 得放开 &lt;code&gt;https:&lt;/code&gt;（随机图和链接卡片封面来自站外）。这类清单只能从自己页面里扒，抄别人的一定漏。&lt;/p&gt;
&lt;h3&gt;X-Frame-Options：和 CSP 重复，但还得留&lt;/h3&gt;
&lt;p&gt;作用是禁止别人用 &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; 把你的站嵌进去做点击劫持——把博客套在一个透明层下面，诱导用户点「发布」「删除」之类的按钮。我的站没有这类操作按钮，风险不高，但成本也接近零。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SAMEORIGIN&lt;/code&gt;（允许同源嵌套）比 &lt;code&gt;DENY&lt;/code&gt; 温和一点，留了余量。副作用是：如果你哪天想在自己的另一个域上 iframe 这个博客，会被自己挡住。&lt;/p&gt;
&lt;p&gt;需要注意它和 CSP 的 &lt;code&gt;frame-ancestors&lt;/code&gt; 是&lt;strong&gt;功能重叠&lt;/strong&gt;的：现代浏览器以 &lt;code&gt;frame-ancestors&lt;/code&gt; 为准，&lt;code&gt;X-Frame-Options&lt;/code&gt; 主要是给老浏览器兜底。两个都写不冲突，但值必须一致（都 &lt;code&gt;SAMEORIGIN&lt;/code&gt;），否则一个说允许一个说禁止，排查起来很折磨。&lt;/p&gt;
&lt;h3&gt;X-Content-Type-Options：六个里最没争议的一个&lt;/h3&gt;
&lt;p&gt;只有 &lt;code&gt;nosniff&lt;/code&gt; 一个合法值。作用是禁止浏览器「嗅探」内容类型：如果一个 &lt;code&gt;.txt&lt;/code&gt; 文件里写着 HTML，浏览器不应该把它当 HTML 执行，而应该老老实实按服务器给的 &lt;code&gt;Content-Type&lt;/code&gt; 处理。挡的是利用 MIME 嗅探的上传型 XSS。&lt;/p&gt;
&lt;p&gt;它唯一的「副作用」场景是：&lt;strong&gt;如果你服务器把 Content-Type 配错了，以前靠嗅探还能歪打正着跑起来，加了 nosniff 之后会直接白屏&lt;/strong&gt;（比如 &lt;code&gt;.js&lt;/code&gt; 被标成 &lt;code&gt;text/plain&lt;/code&gt;，浏览器直接拒绝执行）。&lt;/p&gt;
&lt;p&gt;我实测过自己的静态资源，Cloudflare 是按扩展名给的，&lt;code&gt;/_astro/fancybox.DJVvVWuI.css&lt;/code&gt; 返回 &lt;code&gt;text/css&lt;/code&gt;，&lt;code&gt;Layout.astro_astro_type_script_index_0_lang.DYXp3ISy.js&lt;/code&gt; 返回 &lt;code&gt;text/javascript&lt;/code&gt;，都是对的，所以这个头我加得毫无心理负担。&lt;/p&gt;
&lt;h3&gt;Referrer-Policy：把默认行为写死&lt;/h3&gt;
&lt;p&gt;它控制页面跳转到外站（以及加载外站资源）时，&lt;code&gt;Referer&lt;/code&gt; 里带多少信息。不写的话，现在的浏览器默认已经是 &lt;code&gt;strict-origin-when-cross-origin&lt;/code&gt;：同源带完整路径，跨域只带域名。&lt;/p&gt;
&lt;p&gt;那我为什么还要显式写一遍？两个理由：一是把行为&lt;strong&gt;钉死&lt;/strong&gt;，不依赖浏览器默认值会不会变；二是防止被别的层（CDN 规则、第三方脚本）覆盖掉。代价是：如果你靠 &lt;code&gt;document.referrer&lt;/code&gt; 做站外来源分析，跨域场景只能拿到 origin，拿不到具体路径——不过默认值本来也是这样，所以这个头基本是零成本的。&lt;/p&gt;
&lt;h3&gt;Permissions-Policy：关掉用不到的浏览器能力&lt;/h3&gt;
&lt;p&gt;用一个头批量关掉定位、麦克风、摄像头、支付、USB 这些 API：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;空括号 &lt;code&gt;()&lt;/code&gt; 表示「谁都不允许」，是最严格的写法。副作用很直接：&lt;strong&gt;关掉之后你自己的页面也用不了&lt;/strong&gt;。如果哪天想在文章里嵌一个需要定位的演示，就得回来改这个头。所以我只关了我百分百确定用不到的几项，没有照抄那种几十项的清单。它也不像 &lt;code&gt;nosniff&lt;/code&gt; 那样零成本，属于「想清楚再关」。&lt;/p&gt;
&lt;h2&gt;方案：一个 public/_headers 文件&lt;/h2&gt;
&lt;p&gt;最终要写进仓库的就是这个文件：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://blog.peroe.cn/assets/images/posts/security-headers-audit/04-headers-file.webp&quot; alt=&quot;准备写入 public/_headers 的内容，分两阶段：先上五个基本无副作用的安全头，CSP 先用 Report-Only 跑一周&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# public/_headers —— Cloudflare Workers 静态资源的自定义响应头
# Astro 构建时 public/ 会原样复制到 dist/，Workers 解析这个文件并改写响应头
# （文件本身不会被当作静态资源下发，站点里看不到它）

# 第 1 阶段：先上 5 个基本没有副作用的安全头
/*
  Strict-Transport-Security: max-age=300; includeSubDomains
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=()
  X-Frame-Options: SAMEORIGIN

# 第 2 阶段：CSP 先用 Report-Only 跑一周，确认没有误伤再转正
/*
  Content-Security-Policy-Report-Only: default-src &apos;self&apos;; object-src &apos;none&apos;; base-uri &apos;self&apos;; frame-ancestors &apos;self&apos;

# 顺手的收益：_astro/ 下的产物文件名带内容哈希，可以放心 immutable
/_astro/*
  Cache-Control: public, max-age=31536000, immutable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个设计说明：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么放 &lt;code&gt;public/&lt;/code&gt;。&lt;/strong&gt; &lt;code&gt;wrangler.jsonc&lt;/code&gt; 里是 &lt;code&gt;assets.directory = ./dist&lt;/code&gt;，而 Astro 会把 &lt;code&gt;public/&lt;/code&gt; 下的文件原样复制进 &lt;code&gt;dist/&lt;/code&gt;，所以 &lt;code&gt;dist/_headers&lt;/code&gt; 正好落在静态资源根目录。部署链路一个字都不用改，&lt;code&gt;npx wrangler deploy&lt;/code&gt; 会把它一起带上去。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HSTS 先写 300 秒。&lt;/strong&gt; 这就是上面说的分级爬坡，验证没问题再改成 &lt;code&gt;31536000&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;/_astro/*&lt;/code&gt; 那条不是安全头，是顺手捡的收益。&lt;/strong&gt; 现在我的资源全是 &lt;code&gt;Cache-Control: public, max-age=0, must-revalidate&lt;/code&gt;，虽然带 &lt;code&gt;ETag&lt;/code&gt; 不会重复下载内容，但每次导航仍要走一次条件请求。实测 &lt;code&gt;MainGridLayout.BQV2WkRb.css&lt;/code&gt; 有 288727 字节（约 282 KB），文件名里带内容哈希（&lt;code&gt;BQV2WkRb&lt;/code&gt;）意味着内容一变文件名就变，可以放心 &lt;code&gt;immutable&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;别把这条件加到 HTML 上。&lt;/strong&gt; HTML 保持默认的 &lt;code&gt;max-age=0, must-revalidate&lt;/code&gt;，否则发新文章读者看不到。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
&lt;code&gt;_headers&lt;/code&gt; 只对&lt;strong&gt;静态资源响应&lt;/strong&gt;生效，对 Worker 代码生成的响应&lt;strong&gt;无效&lt;/strong&gt;。我另外两个服务——&lt;code&gt;t.juluo.work&lt;/code&gt;（自建统计 Worker + D1）和 &lt;code&gt;icon.juluo.work&lt;/code&gt;（链接卡片 Worker）——都是脚本模式，响应是代码里 &lt;code&gt;new Response()&lt;/code&gt; 出来的，&lt;code&gt;_headers&lt;/code&gt; 管不到它们，只能在 Worker 里手动 &lt;code&gt;headers.set()&lt;/code&gt;。这一点官方文档写得很明确，别以为放个文件就全站生效了。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;规则本身的几个细节（我对着官方文档核过）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;一个块 = 第一行是路径模式（&lt;code&gt;/&lt;/code&gt;、&lt;code&gt;/path/*&lt;/code&gt;，也支持写完整 URL），下面缩进的行是 &lt;code&gt;名字: 值&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;一个文件最多 100 条规则，每行含空格在内上限 2000 字符。&lt;/li&gt;
&lt;li&gt;多个块命中同一个请求时，头会&lt;strong&gt;合并&lt;/strong&gt;；同一个头被写了两次，值用逗号拼接。&lt;/li&gt;
&lt;li&gt;想&lt;strong&gt;删掉&lt;/strong&gt;某个头（默认头或者更宽规则加上的），用 &lt;code&gt;! 头名&lt;/code&gt; 的写法。&lt;/li&gt;
&lt;li&gt;匹配支持 &lt;code&gt;*&lt;/code&gt; 通配（整个模式只能有一个）和 &lt;code&gt;:placeholder&lt;/code&gt;，匹配到的值可以在头值里用 &lt;code&gt;:splat&lt;/code&gt; / &lt;code&gt;:placeholder&lt;/code&gt; 引用。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;怎么验证&lt;/h2&gt;
&lt;p&gt;下面几步我没法替你跑（文件还没提交上去），只能给出 after 的验证方法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 1. 看完整响应头，预期多出 5 个新头
curl -sI https://blog.juluo.work/

# 2. 只筛安全头，预期有输出（现在是空）
curl -sI https://blog.juluo.work/ \
  | grep -iE &apos;strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy&apos;

# 3. 确认 HTTP 是否跳转（先在 Cloudflare 开 Always Use HTTPS）
curl -s -o /dev/null -w &apos;%{http_code} -&amp;gt; %{redirect_url}\n&apos; http://blog.juluo.work/

# 4. 复检评分
#    https://securityheaders.com/?q=https%3A%2F%2Fblog.juluo.work&amp;amp;followRedirects=on
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第 2 步在我本地现在&lt;strong&gt;是空输出&lt;/strong&gt;，第 3 步现在返回 &lt;code&gt;200&lt;/code&gt;（&lt;code&gt;redirects=0&lt;/code&gt;）。做完之后这两步应该分别变成「有输出」和 &lt;code&gt;301&lt;/code&gt;。Windows 上没有 &lt;code&gt;grep&lt;/code&gt;，我是用 PowerShell 的 &lt;code&gt;Select-String&lt;/code&gt; 做的等价统计（就是前面第二张截图里那条命令，结果是 &lt;code&gt;0&lt;/code&gt;）。&lt;/p&gt;
&lt;h2&gt;踩坑与注意事项&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;坑 1：以为上了 Cloudflare 就有安全头。&lt;/strong&gt; Cloudflare 免费套餐默认只加 &lt;code&gt;Nel&lt;/code&gt;、&lt;code&gt;Report-To&lt;/code&gt;、&lt;code&gt;Server&lt;/code&gt;、&lt;code&gt;CF-RAY&lt;/code&gt;、&lt;code&gt;alt-svc&lt;/code&gt; 这些它自己的头，六个安全头一个都不加。这些是「可观测性头」和「传输协商头」，不是「安全头」，别被响应头列表的长度骗了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;坑 2：securityheaders.com 到底扫的是 HTTP 还是 HTTPS。&lt;/strong&gt; 我第一次直接在输入框填了域名，它按 &lt;code&gt;http://blog.juluo.work/&lt;/code&gt; 扫的（报告里 Site 那一行会写出来）。后面加上 &lt;code&gt;followRedirects=on&lt;/code&gt;、并且显式写 &lt;code&gt;https://&lt;/code&gt;，报告才是你真正想要的那份。两次结果的评级都是 F，但缺的头数量不一样（HTTP 那份是 5 个，HTTPS 那份是 6 个，多了 HSTS），很容易看错。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;坑 3（差点踩）：&lt;code&gt;includeSubDomains&lt;/code&gt; 之前先数一遍子域。&lt;/strong&gt; 我在 &lt;code&gt;blog.juluo.work&lt;/code&gt; 上写是安全的；但如果图省事直接给裸域 &lt;code&gt;juluo.work&lt;/code&gt; 写 &lt;code&gt;includeSubDomains&lt;/code&gt;，而某个子域还跑在 HTTP 上，那就是浏览器缓存级别的打死，改回来也进不去。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;坑 4：CSP 不能直接上强制模式。&lt;/strong&gt; 静态站的页面是构建期生成的一大坨内联脚本，&lt;code&gt;script-src &apos;self&apos;&lt;/code&gt; 下去评论区和统计会立刻全灭。必须 &lt;code&gt;Report-Only&lt;/code&gt; 先跑，看 Console 报错清单。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;坑 5：&lt;code&gt;immutable&lt;/code&gt; 别加错地方。&lt;/strong&gt; 只给带内容哈希的 &lt;code&gt;/_astro/*&lt;/code&gt;，HTML 保持默认。加错到 HTML 上读者会一直看到旧页面，而且因为自己浏览器强刷而不复现，特别难排查。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;现状（实测）&lt;/strong&gt;：六个安全头全缺，&lt;code&gt;securityheaders.com&lt;/code&gt; 给 F，&lt;code&gt;http://blog.juluo.work/&lt;/code&gt; 明文返回 200 不跳转，&lt;code&gt;juluo.work&lt;/code&gt; 不在 HSTS 预加载列表里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;值得马上补的四个&lt;/strong&gt;：&lt;code&gt;X-Content-Type-Options&lt;/code&gt;、&lt;code&gt;Referrer-Policy&lt;/code&gt;、&lt;code&gt;X-Frame-Options&lt;/code&gt;、&lt;code&gt;Permissions-Policy&lt;/code&gt;。几乎是纯收益，副作用可以忽略。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HSTS 要配合强制跳转一起上&lt;/strong&gt;，&lt;code&gt;max-age&lt;/code&gt; 从 300 秒分级爬，&lt;code&gt;preload&lt;/code&gt; 现阶段别碰。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSP 是唯一需要认真做功课的&lt;/strong&gt;：先用 &lt;code&gt;Report-Only&lt;/code&gt; + 结构性指令，等内联脚本清理干净了再收紧。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare Workers 静态资源站，&lt;code&gt;public/_headers&lt;/code&gt; 是成本最低、最可回滚的做法&lt;/strong&gt;：跟仓库走、跟构建走、能 diff 能 review。但它只管静态资源，Worker 生成的响应还得在代码里加。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;本文发布时站点还没有应用这些头，after 数据都是方案说明而不是实测结果。照上面四条命令复检即可。&lt;/p&gt;
&lt;p&gt;配合 HTTPS 配置的 &lt;a href=&quot;/posts/ssllabs-https-grade/&quot;&gt;SSL Labs 评分&lt;/a&gt;一起看，安全审计的两块就齐了。&lt;/p&gt;
</content:encoded></item><item><title>关于本站</title><link>https://blog.peroe.cn/posts/hello-world/</link><guid isPermaLink="true">https://blog.peroe.cn/posts/hello-world/</guid><description>这是本站的第一篇文章：介绍一下这个博客是做什么的、用了哪些技术（Astro 静态站 + Cloudflare Workers），文章怎么组织、评论怎么接，以及可以通过哪些方式联系我。</description><pubDate>Mon, 05 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;欢迎来到我的博客 👋&lt;/p&gt;
&lt;p&gt;这里是我的个人空间，主要记录技术折腾、开发笔记和一些生活碎片。&lt;/p&gt;
&lt;h2&gt;这个站点&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;域名&lt;/strong&gt;：&lt;a href=&quot;https://blog.juluo.work&quot;&gt;https://blog.juluo.work&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;技术栈&lt;/strong&gt;：&lt;a href=&quot;https://astro.build&quot;&gt;Astro&lt;/a&gt; + &lt;a href=&quot;https://svelte.dev&quot;&gt;Svelte&lt;/a&gt; + Tailwind CSS，部署在 Cloudflare Workers 上&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;主题&lt;/strong&gt;：基于 &lt;a href=&quot;https://github.com/saicaca/fuwari&quot;&gt;Fuwari&lt;/a&gt; 二次开发&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;统计&lt;/strong&gt;：Umami（无 Cookie、不追踪个人）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;评论&lt;/strong&gt;：Giscus（基于 GitHub Discussions，登录 GitHub 即可评论）&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;怎么联系我&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;QQ：1576586736&lt;/li&gt;
&lt;li&gt;GitHub：&lt;a href=&quot;https://github.com/juluogo&quot;&gt;https://github.com/juluogo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Email：&lt;a href=&quot;mailto:juluo@juluo.work&quot;&gt;juluo@juluo.work&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;RSS：&lt;a href=&quot;https://blog.juluo.work/rss.xml&quot;&gt;https://blog.juluo.work/rss.xml&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;关于内容&lt;/h2&gt;
&lt;p&gt;文章基本都是我自己实践后整理的笔记，尽量写清楚「为什么这么做」而不只是「怎么做」。
如果某篇文章帮到了你，欢迎在下方评论区留言（评论会同步到 GitHub Discussions）。&lt;/p&gt;
&lt;p&gt;这个博客陆续写过一些实现细节，比如&lt;a href=&quot;/posts/blog-view-counter/&quot;&gt;浏览量统计&lt;/a&gt;、&lt;a href=&quot;/posts/blog-static-search/&quot;&gt;站内搜索&lt;/a&gt;、&lt;a href=&quot;/posts/blog-post-encryption/&quot;&gt;给文章上密码&lt;/a&gt;，还有一篇讲网络排障的 &lt;a href=&quot;/posts/traceroute-packet-loss-misread/&quot;&gt;traceroute 误读&lt;/a&gt;。&lt;/p&gt;
</content:encoded></item></channel></rss>