<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://b.kemeow.top/</id>
  <title type="text">喵洛阁</title>
  <subtitle type="text">克喵的博客</subtitle>
  <updated>2026-09-11T00:00:00.000Z</updated>
  <author><name>克喵Moretti</name></author>
  <link rel="alternate" href="https://b.kemeow.top/"/>
  <link rel="self" href="https://b.kemeow.top/atom.xml"/>
  <generator uri="https://github.com/kmoretti/Firefly">Firefly v0.0.3</generator>
    <entry>
      <id>https://b.kemeow.top/posts/ech0-dynamic-source/</id>
      <title type="text">给博客动态页接 Ech0：一条代理链路和几个绕不开的坑</title>
      <published>2026-09-11T00:00:00.000Z</published>
      <updated>2026-09-11T00:00:00.000Z</updated>
      <author><name>克喵Moretti</name></author>
      <link rel="alternate" href="https://b.kemeow.top/posts/ech0-dynamic-source/"/>
      <summary type="text">动态页的数据源从 Memos 换成自部署的 Ech0。这篇记录整条数据链路：为什么走服务端同源代理而不是前端直连、echo/query 的分页怎么收尾、Markdown 和扩展卡片分别在哪一层渲染，以及实际踩到的几个坑——服务端 pageSize 钳位、时间戳是秒不是毫秒、匿名请求拿不到私密说说、推文嵌入被墙的降级方案。</summary>
      <content type="html"><![CDATA[<section><h2>起因<a href="#起因"><span>#</span></a></h2><p>博客的动态页之前一直吃 Memos 的数据，客户端通过 <code>/api/memos.json</code> 这个同源代理拿说说，体验没什么问题。但我的说说主力早就迁到了自部署的 <a href="/go/?u=aHR0cHMlM0ElMkYlMkZnaXRodWIuY29tJTJGbGluLXNub3clMkZFY2gw" target="_blank">Ech0</a> 上——一个 Go 写的单文件轻微博程序，带标签、扩展卡片（音乐、推文、视频这些）和原生点赞。两边维护同一批内容终究别扭，于是决定让动态页直接吃 Ech0 的数据。</p><p>目标其实很明确：</p><ol>
<li>动态页、侧边栏的「最新动态」都换成 Ech0 数据源</li>
<li>正文里的 Markdown 和内嵌 HTML 都要正常渲染</li>
<li>音乐、推文这类扩展卡片要能渲染出来，不能退化成一行链接</li>
<li>标签点击筛选、搜索、年份筛选这些现有交互一个都不能少</li>
<li>点赞直接用 Ech0 原生的，计数和 Ech0 站内互通</li>
</ol></section>
<section><h2>为什么走服务端代理<a href="#为什么走服务端代理"><span>#</span></a></h2><p>Ech0 的 CORS 是开放的，实测它会回显任意 Origin，理论上前端直接 fetch <code>m.081531.xyz</code> 就能用。但我还是选择了和 Memos 一样的方案：写一个服务端适配器，数据经 <code>/api/ech0.json</code> 同源代理吐给前端。原因有三个。</p><p>第一，动态页的消费端契约是 <code>DynamicEntry</code>，里面有一个<strong>已经渲染好的 <code>html</code> 字段</strong>。Markdown 转 HTML 这一步目前依赖 marked，跑在服务端。如果前端直连，要么把渲染逻辑搬进浏览器白白增加打包体积，要么每个消费组件各渲染一遍——两条路都比多写一个适配器难看。</p><p>第二，代理端点可以挂缓存头。我给的是 <code>Cache-Control: public, max-age=60, stale-while-revalidate=300</code>，热页面上新内容最多延迟一分钟，冷门时段先吐旧缓存、后台刷新。关键是这个端点是<strong>运行时按需执行</strong>的，不是构建时产物——我在 Ech0 发条说说，博客不用重新构建，下次请求就是新的。</p><p>第三，顺便把实例地址藏在了服务端，前端只见 <code>/api/ech0.json</code>，请求数也被代理端的去重逻辑收拢了。</p></section>
<section><h2>数据怎么拿<a href="#数据怎么拿"><span>#</span></a></h2><p>Ech0 对外的查询入口是 <code>POST /api/echo/query</code>，body 长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"page"</span><span>: </span><span>1</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"pageSize"</span><span>: </span><span>100</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"search"</span><span>: </span><span>""</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>"tagIds"</span><span>: [],</span></div></div><div><div><div>6</div></div><div><span>  </span><span>"sortBy"</span><span>: </span><span>"created_at"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>  </span><span>"sortOrder"</span><span>: </span><span>"desc"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>  </span><span>"dateFrom"</span><span>: </span><span>0</span><span>,</span></div></div><div><div><div>9</div></div><div><span>  </span><span>"dateTo"</span><span>: </span><span>0</span></div></div><div><div><div>10</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>返回 <code>data.items</code> 和 <code>data.total</code>。适配器做的事就是循环翻页拉全量，然后逐条转成动态系统的统一格式：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// fetchEchoPage 带三次重试，超时错误不重试——</span></div></div><div><div><div>2</div></div><div><span>// 上游响应慢的时候重试只会成倍拖长代理端点的等待时间</span></div></div><div><div><div>3</div></div><div><span>const</span><span> { </span><span>items</span><span>, </span><span>total</span><span>: </span><span>queryTotal</span><span> } </span><span>=</span><span> </span><span>await</span><span> </span><span>fetchEchoPage</span><span><span>(</span><span>apiUrl</span><span>, {</span></span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>page</span><span>,</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>pageSize</span><span>,</span></div></div><div><div><div>6</div></div><div><span>  </span><span>search</span><span>:</span><span> </span><span>""</span><span>,</span></div></div><div><div><div>7</div></div><div><span>  </span><span>tagIds</span><span>:</span><span> [],</span></div></div><div><div><div>8</div></div><div><span>  </span><span>sortBy</span><span>:</span><span> </span><span>"created_at"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>  </span><span>sortOrder</span><span>:</span><span> </span><span>"desc"</span><span>,</span></div></div><div><div><div>10</div></div><div><span>  </span><span>dateFrom</span><span>:</span><span> </span><span>0</span><span>,</span></div></div><div><div><div>11</div></div><div><span>  </span><span>dateTo</span><span>:</span><span> </span><span>0</span><span>,</span></div></div><div><div><div>12</div></div><div><span>});</span></div></div><div><div><div>13</div></div><div><span><span>allEchos</span><span> </span></span><span>=</span><span><span> </span><span>allEchos</span><span>.</span></span><span>concat</span><span><span>(</span><span>items</span><span>);</span></span></div></div><div><div><div>14</div></div><div><span><span>total</span><span> </span></span><span>=</span><span><span> </span><span>queryTotal</span><span> </span></span><span>||</span><span><span> </span><span>allEchos</span><span>.</span></span><span>length</span><span>;</span></div></div><div><div><div>15</div></div><div><span>if</span><span><span> (</span><span>allEchos</span><span>.</span></span><span>length</span><span> </span><span>&gt;=</span><span><span> </span><span>total</span><span> </span></span><span>||</span><span><span> </span><span>items</span><span>.</span></span><span>length</span><span> </span><span>&lt;</span><span><span> </span><span>pageSize</span><span>) </span></span><span>break</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>单条 Echo 转换的核心就是字段映射：<code>content</code> 丢给 marked 出 <code>html</code>，<code>created_at</code> 乘 1000 变毫秒，<code>tags</code> 从对象数组抽成名字数组，<code>fav_count</code> 透传成 <code>likes</code>，<code>extension</code> 原样透传。图片单独提出来放进 <code>images</code> 数组，交给现有的画廊组件。</p><p>Markdown 渲染用的 marked 实例和 Memos 适配器保持同一套配置：GFM 开、单换行转 <code>&lt;br&gt;</code>、链接强制新标签页打开。有个细节是<strong>图片渲染器置空</strong>——Markdown 里的图片语法不直接输出 <code>&lt;img&gt;</code>，而是用 lexer 把图片 token 摘出来塞进 <code>images</code> 数组，最后由画廊统一渲染，避免正文和画廊出现两张一样的图。</p></section>
<section><h2>扩展卡片：透传，不要在服务端拼 HTML<a href="#扩展卡片透传不要在服务端拼-html"><span>#</span></a></h2><p>Ech0 的说说可以挂扩展：<code>extension</code> 字段带着 <code>type</code> 和 <code>payload</code>。六种类型的 payload 我逐个确认过：</p>

<table><thead><tr><th>type</th><th>payload</th></tr></thead><tbody><tr><td>MUSIC</td><td><code>{ url }</code>（网易云/QQ 音乐链接）</td></tr><tr><td>VIDEO</td><td><code>{ videoId }</code>（B 站 BV 号或 YouTube id）</td></tr><tr><td>GITHUBPROJ</td><td><code>{ repoUrl }</code></td></tr><tr><td>WEBSITE</td><td><code>{ site, title }</code></td></tr><tr><td>LOCATION</td><td><code>{ latitude, longitude, placeholder }</code></td></tr><tr><td>TWEET</td><td><code>{ url, username }</code></td></tr></tbody></table><p>第一反应是在适配器里直接把卡片拼成 HTML 塞进正文，零前端代码。但很快发现走不通：推文卡片靠 <code>platform.twitter.com/widgets.js</code> 渲染，脚本必须客户端加载；音乐卡片要调 Meting API 解析出真实音频地址。既然音乐和推文注定要写客户端代码，干脆六种卡片全部结构化透传，由一个 <code>&lt;dynamic-extension&gt;</code> 自定义元素在前端按 type 分发渲染。适配器保持干净，卡片样式走博客自己的主题变量，明暗模式白送。</p><p>其中 LOCATION 是个例外：它不渲染卡片，而是映射到卡片 meta 行的位置字段，用博客现成的位置样式展示。同一个信息渲染两遍没有意义。</p><p>音乐卡片的实现是中庸路线：先用和 Ech0 同源的规则解析出 <code>server/type/id</code>（网易云和 QQ 音乐两套正则），再去 Meting API 换封面、歌名、歌手和音频直链，原生 <code>&lt;audio&gt;</code> 播放。Ech0 站内那个带歌词滚动的完整播放器在说说卡片场景里太重了，而且博客本身已有全局音乐播放器，没必要重复建设。Meting 全挂或者音频加载失败，就降级成一个跳转链接卡。</p></section>
<section><h2>踩到的坑<a href="#踩到的坑"><span>#</span></a></h2><p><strong>服务端把 pageSize 钳到 100。</strong> 我一开始传 500 想一页拉完，响应看起来也正常，实际只回了 100 条。查源码才发现 <code>QueryEchos</code> 里有硬钳位：<code>if queryDto.PageSize &gt; 100 { queryDto.PageSize = 100 }</code>。所以翻页循环必须老老实实按 100 一页走，终止条件用 <code>total</code> 和「短页」双重判断。</p><p><strong><code>created_at</code> 是 Unix 秒，不是毫秒。</strong> 直接 <code>new Date(echo.created_at)</code> 会得到 1970 年，年份筛选直接全军覆没。乘 1000 才对。</p><p><strong>匿名请求拿不到私密说说。</strong> <code>echo/query</code> 是 optional auth 的路由，不带 token 时服务端只返回公开内容。这对博客来说恰好是对的——但联调时如果发现某条说说在博客上不出现，先去 Ech0 检查它是不是设了私密，别急着查代码。</p><p><strong>推文嵌入必须准备降级。</strong> <code>platform.twitter.com</code> 在国内网络基本是玄学，能加载出来体验最好，加载不出来不能留白。我的做法是卡片渲染时才懒加载 widgets.js，起一个 6 秒的定时器，到点还没出现 iframe 就把整个卡片替换成 <code>@username</code> 的链接卡。Ech0 前端用的也是同一套思路，阈值直接抄了。</p><p><strong>内嵌 HTML 是原样输出的。</strong> marked 对正文里的 HTML 不做转义直接透传，Ech0 的内容里也确实会有 HTML 片段。这里没有做 sanitize——数据源是我自己的实例、我自己发的内容，信任边界是清晰的。但如果要做一个多人共用的方案，这一层必须补上，不能照抄。</p><p><strong>原生点赞有限流。</strong> <code>PUT /api/echo/like/{id}</code> 是公开接口，但服务端做了按 id 的幂等限流（每小时窗口内每个说说最多两次）。正常点一下完全够用，连续狂点会被拒——前端本来就有 localStorage 防重复，两层加起来正好。要说明的是 localStorage 只是软限制，清了浏览器数据就能再点，这类无身份方案的天花板就这样，够用。</p><p><strong>数据源切换的优先级要定死。</strong> 配置里同时存在 <code>ech0.enable</code> 和 <code>memos.enable</code> 两个开关，判定链是 <code>ech0 &gt; memos &gt; apiUrl</code>。不搞混排——两个来源的说说排序、锚点、点赞语义都不一样，混在一起是自找麻烦。想换数据源，改一个布尔值就行。</p></section>
<section><h2>收尾<a href="#收尾"><span>#</span></a></h2><p>现在动态页的数据链路是：Ech0 实例 → 服务端适配器（marked 渲染 + 字段映射）→ <code>/api/ech0.json</code>（60 秒缓存）→ 前端卡片流。扩展卡片结构化透传给自定义元素渲染，点赞双轨制——Ech0 的说说用原生计数，本地和其他数据源继续走 star-vote，按钮长一个样。</p><p>整套东西都是配置驱动的，<code>dynamicConfig.ech0.enable</code> 一个开关决定生死，关掉就自动回落 Memos，不残留任何请求。代码在 <a href="/go/?u=aHR0cHMlM0ElMkYlMkZnaXRodWIuY29tJTJGa21vcmV0dGklMkZGaXJlZmx5" target="_blank">kmoretti/Firefly</a> 的 <code>src/utils/ech0-adapter.ts</code> 和 <code>src/components/pages/dynamic/dynamic-extensions.ts</code>，想抄作业的自取。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://b.kemeow.top/posts/custom-context-menu/</id>
      <title type="text">自定义右键菜单：配置驱动、上下文感知，和三个隐蔽的坑</title>
      <published>2026-09-07T00:00:00.000Z</published>
      <updated>2026-09-07T00:00:00.000Z</updated>
      <author><name>克喵Moretti</name></author>
      <link rel="alternate" href="https://b.kemeow.top/posts/custom-context-menu/"/>
      <summary type="text">给博客写了个自定义右键菜单：所有菜单项走配置文件，右键位置和页面能力不同，菜单内容跟着变，Ctrl+右键还能唤出原版菜单。文章按「拦截、感知、定位」三条线讲实现思路，顺带记录三个排查起来很折磨的坑——Stylus 嵌套少写一个 &amp;、布尔守卫写反、dev 正常但 preview 跑的是旧构建。</summary>
      <content type="html"><![CDATA[<section><h2>起因<a href="#起因"><span>#</span></a></h2><p>刷别人博客的时候见过几回自定义右键菜单：右键出来的不是浏览器原版那一坨，而是主题自己画的一列选项，前进后退、复制链接、进阅读模式一应俱全。这东西不算必要功能，但点开的那一下很有「这站被人认真打理过」的感觉。决定自己也整一个。</p><p>先把想要的形态列清楚：</p><ol>
<li><strong>所有菜单项走配置</strong>。加一项、删一项、调顺序，改配置文件就行，不碰组件</li>
<li><strong>右键在哪，菜单不一样</strong>。选中文本时出「复制、搜索」，链接上出「新窗口打开、复制地址」，图片上出「打开图片、复制图片地址」</li>
<li><strong>页面能力不同，菜单也不一样</strong>。文章页才有「回到评论」，装了音乐播放器才出播放控制，能进阅读模式的页面才给「阅读模式」</li>
<li><strong>Ctrl+右键保留原版菜单</strong>。不赶尽杀绝，偶尔还得用原版的「检查」</li>
<li><strong>只在桌面端生效</strong>。窄屏和触屏没这个交互习惯，小于某个宽度直接放行</li>
</ol><p>清单看着长，落到实现上其实就一句话：菜单内容是动态的，但菜单本身必须是声明式的。</p></section>
<section><h2>思路：一份配置说了算<a href="#思路一份配置说了算"><span>#</span></a></h2><p>组件被压得很薄——配置决定「有什么」，运行时决定「显示什么、放哪」，CSS 只负责「藏」和「放」。</p><p>配置长这样：</p><div><div><div><figure><figcaption><span>src/config/contextMenuConfig.ts</span><span>展开</span><span>收起</span></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>contextMenuConfig</span><span>:</span><span> </span><span>ContextMenuConfig</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>enable</span><span>:</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>minWidth</span><span>:</span><span> </span><span>1024</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>// 在输入框/文本域/可编辑元素内右键时展示原版菜单</span></div></div><div><div><div>5</div></div><div><span>  </span><span>showNativeInEditable</span><span>:</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>6</div></div><div><span>  </span><span>// 搜索选中内容用的搜索引擎，{query} 会被替换成选中文本</span></div></div><div><div><div>7</div></div><div><span>  </span><span>searchEngine</span><span>:</span><span> </span><span>"https://www.baidu.com/s?wd={query}"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>  </span><span>contextItems</span><span>:</span><span> {</span></div></div><div><div><div>9</div></div><div><span>    </span><span>copySelection</span><span>:</span><span> </span><span>true</span><span>, </span><span>// 有选中文本：复制</span></div></div><div><div><div>10</div></div><div><span>    </span><span>searchSelection</span><span>:</span><span> </span><span>true</span><span>, </span><span>// 有选中文本：搜索</span></div></div><div><div><div>11</div></div><div><span>    </span><span>openLink</span><span>:</span><span> </span><span>true</span><span>, </span><span>// 右键链接：新窗口打开</span></div></div><div><div><div>12</div></div><div><span>    </span><span>copyLink</span><span>:</span><span> </span><span>true</span><span>, </span><span>// 右键链接：复制地址</span></div></div><div><div><div>13</div></div><div><span>    </span><span>openImage</span><span>:</span><span> </span><span>true</span><span>, </span><span>// 右键图片：打开图片</span></div></div><div><div><div>14</div></div><div><span>    </span><span>copyImage</span><span>:</span><span> </span><span>true</span><span>, </span><span>// 右键图片：复制地址</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>16</div></div><div><span>  </span><span>items</span><span>:</span><span> [</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"history-back"</span><span> },</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"history-forward"</span><span> },</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"reload"</span><span>, </span><span>divider</span><span>:</span><span> </span><span>true</span><span> },</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"music-toggle-play"</span><span> },</span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"music-prev"</span><span> },</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"music-next"</span><span> },</span></div></div><div><div><div>23</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"music-volume"</span><span> },</span></div></div><div><div><div>24</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"toggle-theme"</span><span>, </span><span>divider</span><span>:</span><span> </span><span>true</span><span> },</span></div></div><div><div><div>25</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"reading-mode"</span><span> },</span></div></div><div><div><div>26</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"back-to-comment"</span><span> },</span></div></div><div><div><div>27</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"back-to-top"</span><span> },</span></div></div><div><div><div>28</div></div><div><span><span>    </span></span><span>{ </span><span>action</span><span>:</span><span> </span><span>"copy-url"</span><span> },</span></div></div><div><div><div>29</div></div><div><span><span>  </span></span><span>],</span></div></div><div><div><div>30</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p><code>items</code> 里每项是一个内置 action，<code>divider</code> 控制分组分隔线。想加外链或自定义行为，用 <code>action: "custom"</code> 配 <code>link</code> 或者一段函数体，图标不指定时组件会按 <code>item.icon ?? 默认图标</code> 兜底。类型在 <code>src/types/contextMenuConfig.ts</code> 里收着，配置写错了构建期就能报出来。</p><p>早期版本试过直接在模板里堆菜单节点，结果改个顺序要动模板，加一项要动模板，上下文显隐逻辑还散落在各处。收进配置之后，这些痛苦全消失了——这也是我坚持「配置驱动」最大的理由：改配置的心智负担和改组件完全不是一个量级。</p></section>
<section><h2>拦截：先想清楚什么时候「不管」<a href="#拦截先想清楚什么时候不管"><span>#</span></a></h2><p>写自定义菜单，最先要想清楚的不是怎么接管，而是什么时候不管。放行条件比拦截条件好写——前者每条都是独立的，后者要把所有反例串成一长串否定。</p><p>我的放行条件就三条：</p><div><figure><figcaption><span>src/components/features/ContextMenu.astro</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>document</span><span>.</span></span><span>addEventListener</span><span>(</span><span>"contextmenu"</span><span><span>, (</span><span>e</span><span>) </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>if</span><span><span> (</span><span>e</span><span>.</span></span><span>ctrlKey</span><span>) </span><span>return</span><span>; </span><span>// Ctrl+右键：放行原版菜单</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span><span> (</span><span>window</span><span>.</span></span><span>innerWidth</span><span> </span><span>&lt;</span><span><span> </span><span>minWidth</span><span>) </span></span><span>return</span><span>; </span><span>// 窄屏：没这个交互</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>target</span><span> </span><span>=</span><span><span> </span><span>e</span><span>.</span></span><span>target</span><span> </span><span>instanceof</span><span> </span><span>Element</span><span> </span><span>?</span><span><span> </span><span>e</span><span>.</span></span><span>target</span><span> </span><span>:</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>5</div></div><div><span>  </span><span>const</span><span> </span><span>isFormField</span><span> </span><span>=</span></div></div><div><div><div>6</div></div><div><span>    </span><span>!!</span><span><span>target</span><span>?.</span></span><span>closest</span><span>(</span><span>"input, textarea, select"</span><span>) </span><span>||</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>(</span><span>target</span><span> </span><span>instanceof</span><span> </span><span>HTMLElement</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>target</span><span>.</span></span><span>isContentEditable</span><span>);</span></div></div><div><div><div>8</div></div><div><span>  </span><span>if</span><span><span> (</span><span>isFormField</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>showNativeInEditable</span><span>) </span></span><span>return</span><span>; </span><span>// 输入框里：原版菜单更好用</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>e</span><span>.</span><span>preventDefault</span><span>();</span></div></div><div><div><div>10</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>流程画出来是这样：</p><div><div><div><div><span></span></div><div><span><p>是</p></span></div><div><span><p>否</p></span></div><div><span><p>是</p></span></div><div><span><p>否</p></span></div><div><span><p>是</p></span></div><div><span><p>是</p></span></div><div><span><p>否</p></span></div><div><span><p>否</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>contextmenu 事件</p></span></div><div><span><p>按住 Ctrl?</p></span></div><div><span><p>放行：原版菜单</p></span></div><div><span><p>视口窄于配置宽度?</p></span></div><div><span><p>焦点在输入框等可编辑区?</p></span></div><div><span><p>showNativeInEditable?</p></span></div><div><span><p>preventDefault 接管</p></span></div><div><span><p>探测上下文 → 挂标记类</p></span></div><div><span><p>隐身测量 → 定位</p></span></div><div><span><p>显示</p></span></div>
</div><div><div><span></span></div><div><span><p>是</p></span></div><div><span><p>否</p></span></div><div><span><p>是</p></span></div><div><span><p>否</p></span></div><div><span><p>是</p></span></div><div><span><p>是</p></span></div><div><span><p>否</p></span></div><div><span><p>否</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>contextmenu 事件</p></span></div><div><span><p>按住 Ctrl?</p></span></div><div><span><p>放行：原版菜单</p></span></div><div><span><p>视口窄于配置宽度?</p></span></div><div><span><p>焦点在输入框等可编辑区?</p></span></div><div><span><p>showNativeInEditable?</p></span></div><div><span><p>preventDefault 接管</p></span></div><div><span><p>探测上下文 → 挂标记类</p></span></div><div><span><p>隐身测量 → 定位</p></span></div><div><span><p>显示</p></span></div>
</div></div></div><p>另外两个工程细节：绑定前先检查 <code>window.__fireflyContextMenu</code> 防止重复挂监听（Astro 页面脚本在某些跳转场景会重新执行，不设防会越绑越多）；关菜单的途径比开菜单多得多——点遮罩、Esc、窗口失焦、滚动、resize、路由跳转全都要收尾，其中滚动监听用 capture 加 passive 就够了，反正目的只是把菜单收起来。</p><p>这段代码里埋着本篇坑二的祸根，先按下不表。</p></section>
<section><h2>上下文感知：两组标记类<a href="#上下文感知两组标记类"><span>#</span></a></h2><p>探测上下文，说穿了就是把一堆布尔判断折算成根元素上的类名：</p><div><figure><figcaption><span>src/components/features/ContextMenu.astro</span></figcaption><pre><code><div><div><div>1</div></div><div><span>// 右键位置：点在哪</span></div></div><div><div><div>2</div></div><div><span><span>menu</span><span>.</span></span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"show-selection"</span><span><span>, </span><span>hasSelection</span><span>);</span></span></div></div><div><div><div>3</div></div><div><span><span>menu</span><span>.</span></span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"show-link"</span><span>, </span><span>!!</span><span><span>link</span><span>);</span></span></div></div><div><div><div>4</div></div><div><span><span>menu</span><span>.</span></span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"show-image"</span><span>, </span><span>!!</span><span><span>img</span><span>);</span></span></div></div><div><div><div>5</div></div><div><span>// 页面能力：这页有什么</span></div></div><div><div><div>6</div></div><div><span><span>menu</span><span>.</span></span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"ctx-has-music"</span><span>, </span><span>typeof</span><span> </span><span>music</span><span>()?.</span><span>getState</span><span> </span><span>===</span><span> </span><span>"function"</span><span>);</span></div></div><div><div><div>7</div></div><div><span><span>menu</span><span>.</span></span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"ctx-has-comments"</span><span>, </span><span>!!</span><span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>"post-comments"</span><span>));</span></div></div><div><div><div>8</div></div><div><span><span>menu</span><span>.</span></span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"ctx-has-reading"</span><span>, </span><span>isReadingModeAvailable</span><span>());</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>两组前缀语义不同：<code>show-*</code> 描述「右键点在哪」，每次打开都重新探测；<code>ctx-has-*</code> 描述「这个页面有什么能力」。CSS 那边配合很机械——扩展项默认藏起来，标记类出现才放行：</p><div><figure><figcaption><span>src/components/features/ContextMenu.astro（样式部分）</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#context-menu</span></div></div><div><div><div>2</div></div><div><span>    </span><span>.ctx-need-selection</span></div></div><div><div><div>3</div></div><div><span><span>        </span></span><span>display: </span><span>none</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>&amp;</span><span>.show-selection</span></div></div><div><div><div>5</div></div><div><span>        </span><span>.ctx-need-selection</span></div></div><div><div><div>6</div></div><div><span><span>            </span></span><span>display: </span><span>flex</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>注意第三行的 <code>&amp;</code>，它是坑一的主角，后面细说。</p><p>能力探测里有几个值得展开的点：</p><p>音乐播放器是 Svelte island，把实例挂到 <code>window.__fireflyMusic</code> 上。但 island 水合有时机问题——页面刚开就右键，这个全局对象可能还不存在；就算存在了，也得确认它真能用。所以探测写成 <code>typeof getState === "function"</code> 而不是简单判存在：对象在、方法还没挂上的窗口期里，单纯判存在会误报。</p><p>评论区探测最朴素：<code>document.getElementById("post-comments")</code> 找得到节点就有评论。因为评论区只渲染在文章页，这一行天然把「文章页」的语义带上了，不用再判断路径。</p><p>阅读模式的判断和站内阅读模式按钮共用同一个谓词：路径在 <code>/posts/</code> 下且视口够宽。两处逻辑一个标准，避免「按钮说能进、菜单里却没有」的尴尬。</p></section>
<section><h2>定位：先隐身，量完再现身<a href="#定位先隐身量完再现身"><span>#</span></a></h2><p>弹出位置看着五分钟搞定，实际有个经典陷阱：菜单贴近屏幕右缘时，音量条那组二级菜单会探出屏幕。等渲染出来再发现溢出，视觉上就是闪一下。</p><p>做法是显示之前先隐身量尺寸：</p><div><figure><figcaption><span>src/components/features/ContextMenu.astro</span></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>SUBMENU_SPACE</span><span> </span><span>=</span><span> </span><span>240</span><span>; </span><span>// 给二级菜单留的空间</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span><span>menu</span><span>.</span></span><span>classList</span><span>.</span><span>remove</span><span>(</span><span>"hidden"</span><span>, </span><span>"ctx-flip-left"</span><span>);</span></div></div><div><div><div>4</div></div><div><span><span>menu</span><span>.</span></span><span>style</span><span>.</span><span>visibility</span><span> </span><span>=</span><span> </span><span>"hidden"</span><span>;</span></div></div><div><div><div>5</div></div><div><span>const</span><span> </span><span>rect</span><span> </span><span>=</span><span><span> </span><span>menu</span><span>.</span></span><span>getBoundingClientRect</span><span>();</span></div></div><div><div><div>6</div></div><div><span>const</span><span> </span><span>flipLeft</span><span> </span><span>=</span><span><span> </span><span>x</span><span> </span></span><span>+</span><span><span> </span><span>rect</span><span>.</span></span><span>width</span><span> </span><span>+</span><span> </span><span>SUBMENU_SPACE</span><span> </span><span>&gt;</span><span><span> </span><span>vw</span><span>;</span></span></div></div><div><div><div>7</div></div><div><span>if</span><span><span> (</span><span>flipLeft</span><span>) </span><span>menu</span><span>.</span></span><span>classList</span><span>.</span><span>add</span><span>(</span><span>"ctx-flip-left"</span><span>);</span></div></div><div><div><div>8</div></div><div><span><span>menu</span><span>.</span></span><span>style</span><span>.</span><span>visibility</span><span> </span><span>=</span><span> </span><span>""</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>关键在用 <code>visibility: hidden</code> 而不是 <code>display: none</code>——前者占据布局、能量出真实尺寸，后者量出来全是 0。量完该翻转就加 <code>ctx-flip-left</code> 让二级菜单朝左开，最后再去掉隐身。整个过程在同一帧内完成，肉眼看不到闪。</p></section>
<section><h2>明暗适配<a href="#明暗适配"><span>#</span></a></h2><p>这部分反而最省心：颜色全部走站内 CSS 变量，<code>var(--card-bg)</code>、<code>var(--btn-regular-bg-hover)</code> 这类，主题切换时变量一变菜单自动跟上。唯一手写的是深色模式下的文字颜色，站内惯例是 <code>:global(.dark)</code> 里配一个半透明白，照抄即可。</p></section>
<section><h2>踩坑清单<a href="#踩坑清单"><span>#</span></a></h2><p>下面按踩到的顺序讲三个坑。共同点是：每一个的现象都在暗示「代码是对的」，而真凶全在别处。</p><section><h3>坑一：Stylus 嵌套少写一个 &amp;，样式全灭<a href="#坑一stylus-嵌套少写一个-样式全灭"><span>#</span></a></h3><p>现象：文章页右键，音乐控制和阅读模式死活不出现。DevTools 里看元素，<code>ctx-has-music</code> 标记类明明白白挂在根元素上，对应的 CSS 规则也能搜到——就是不生效。</p><p>当时的排查动作是拿源码里的选择器去控制台对元素做 <code>matches()</code> 验证，结果永远是 false，一度把我带偏到怀疑类名拼错。后来才反应过来：Astro 的 scoped style 编译时会往每一段选择器里塞 <code>data-astro-cid-xxx</code> 属性，源码里的 <code>#context-menu .ctx-has-music</code> 和运行时真实的选择器根本不是同一串字符。<strong>调样式问题，看编译产物，别看源码。</strong></p><p>编译产物（简化了无关细节）长这样：</p><div><figure><figcaption><span>编译产物：坏的</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#context-menu</span><span>[</span><span>data-astro-cid-mdguodk3</span><span>] </span><span>.ctx-has-music</span><span> </span><span>.ctx-need-music</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>display: </span><span>flex</span><span>;</span></div></div><div><div><div>3</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>而实际的 DOM 结构是这样的：</p><div><figure><figcaption><span>简化后的结构</span></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"context-menu"</span><span> </span><span>class</span><span>=</span><span>"ctx-has-music"</span><span> </span><span>data-astro-cid-mdguodk3</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"ctx-need-music"</span><span>&gt;音量&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span>&lt;/</span><span>div</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>看出问题了吗？坏的选择器要求 <code>.ctx-has-music</code> 是 <code>#context-menu</code> 的<strong>后代</strong>，可这个类挂在根元素自己身上，后代里根本不存在——这条规则永远匹配不到任何元素，于是菜单项一直保持 <code>display: none</code>。</p><p>根源在 Stylus 嵌套少写了一个 <code>&amp;</code>：</p><div><figure><figcaption><span>错：第一层没有 &amp;</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#context-menu</span></div></div><div><div><div>2</div></div><div><span>    </span><span>.ctx-has-music</span></div></div><div><div><div>3</div></div><div><span>        </span><span>.ctx-need-music</span></div></div><div><div><div>4</div></div><div><span><span>            </span></span><span>display: </span><span>flex</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption><span>对：&amp;. 连写在根元素上</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#context-menu</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>&amp;</span><span>.ctx-has-music</span></div></div><div><div><div>3</div></div><div><span>        </span><span>.ctx-need-music</span></div></div><div><div><div>4</div></div><div><span><span>            </span></span><span>display: </span><span>flex</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>一字之差，编译产物从「空格分隔找后代」变成「连写找自身」：</p><div><figure><figcaption><span>编译产物：对的</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#context-menu</span><span>[</span><span>data-astro-cid-mdguodk3</span><span>]</span><span>.ctx-has-music</span><span> </span><span>.ctx-need-music</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>display: </span><span>flex</span><span>;</span></div></div><div><div><div>3</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div></div><div>Tip</div></div><div><p>Astro/Stylus 里凡是「状态类挂在容器根上、控制后代显隐」的写法，嵌套第一层必须 <code>&amp;</code> 开头。写完别信源码，去编译产物里确认一眼。</p></div></div></section><section><h3>坑二：布尔守卫写反了<a href="#坑二布尔守卫写反了"><span>#</span></a></h3><p><code>showNativeInEditable</code> 这个配置，本意是「输入框里右键用原版菜单」。第一版代码：</p><div><figure><figcaption><span>src/components/features/ContextMenu.astro</span></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span><span> (</span><span>isFormField</span><span> </span></span><span>&amp;&amp;</span><span> </span><span>!</span><span><span>showNativeInEditable</span><span>) </span></span><span>return</span><span>; </span><span>// [!code --]</span></div></div><div><div><div>2</div></div><div><span>if</span><span><span> (</span><span>isFormField</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>showNativeInEditable</span><span>) </span></span><span>return</span><span>; </span><span>// [!code ++]</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>单看每一半都没错：<code>isFormField</code> 判断是 Copy 来的没毛病，<code>showNativeInEditable</code> 字段名也没拼错。但把条件念出来就露馅了——「在输入框里，<strong>且</strong> 配置说要用原版菜单，所以放行」，念成第一版就是「配置开着，反而拦截」，语义整个颠倒。</p><p>这个 bug 的刁钻之处在于：默认配置下功能「看起来正常」，只是输入框里也弹自定义菜单；要不是特意去输入框里右键试了一把，可能很久都发现不了。</p><p>后来养成的自查习惯：布尔条件写完念一遍。比如「如果 A 且非 B，放行」翻译成人话是「B 开着反而拦着」——一念就穿帮。</p></section><section><h3>坑三：dev 里好好的，preview 里菜单缺项<a href="#坑三dev-里好好的preview-里菜单缺项"><span>#</span></a></h3><p>最玄学的一个。源码翻来覆去看没问题，dev 服务里右键 12 项全出；切到 <code>pnpm preview</code>，音乐控制和阅读模式没了，连样式都对不上。</p><p>第一反应是「preview 模式行为不一样」——还真不是。真相是：</p><p><code>astro preview</code> 只伺服上次 <code>astro build</code> 产出的 <code>dist/</code>，它自己不编译任何东西。而那份 <code>dist/</code> 是坑一修复<strong>之前</strong>构建的，里面躺着的还是缺 <code>&amp;</code> 的坏选择器。dev 实时编译所以一直是对的，preview 开的是罐头所以一直是旧的。</p><p>验证方法也简单：直接翻 <code>dist/_astro/*.css</code>，拿「连写」的正确形态去搜，搜不到；拿「后代链」的坏形态一搜一个准。罐头内容和菜谱对不上，问题就不在菜谱。</p><div><div><div></div><div>Warning</div></div><div><p>排查样式问题前先问自己一句：<strong>我看到的是哪份代码？</strong> dev 反映源码，preview 反映上次构建，部署环境反映更早的构建。三者不同步时，现象会互相矛盾，但每一份都如实反映了它对应的那份代码。改完记得重新 build，别让旧产物陪跑。</p></div></div></section></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>右键菜单是典型的「平时没人看，写起来细节不少」的功能。回头看，思路层面就三件事：放行条件先于拦截条件想清楚、状态折算成标记类交给 CSS、量完尺寸再显示。剩下的坑大多出在源码和运行时之间的那些层上——Stylus 会编译选择器，Astro 会改写选择器，preview 伺服的是历史产物。每一层都在帮忙，也都在让你离真相远一步。</p><p>主题完整代码在这里：</p><a href="https://github.com/kmoretti/firefly" target="_blank"><div><div><div><div></div><div>kmoretti</div></div><div>/</div><div>firefly</div></div><div></div></div><div>🍀Firefly, fresh and aesthetic Astro blog theme template. </div><div><div>—</div><div>—</div><div>MIT</div><span>Astro</span></div></a></section>]]></content>
    </entry>
    <entry>
      <id>https://b.kemeow.top/posts/swpp-cache-practice/</id>
      <title type="text">引入 swpp：先定规则，再谈缓存，以及集成路上踩过的几个坑</title>
      <published>2026-09-07T00:00:00.000Z</published>
      <updated>2026-09-07T00:00:00.000Z</updated>
      <author><name>克喵Moretti</name></author>
      <link rel="alternate" href="https://b.kemeow.top/posts/swpp-cache-practice/"/>
      <summary type="text">给博客接入了 swpp——一个规则驱动、把「精确控制缓存」当主业、把离线当副产品的 Service Worker 方案。文章先讲清楚它那套有点反直觉的缓存协议（false 不缓存、-1 永久缓存、版本号驱动失效），再展示双端 matchCacheRule 与构建编排，最后复盘六个集成期真实踩到的坑：saveFiles 撞上已存在文件、HTML 注入要求结构完整的 head、runOnBrowser 的序列化函数不能碰闭包、永久缓存的名不副实、HTTP 缓存头不配合版本检查失灵，以及 Service Worker 永远慢半拍的验证节奏。</summary>
      <content type="html"><![CDATA[<section><h2>起因<a href="#起因"><span>#</span></a></h2><p>博客是纯静态站，内容基本不变，但图片字体一堆，每次访问都回源有点浪费；偶尔断网，也想让看过的东西还能打开。于是动了加 Service Worker 的念头。</p><p>目标先列清楚：</p><ol>
<li><strong>静态资源少回源</strong>，但不要「缓存一切」的黑盒</li>
<li><strong>页面能离线访问</strong>，可这必须是精确控制的副产品，不是拿全站预缓存换的</li>
<li><strong>发新文章后老访客要能尽快看到</strong>，不能永久困在旧页面里</li>
<li><strong>出问题要能一键撤退</strong>，不至于把用户锁死在坏缓存上</li>
<li><strong>整个开关走配置</strong>，<code>src/config</code> 里一行 <code>enable: false</code> 就能下掉</li>
</ol><p>第一反应是 Workbox：generateSW 一把梭，manifest 预缓存全站 + runtime 正则。但它的默认心智是「能缓存就缓存，更新靠版本号整体替换」，规则写起来是正则集合，跟我的诉求有点错位。翻到 swpp 的时候，它文档里那句「缓存控制是主业，离线只是副作用」正中下怀——于是就有了这篇。</p></section>
<section><h2>swpp 是什么：规则驱动的缓存<a href="#swpp-是什么规则驱动的缓存"><span>#</span></a></h2><p>swpp（npm 包叫 <code>swpp-backends</code>，文档在 <a href="/go/?u=aHR0cHMlM0ElMkYlMkZzd3BwLmttYXIudG9wJTJGJTI1RUYlMjVCQyUyNTg5JTI1RTglMjVCNyUyNTlG" target="_blank">https://swpp.kmar.top/）跟</a> Workbox 最大的区别在心智模型上。Workbox 默认生成一份资源清单，把「要不要缓存、什么时候过期」交给一套运行时逻辑；swpp 反着来——<strong>默认不缓存</strong>，每个请求进不进缓存、进了怎么失效，都由一段显式的判定函数说了算。</p><section><h3>缓存协议：false 与 -1<a href="#缓存协议false-与--1"><span>#</span></a></h3><p>swpp 的规则返回值语义很简单，但和直觉相反的地方恰恰在这里：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 返回 false —— 不缓存，每次照常走网络</span></div></div><div><div><div>2</div></div><div><span>// 返回 -1  —— 永久缓存，且不参与 swpp 的版本更新检查</span></div></div><div><div><div>3</div></div><div><span>function</span><span> </span><span>matchCacheRule</span><span><span>(</span><span>url</span></span><span>:</span><span> </span><span>URL</span><span>)</span><span>:</span><span> </span><span>number</span><span> </span><span>|</span><span> </span><span>false</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>-1</code> 看着像「永远缓存」，实际上它真正的意思是「<strong>这份资源信任文件名就是版本，交给 swpp 管反而多余</strong>」。所以它只适合两类东西：</p><ul>
<li>文件名带内容哈希的构建产物（如 <code>/_astro/*.js</code>），内容一变文件名必变，旧引用自然失效</li>
<li>由版本号机制统一失效的 HTML 页面</li>
</ul><p>其余资源——API、跨域资源、任何会原地更新的东西——一律 <code>false</code>，别指望 SW 替你兜底。</p></section><section><h3>版本号驱动失效：update.json<a href="#版本号驱动失效updatejson"><span>#</span></a></h3><p>离线是副产品，那新内容怎么推送？swpp 的做法是给缓存引入「版本」概念。生成的 sw.js 里藏着几个关键常量：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>CACHE_NAME</span><span> </span><span>=</span><span> </span><span>"kmarBlogCache"</span><span>;</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>UPDATE_JSON_URL</span><span> </span><span>=</span><span> </span><span>"swpp/update.json"</span><span>;</span></div></div><div><div><div>3</div></div><div><span>const</span><span> </span><span>VERSION_PATH</span><span> </span><span>=</span><span> </span><span>"https://id.v3/"</span><span>; </span><span>// 版本元数据以特殊 key 存在 Cache Storage</span></div></div><div><div><div>4</div></div><div><span>const</span><span> </span><span>INVALID_KEY</span><span> </span><span>=</span><span> </span><span>"X-Swpp-Invalid"</span><span>;  </span><span>// 失效标记，打在待更新的缓存 entry 上</span></div></div><div><div><div>5</div></div><div><span>const</span><span> </span><span>UPDATE_CD</span><span> </span><span>=</span><span> </span><span>600000</span><span>;              </span><span>// 两次版本检查的最小间隔（10 分钟）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>SW 收到页面发来的更新信号后，会拉一份 <code>swpp/update.json</code>（构建期生成），把里面的版本号和自己存下的版本号做比较：</p><div><div><div><div><span></span></div><div><span><p>否</p></span></div><div><span><p>是</p></span></div><div><span><p>一致</p></span></div><div><span><p>不一致</p></span></div><div><span></span></div><div><span></span></div><div><span><p>页面发 update 信号</p></span></div><div><span><p>距上次检查<br />超过 UPDATE_CD?</p></span></div><div><span><p>跳过本次检查</p></span></div><div><span><p>版本号一致?</p></span></div><div><span><p>逐版本收集 change<br />命中规则的 entry 打上 X-Swpp-Invalid</p></span></div><div><span><p>写入新版本号</p></span></div><div><span><p>dom.js 收到更新消息<br />热替换 js/css 后 location.reload</p></span></div>
</div><div><div><span></span></div><div><span><p>否</p></span></div><div><span><p>是</p></span></div><div><span><p>一致</p></span></div><div><span><p>不一致</p></span></div><div><span></span></div><div><span></span></div><div><span><p>页面发 update 信号</p></span></div><div><span><p>距上次检查<br />超过 UPDATE_CD?</p></span></div><div><span><p>跳过本次检查</p></span></div><div><span><p>版本号一致?</p></span></div><div><span><p>逐版本收集 change<br />命中规则的 entry 打上 X-Swpp-Invalid</p></span></div><div><span><p>写入新版本号</p></span></div><div><span><p>dom.js 收到更新消息<br />热替换 js/css 后 location.reload</p></span></div>
</div></div></div><p>三个值得记住的兜底行为：</p><ul>
<li><strong>首访用户</strong>没有旧版本号，直接返回 -1，按当前规则铺缓存，不参与比较</li>
<li><strong>版本号整体丢失</strong>（比如缓存被浏览器清了但 SW 还在）时，干脆清空整个 <code>CACHE_NAME</code> 重来</li>
<li><strong>逃生门 escape</strong>：构建期由 <code>defineCrossEnv</code> 注入到 sw.js，跨版本协议大改时用来兜底</li>
</ul><div><div><div></div><div>Tip</div></div><div><p>这套设计最舒服的一点：发新文章后，<code>update.json</code> 里只多一条「这份 HTML 变了」的记录，而不是把整站资源重新下发一遍。增量，而不是全量。</p></div></div></section></section>
<section><h2>白名单怎么定：一份判断，两端执行<a href="#白名单怎么定一份判断两端执行"><span>#</span></a></h2><p>规则是协议的核心，所以它不能只活在浏览器里。swpp 要求把判定函数写进配置，让 <strong>Node 端（构建期算增量）</strong> 和 <strong>浏览器端（运行时真拦截）</strong> 各有一份实现，这就是 <code>defineCrossDep</code> 的由来：</p><div><div><div><figure><figcaption><span>swpp.config.ts（节选）</span><span>展开</span><span>收起</span></figcaption><pre><code><div><div><div>1</div></div><div><span>defineCrossDep</span><span>({</span></div></div><div><div><div>2</div></div><div><span>  </span><span>matchCacheRule</span><span>:</span><span> {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>// 浏览器端实现：swpp 通过 toString() 将此函数序列化进 sw.js</span></div></div><div><div><div>4</div></div><div><span>    </span><span>runOnBrowser</span><span>:</span><span><span> (</span><span>url</span></span><span>:</span><span> </span><span>URL</span><span>)</span><span>:</span><span> </span><span>number</span><span> </span><span>|</span><span> </span><span>false</span><span> </span><span>=&gt;</span><span> {</span></div></div><div><div><div>5</div></div><div><span>      </span><span>const</span><span> { </span><span>pathname</span><span> } </span><span>=</span><span><span> </span><span>url</span><span>;</span></span></div></div><div><div><div>6</div></div><div><span>      </span><span>if</span><span><span> (</span><span>url</span><span>.</span></span><span>origin</span><span> </span><span>!==</span><span><span> </span><span>self</span><span>.</span></span><span>location</span><span>.</span><span>origin</span><span>) </span><span>return</span><span> </span><span>false</span><span>; </span><span>// 跨域永不缓存</span></div></div><div><div><div>7</div></div><div><span>      </span><span>if</span><span><span> (</span><span>pathname</span><span>.</span></span><span>startsWith</span><span>(</span><span>"/api/"</span><span>)) </span><span>return</span><span> </span><span>false</span><span>;        </span><span>// 动态接口不缓存</span></div></div><div><div><div>8</div></div><div><span>      </span><span>if</span><span><span> (</span><span>pathname</span><span>.</span></span><span>startsWith</span><span>(</span><span>"/_astro/"</span><span>) </span><span>||</span><span><span> </span><span>pathname</span><span>.</span></span><span>startsWith</span><span>(</span><span>"/pagefind/"</span><span>)) {</span></div></div><div><div><div>9</div></div><div><span>        </span><span>return</span><span> </span><span>-</span><span>1</span><span>; </span><span>// 文件名即版本，永久缓存</span></div></div><div><div><div>10</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>11</div></div><div><span>      </span><span>if</span><span><span> (</span><span>pathname</span><span>.</span></span><span>endsWith</span><span>(</span><span>"/"</span><span>) </span><span>||</span><span><span> </span><span>pathname</span><span>.</span></span><span>endsWith</span><span>(</span><span>".html"</span><span>)) </span><span>return</span><span> </span><span>-</span><span>1</span><span>;</span></div></div><div><div><div>12</div></div><div><span>      </span><span>if</span><span> (</span><span><span>/</span><span>\.</span><span>(?:html</span><span>|</span><span>woff2</span><span>?</span><span>|</span><span>ttf</span><span>|</span><span>otf</span><span>|</span><span>png</span><span>|</span><span>jpe</span><span>?</span><span>g</span><span>|</span><span>webp</span><span>|</span><span>avif</span><span>|</span><span>svg</span><span>|</span><span>gif</span><span>|</span><span>ico)</span></span><span>$</span><span>/</span><span>i</span><span>.</span><span>test</span><span><span>(</span><span>pathname</span><span>)) {</span></span></div></div><div><div><div>13</div></div><div><span>        </span><span>return</span><span> </span><span>-</span><span>1</span><span>; </span><span>// 字体、图片等静态媒体</span></div></div><div><div><div>14</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>15</div></div><div><span>      </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>},</span></div></div><div><div><div>17</div></div><div><span>    </span><span>// Node 端实现：构建时计算增量更新规则，允许复用模块作用域</span></div></div><div><div><div>18</div></div><div><span>    </span><span>runOnNode</span><span>:</span><span><span> (</span><span>url</span></span><span>:</span><span> </span><span>URL</span><span>) </span><span>=&gt;</span><span> </span><span>matchCacheRuleByUrl</span><span><span>(</span><span>url</span><span>, </span></span><span>SITE_ORIGIN</span><span>),</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>20</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>具体到我们这个站，白名单就三条线：</p><ol>
<li><strong>跨域直接放行</strong>。友链头像、第三方图床这些资源，SW 管不着也不该管</li>
<li><strong><code>/_astro/*</code> 与 <code>/pagefind/*</code> 给 -1</strong>。这些是构建产物，文件名带内容哈希，命中即永远命中</li>
<li><strong>HTML 与静态媒体给 -1</strong>。媒体靠哈希、靠 CDN 头；HTML 不靠哈希，靠的是上面那套版本号机制来失效</li>
</ol><div><div><div></div><div>Warning</div></div><div><p>注意 Node 端走的是 <code>SITE_ORIGIN</code> 这种模块作用域常量（<code>runOnNode</code> 在构建期运行），浏览器端却只能拿 <code>self.location.origin</code> 现算。两端行为不一致时不会报错，只会表现为「构建算出来的增量规则和浏览器实际执行的规则对不上」——这类漂移极难排查，改规则时务必两端一起改。</p></div></div></section>
<section><h2>构建编排：注入与产物<a href="#构建编排注入与产物"><span>#</span></a></h2><p><code>swpp-backends</code> 的构建期产物是固定的：站点根目录的 <code>sw.js</code>，加 <code>swpp/</code> 目录下的 <code>update.json</code>、<code>tracker.json</code>、<code>dom.js</code>。其中 <code>dom.js</code> 是官方提供的前端更新接收脚本：页面加载后如果 SW 已接管，就发个 update 信号；收到「有更新」的消息后，配合 Pjax 先热替换改动的 <code>&lt;script&gt;</code>/<code>&lt;link&gt;</code>，再 <code>location.reload()</code>——刷新前写 sessionStorage 标记，防止 reload 死循环。</p><p>剩下还有两件事得自己编排，我写在 <code>scripts/build-swpp.ts</code> 里：</p><p><strong>第一，把注册脚本注入每个 HTML。</strong> 位置和 swpp CLI 保持一致：<code>&lt;script&gt;(${registry})()&lt;/script&gt;</code> 紧跟 <code>&lt;head&gt;</code> 开标签之后，<code>&lt;script defer src="/swpp/dom.js"&gt;</code> 贴在 <code>&lt;/head&gt;</code> 之前：</p><div><figure><figcaption><span>scripts/build-swpp.ts（节选）</span></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>registry</span><span>:</span><span> </span><span>string</span><span> </span><span>=</span><span><span> </span><span>runtimeData</span><span>.</span></span><span>domConfig</span><span>.</span><span>read</span><span>(</span><span>"registry"</span><span>);</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>registryScript</span><span> </span><span>=</span><span> </span><span>`&lt;script&gt;(</span><span>${</span><span>registry</span><span>}</span><span>)()&lt;/script&gt;`</span><span>;</span></div></div><div><div><div>3</div></div><div><span>const</span><span> </span><span>domScript</span><span> </span><span>=</span><span> </span><span>`&lt;script defer src="/swpp/dom.js"&gt;&lt;/script&gt;`</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>// 幂等：已包含 dom.js 引用说明注入过，跳过</span></div></div><div><div><div>6</div></div><div><span>if</span><span><span> (</span><span>html</span><span>.</span></span><span>includes</span><span>(</span><span>DOM_JS_URL</span><span>)) </span><span>continue</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>第二，留一条撤退路线。</strong> 配置里 <code>enable: false</code> 时不生成 swpp 产物，而是往 <code>sw.js</code> 写一段「自杀 SW」：activate 时清空全部缓存、注销自身、刷新所有受控页面。想下掉这个功能，改一行配置、重新构建部署，老访客下次打开就自动回归无 SW 状态，不用等任何 TTL。</p></section>
<section><h2>出问题点<a href="#出问题点"><span>#</span></a></h2><p>流程捋顺了，下面这些坑是实际集成时一个一个踩出来的，按折磨程度排序。</p><section><h3>坑一：saveFiles 撞上已存在文件，直接抛 file_duplicate<a href="#坑一savefiles-撞上已存在文件直接抛-file_duplicate"><span>#</span></a></h3><p>第一次跑构建一切正常，第二次跑同一份 <code>dist/</code> 就直接炸。翻 <code>swpp-backends</code> 源码，<code>saveFiles</code> 对「文件已存在」的处置不是覆盖，而是<strong>抛 <code>file_duplicate</code> 异常</strong>。</p><p>这其实是个守护设计：<code>publicPath</code> 指向的就是站点根目录，里面本来就躺着别人写的文件，无脑覆盖太危险。但对我们的构建流程来说，<code>swpp/</code> 产物每次都是重新生成的，旧的必须清掉。解法很朴素——先删后写：</p><div><figure><figcaption><span>scripts/build-swpp.ts（节选）</span></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>files</span><span> </span><span>=</span><span> </span><span>await</span><span><span> </span><span>builder</span><span>.</span></span><span>buildFiles</span><span>();</span></div></div><div><div><div>2</div></div><div><span>// saveFiles 对已存在的文件会抛 file_duplicate，先清掉上次构建可能遗留的产物</span></div></div><div><div><div>3</div></div><div><span>await</span><span> </span><span>Promise</span><span>.</span><span>all</span><span>(</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>files</span><span>.</span><span>map</span><span><span>((</span><span>file</span><span>) </span></span><span>=&gt;</span><span><span> </span><span>fs</span><span>.</span></span><span>rm</span><span><span>(</span><span>file</span><span>.</span></span><span>path</span><span>.</span><span>absPath</span><span>, { </span><span>force</span><span>:</span><span> </span><span>true</span><span> })),</span></div></div><div><div><div>5</div></div><div><span>);</span></div></div><div><div><div>6</div></div><div><span>await</span><span><span> </span><span>builder</span><span>.</span></span><span>saveFiles</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div></div><div>Tip</div></div><div><p>教训：遇到「第二次构建才报错」的诡异现象，先想想是不是产物落盘逻辑把上一次的东西当成了「别人的文件」。</p></div></div></section><section><h3>坑二：注入成功的前提是「结构完整的 head」<a href="#坑二注入成功的前提是结构完整的-head"><span>#</span></a></h3><p>注入脚本找的是 <code>&lt;head ...&gt;</code> 开标签和它之后的 <code>&lt;/head&gt;</code>：</p><div><figure><figcaption><span>scripts/build-swpp.ts（节选）</span></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>openTag</span><span> </span><span>=</span><span> /&lt;head</span><span>[</span><span>^</span><span>&gt;]</span><span><span>*</span><span>&gt;/</span></span><span>i</span><span>.</span><span>exec</span><span><span>(</span><span>html</span><span>);</span></span></div></div><div><div><div>2</div></div><div><span>if</span><span> (</span><span>!</span><span><span>openTag</span><span>) </span></span><span>return</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>3</div></div><div><span>const</span><span> </span><span>closeStart</span><span> </span><span>=</span><span><span> </span><span>html</span><span>.</span></span><span>toLowerCase</span><span>().</span><span>lastIndexOf</span><span>(</span><span>"&lt;/head&gt;"</span><span>);</span></div></div><div><div><div>4</div></div><div><span>if</span><span><span> (</span><span>closeStart</span><span> </span></span><span>&lt;</span><span><span> </span><span>openEnd</span><span>) </span></span><span>return</span><span> </span><span>null</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>任何一边缺了，<code>injectHead</code> 就返回 <code>null</code>，构建日志里只多一行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>⚠ Skipped ${file}: no &lt;head&gt; found</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Astro 正常产出的页面没问题，但自定义输出、纯脚本生成的页面一旦结构不完整，就会被<strong>静默跳过</strong>——SW 注册脚本没进去，那一页就成了缓存盲区。坑不在实现，在于它太安静了。所以注入完我特意数了成功数打日志，<code>injected: n/total</code> 对不上就立刻查。</p><p>另一个相关细节是幂等：<code>html.includes(DOM_JS_URL)</code> 命中就直接跳过，保证脚本重复执行不会给页面塞两份注册代码。</p></section><section><h3>坑三：runOnBrowser 是被 toString 的裸函数，别惦记闭包<a href="#坑三runonbrowser-是被-tostring-的裸函数别惦记闭包"><span>#</span></a></h3><p>这是最反直觉的一个。<code>runOnBrowser</code> 的实现会被 swpp <strong>整个序列化（toString）后嵌进生成的 sw.js</strong>——函数体之外的一切都带不走：闭包变量、外层常量、import 进来的东西，全都不存在。</p><p>我第一次写的时候，习惯性地在函数体里引用了 <code>SITE_ORIGIN</code>，构建一点错不报，浏览器里跑起来就是 <code>undefined !== undefined</code> 之类的诡异比较。排查了很久才意识到：<strong>那个函数在 sw.js 里是孤零零的一段源码，它只认识自己函数体里的东西。</strong></p><p>所以写法上要遵守两条：</p><ol>
<li><code>runOnBrowser</code> 函数体必须<strong>自包含</strong>，要判断同源就现场算 <code>self.location.origin</code></li>
<li><code>runOnNode</code> 运行在构建期 Node 环境，随便复用模块作用域——但别忘了它和浏览器端<strong>必须产出一致的判定结果</strong></li>
</ol></section><section><h3>坑四：-1 是「永久缓存」，不是「永不变」<a href="#坑四-1-是永久缓存不是永不变"><span>#</span></a></h3><p>一开始想偷懒，把所有资源都返回 -1，想着「缓存越多越离线」。结果发现 -1 的资源<strong>不参与版本检查</strong>，意味着它一旦进了缓存，就只有靠它自己的 URL 变化才会被替换。</p><ul>
<li>对 <code>/_astro/*</code> 这种带内容哈希的产物，这没问题——文件变了 URL 必变</li>
<li>对<strong>固定 URL 的资源</strong>，这就是灾难：内容更新了，URL 没变，swpp 版本机制又不检查它，用户将永远看到旧文件</li>
</ul><p>所以 -1 的正确用法是且仅是「文件名即版本」的资源；任何「URL 不变但内容会变」的东西，要么交给版本机制失效，要么干脆不缓存。HTML 之所以能 -1，是因为它不在版本机制之外——HTML 恰恰是版本号要盯的主要对象。</p></section><section><h3>坑五：HTTP 缓存头不配合，版本检查会失灵<a href="#坑五http-缓存头不配合版本检查会失灵"><span>#</span></a></h3><p>swpp 的更新链路依赖「SW 每次能拿到新鲜的 <code>sw.js</code> 和 <code>update.json</code>」。部署端要是给这两个文件套上长缓存，SW 检查到的版本号永远是旧的，等于版本机制整个失灵。而 <code>/_astro/*</code> 这类产物则反过来，巴不得 CDN 层就给足缓存，SW 都不必每次都回源。</p><p>所以部署在 Cloudflare Pages 时，<code>_headers</code> 里必须给出截然相反的两段：</p><div><figure><figcaption><span>public/_headers</span></figcaption><pre><code><div><div><div>1</div></div><div><span>/sw.js</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>Cache-Control: no-cache, must-revalidate</span></div></div><div><div><div>3</div></div><div><span>/swpp/*</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>Cache-Control: no-cache, must-revalidate</span></div></div><div><div><div>5</div></div><div><span>/_astro/*</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>Cache-Control: public, max-age=31536000, immutable</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div></div><div>Warning</div></div><div><p>改完 swpp 相关代码后，如果线上迟迟不生效，先查的不是代码，是这两个文件的 HTTP 响应头。no-cache 写没写对，直接影响用户多久能拿到新版本。</p></div></div></section><section><h3>坑六：验证 SW 永远慢半拍<a href="#坑六验证-sw-永远慢半拍"><span>#</span></a></h3><p>SW 的更新节奏和普通前端完全不一样，第一次接触很容易被它的「慢半拍」绕进去：</p><ol>
<li><strong>首次访问</strong>只是注册 + install，页面还没被接管</li>
<li><strong>刷新一次</strong>后 activate 接管，之后的请求才走缓存</li>
<li>新版本的 sw.js 下载后可能处于 <strong>waiting</strong> 状态，要等旧页面全部关闭才接管</li>
<li><code>dom.js</code> 刷新前还有 sessionStorage 防死循环标记，一个 session 里只刷新一次</li>
</ol><p>把这些叠在一起，实际效果是：改了规则 → build → 刷新 → 看效果，<strong>不是</strong>原子动作。你可能刷到的是仍被旧 SW 喂着的旧页面，而新 SW 正安静地等在后台。</p><p>再叠加一个老熟人——<code>astro preview</code> 只伺服上次 build 产出的 <code>dist/</code>，不编译任何东西。如果 build 都没重新跑，你验的压根不是刚写的代码。所以正确的验证姿势是：</p><ol>
<li>先 <code>pnpm build</code> 再 <code>pnpm preview</code>，确认 dist 是新的</li>
<li>打开开发者工具 Application 面板看 SW 状态，必要时手动 Skip waiting</li>
<li>要彻底重来就直接 Unregister + Clear storage，从头走一遍首访流程</li>
</ol><div><div><div></div><div>Tip</div></div><div><p>改一次规则要 build、preview、unregister、reload 四个动作，才能干净地验证一轮。嫌烦就写进自己的检查清单——SW 的「缓存要几跳才生效」特性，决定了它没法像普通代码那样热改热验。</p></div></div></section></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>回头看，swpp 真正的门槛不在配置，而在<strong>心智转换</strong>：它默认什么都不缓存，规则返回值的每个分支都要你回答「这份资源变了以后，靠什么失效」——靠文件名哈希、靠版本号机制，还是根本不缓存。把这个问题想清楚，剩下的实现都是水到渠成；想不清楚，就会在「怎么不生效」「怎么老不更新」之间反复横跳。</p><p>集成期的坑也大多长在这个心智缝隙里：序列化的函数没有闭包、永久缓存不等于永不变、HTTP 头能废掉版本检查、SW 的验证天然慢半拍。每一个都是「某一层帮你做了件事，但没告诉你」。</p><p>swpp 本身是个很克制也很完整的方案，作者 kmar 的文档写得值得通读，仓库在 EmptyDreams/swpp-backends。博客这边从配置到构建编排的完整实现都在：</p><a href="https://github.com/kmoretti/firefly" target="_blank"><div><div><div><div></div><div>kmoretti</div></div><div>/</div><div>firefly</div></div><div></div></div><div>🍀Firefly, fresh and aesthetic Astro blog theme template. </div><div><div>—</div><div>—</div><div>MIT</div><span>Astro</span></div></a></section>]]></content>
    </entry>
    <entry>
      <id>https://b.kemeow.top/posts/ai-article-summary/</id>
      <title type="text">AI 文章摘要：从生成脚本到打字机组件的折腾记录</title>
      <published>2026-09-05T00:00:00.000Z</published>
      <updated>2026-09-05T00:00:00.000Z</updated>
      <author><name>克喵Moretti</name></author>
      <link rel="alternate" href="https://b.kemeow.top/posts/ai-article-summary/"/>
      <summary type="text">给博客文章加 AI 摘要的记录：本地脚本调硅基流动 Qwen3-8B 生成 50~100 字摘要写进 frontmatter，前端用 Svelte 5 写了个带打字机动画和窄屏折叠的组件。文章按「数据从哪来、前端怎么展示」两条线讲，顺带记录了折叠裁切、dev 缓存、离线图标几个坑。</summary>
      <content type="html"><![CDATA[<section><h2>起因<a href="#起因"><span>#</span></a></h2><p>参考站的每篇文章顶部都有一小段 AI 摘要，打字机效果逐字蹦出来，后面还缀着一个闪烁的光标，看着挺精致。眼馋，决定给自己的博客也塞一个。</p><p>拆开看，这功能其实就两块：</p><ol>
<li><strong>摘要数据从哪来</strong> —— 我选了本地脚本：调一次模型，把结果写进文章的 frontmatter</li>
<li><strong>前端怎么展示</strong> —— 一个 Svelte 5 组件，负责打字机动画和移动端折叠</li>
</ol><p>整体流程画出来是这样：</p><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>超范围重试一次</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>frontmatter<br />aiSummary 为空</p></span></div><div><span><p>pnpm ai-summary<br />交互式选择文章</p></span></div><div><span><p>硅基流动<br />Qwen3-8B</p></span></div><div><span><p>长度 50~100 校验</p></span></div><div><span><p>写回 frontmatter<br />只动 aiSummary 行</p></span></div><div><span><p>构建期 astro:content 读取</p></span></div><div><span><p>PostAiSummary.svelte<br />打字机动画 + 折叠</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>超范围重试一次</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>frontmatter<br />aiSummary 为空</p></span></div><div><span><p>pnpm ai-summary<br />交互式选择文章</p></span></div><div><span><p>硅基流动<br />Qwen3-8B</p></span></div><div><span><p>长度 50~100 校验</p></span></div><div><span><p>写回 frontmatter<br />只动 aiSummary 行</p></span></div><div><span><p>构建期 astro:content 读取</p></span></div><div><span><p>PostAiSummary.svelte<br />打字机动画 + 折叠</p></span></div>
</div></div></div><p>下面按这两条线说。</p></section>
<section><h2>数据：frontmatter 里躺一个字段<a href="#数据frontmatter-里躺一个字段"><span>#</span></a></h2><p>没搞单独的 JSON 文件，也没上云端函数，直接在内容集合的 schema 里加了个可选字段：</p><div><figure><figcaption><span>src/content.config.ts</span></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>postsCollection</span><span> </span><span>=</span><span> </span><span>defineCollection</span><span>({</span></div></div><div><div><div>2</div></div><div><span>  </span><span>loader</span><span>:</span><span> </span><span>glob</span><span>({ </span><span>pattern</span><span>:</span><span> </span><span>"**/*.{md,mdx}"</span><span>, </span><span>base</span><span>:</span><span> </span><span>"./src/content/posts"</span><span> }),</span></div></div><div><div><div>3</div></div><div><span>  </span><span>schema</span><span>:</span><span><span> </span><span>z</span><span>.</span></span><span>object</span><span>({</span></div></div><div><div><div>4</div></div><div><span>    </span><span>title</span><span>:</span><span><span> </span><span>z</span><span>.</span></span><span>string</span><span>(),</span></div></div><div><div><div>5</div></div><div><span>    </span><span>published</span><span>:</span><span><span> </span><span>z</span><span>.</span></span><span>date</span><span>(),</span></div></div><div><div><div>6</div></div><div><span>    </span><span>description</span><span>:</span><span><span> </span><span>z</span><span>.</span></span><span>string</span><span>().</span><span>optional</span><span>().</span><span>default</span><span>(</span><span>""</span><span>),</span></div></div><div><div><div>7</div></div><div><span>    </span><span>aiSummary</span><span>:</span><span><span> </span><span>z</span><span>.</span></span><span>string</span><span>().</span><span>optional</span><span>().</span><span>default</span><span>(</span><span>""</span><span>),</span></div></div><div><div><div>8</div></div><div><span>    </span><span>// ...</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>}),</span></div></div><div><div><div>10</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>字段可选、默认空串，没生成摘要的文章 <code>entry.data.aiSummary</code> 就是空串，页面判断一下就能决定显不显示。</p><p>为什么存 frontmatter：</p><ul>
<li>跟着文章走，换主题、迁移仓库，摘要不会丢</li>
<li>构建期 <code>astro:content</code> 直接能读到，不需要额外请求</li>
</ul><div><div><div></div><div>Tip</div></div><div><p>aiSummary 和 description 各管各的：description 是给搜索引擎和列表页的，aiSummary 是给点进文章的人看的，文案的路子不一样，别混着用。</p></div></div></section>
<section><h2>生成脚本：一次调用，写回文件<a href="#生成脚本一次调用写回文件"><span>#</span></a></h2><p>脚本是一个交互式 CLI，<code>pnpm ai-summary</code> 跑起来长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[AI-SUMMARY] 可处理的文章：</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>1. AI 文章摘要：从生成脚本到打字机组件的折腾记录</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>2. 国内外分流实践：Cloudflare + EdgeOne 双端部署的踩坑记录 （已有摘要，将重写）</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>输入要处理的序号（多选用逗号分隔，如 1,3,5）：</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>已生成过的文章会标「已有摘要，将重写」，想重写哪篇直接再选一次就行。draft 状态和带密码的文章直接跳过，不列出。</p><section><h3>读环境变量<a href="#读环境变量"><span>#</span></a></h3><p>项目脚本一直没引 dotenv，我不想为了这一个脚本破例，就自己写了个极简解析器。<code>.env</code> 其实就是 <code>KEY=VALUE</code> 逐行排开，剥掉空行、注释行和引号就完事了：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>parseEnvFile</span><span><span>(</span><span>content</span></span><span>:</span><span> </span><span>string</span><span>)</span><span>:</span><span> </span><span>EnvVars</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span> </span><span>vars</span><span>:</span><span> </span><span>EnvVars</span><span> </span><span>=</span><span> {};</span></div></div><div><div><div>3</div></div><div><span>  </span><span>for</span><span> (</span><span>const</span><span> </span><span>rawLine</span><span> </span><span>of</span><span><span> </span><span>content</span><span>.</span></span><span>split</span><span>(</span><span>/</span><span>\r</span><span>?</span><span>\n</span><span>/</span><span>)) {</span></div></div><div><div><div>4</div></div><div><span>    </span><span>const</span><span> </span><span>line</span><span> </span><span>=</span><span><span> </span><span>rawLine</span><span>.</span></span><span>trim</span><span>();</span></div></div><div><div><div>5</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span><span>line</span><span> </span></span><span>||</span><span><span> </span><span>line</span><span>.</span></span><span>startsWith</span><span>(</span><span>"#"</span><span>)) </span><span>continue</span><span>;</span></div></div><div><div><div>6</div></div><div><span>    </span><span>const</span><span> </span><span>eq</span><span> </span><span>=</span><span><span> </span><span>line</span><span>.</span></span><span>indexOf</span><span>(</span><span>"="</span><span>);</span></div></div><div><div><div>7</div></div><div><span>    </span><span>if</span><span><span> (</span><span>eq</span><span> </span></span><span>&lt;=</span><span> </span><span>0</span><span>) </span><span>continue</span><span>;</span></div></div><div><div><div>8</div></div><div><span>    </span><span>const</span><span> </span><span>key</span><span> </span><span>=</span><span><span> </span><span>line</span><span>.</span></span><span>slice</span><span>(</span><span>0</span><span><span>, </span><span>eq</span><span>).</span></span><span>trim</span><span>();</span></div></div><div><div><div>9</div></div><div><span>    </span><span>let</span><span><span> </span><span>value</span><span> </span></span><span>=</span><span><span> </span><span>line</span><span>.</span></span><span>slice</span><span><span>(</span><span>eq</span><span> </span></span><span>+</span><span> </span><span>1</span><span>).</span><span>trim</span><span>();</span></div></div><div><div><div>10</div></div><div><span>    </span><span>if</span><span> (</span></div></div><div><div><div>11</div></div><div><span><span>      </span></span><span>(</span><span>value</span><span>.</span><span>startsWith</span><span>(</span><span>'"'</span><span>) </span><span>&amp;&amp;</span><span><span> </span><span>value</span><span>.</span></span><span>endsWith</span><span>(</span><span>'"'</span><span>)) </span><span>||</span></div></div><div><div><div>12</div></div><div><span><span>      </span></span><span>(</span><span>value</span><span>.</span><span>startsWith</span><span>(</span><span>"'"</span><span>) </span><span>&amp;&amp;</span><span><span> </span><span>value</span><span>.</span></span><span>endsWith</span><span>(</span><span>"'"</span><span>))</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>) {</span></div></div><div><div><div>14</div></div><div><span><span>      </span></span><span>value</span><span> </span><span>=</span><span><span> </span><span>value</span><span>.</span></span><span>slice</span><span>(</span><span>1</span><span>, </span><span>-</span><span>1</span><span>);</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>vars</span><span>[</span><span>key</span><span>] </span><span>=</span><span><span> </span><span>value</span><span>;</span></span></div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>18</div></div><div><span>  </span><span>return</span><span><span> </span><span>vars</span><span>;</span></span></div></div><div><div><div>19</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section><section><h3>调模型<a href="#调模型"><span>#</span></a></h3><p>硅基流动的接口是 OpenAI 兼容的，一个 <code>fetch</code> 完事：</p><div><div><div><figure><figcaption><span>scripts/generate-ai-summary.ts</span><span>展开</span><span>收起</span></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>response</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>fetch</span><span>(</span></div></div><div><div><div>2</div></div><div><span>  </span><span>`</span><span>${</span><span>baseUrl</span><span>.</span><span>replace</span><span>(</span><span>/</span><span>\/</span><span>$</span><span>/</span><span>,</span><span> </span><span>""</span><span>)</span><span>}</span><span>/chat/completions`</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>4</div></div><div><span>    </span><span>method</span><span>:</span><span> </span><span>"POST"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>    </span><span>headers</span><span>:</span><span> {</span></div></div><div><div><div>6</div></div><div><span>      </span><span>"Content-Type"</span><span>:</span><span> </span><span>"application/json"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>      </span><span>Authorization</span><span>:</span><span> </span><span>`Bearer </span><span>${</span><span>apiKey</span><span>}</span><span>`</span><span>,</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>},</span></div></div><div><div><div>9</div></div><div><span>    </span><span>body</span><span>:</span><span> </span><span>JSON</span><span>.</span><span>stringify</span><span>({</span></div></div><div><div><div>10</div></div><div><span><span>      </span></span><span>model</span><span>, </span><span>// 默认 Qwen/Qwen3-8B</span></div></div><div><div><div>11</div></div><div><span>      </span><span>messages</span><span>:</span><span> [</span></div></div><div><div><div>12</div></div><div><span><span>        </span></span><span>{ </span><span>role</span><span>:</span><span> </span><span>"system"</span><span>, </span><span>content</span><span>:</span><span> </span><span>PROMPT</span><span> },</span></div></div><div><div><div>13</div></div><div><span><span>        </span></span><span>{ </span><span>role</span><span>:</span><span> </span><span>"user"</span><span>, </span><span>content</span><span>:</span><span> </span><span>`文章标题：</span><span>${</span><span>title</span><span>}</span><span>\n\n</span><span>正文内容：</span><span>${</span><span>body</span><span>}</span><span>`</span><span> },</span></div></div><div><div><div>14</div></div><div><span><span>      </span></span><span>],</span></div></div><div><div><div>15</div></div><div><span>      </span><span>temperature</span><span>:</span><span> </span><span>0.5</span><span>,</span></div></div><div><div><div>16</div></div><div><span>      </span><span>max_tokens</span><span>:</span><span> </span><span>300</span><span>,</span></div></div><div><div><div>17</div></div><div><span>      </span><span>stream</span><span>:</span><span> </span><span>false</span><span>,</span></div></div><div><div><div>18</div></div><div><span>      </span><span>enable_thinking</span><span>:</span><span> </span><span>false</span><span>,</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>}),</span></div></div><div><div><div>20</div></div><div><span>    </span><span>signal</span><span>:</span><span><span> </span><span>AbortSignal</span><span>.</span></span><span>timeout</span><span>(</span><span>60_000</span><span>),</span></div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>22</div></div><div><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>system prompt 里把要求写死了：<strong>50~100 个字符、不要「本文」「这篇文章」开头的套话、直接概括核心内容、不用引号不用 markdown 不换行</strong>。</p><p><code>enable_thinking: false</code> 关掉思维链挺关键，不然 Qwen 先吐一大段「思考过程」，又浪费 token 又得费劲剥。不过保险起见脚本里还是做了兜底清理：剥掉 <code>&lt;think&gt;</code> 标签之间的内容、去首尾引号、压平空白，再进长度校验。</p></section><section><h3>长度校验与重试<a href="#长度校验与重试"><span>#</span></a></h3><p>模型可不会每次都守规矩。生成完先数长度，不在 50~100 就自动重试一次；重试完还超范围，太短（20 字以下）的直接跳过不写，长度勉强能看的会弹出来问一句「是否仍写入」。</p></section><section><h3>写回 frontmatter：只动一行<a href="#写回-frontmatter只动一行"><span>#</span></a></h3><p>写回的逻辑有个讲究。最开始想的是把整个 frontmatter 反序列化改完再序列化回去，后来发现会破坏原文件的其他格式（比如有的字段故意写成块标量）。现在的做法是定位 frontmatter 区域，逐行找 <code>aiSummary:</code> 替换，找不到就在末尾插一行，<strong>文件其余内容原样不动</strong>：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>upsertAiSummary</span><span><span>(</span><span>raw</span></span><span>:</span><span> </span><span>string</span><span><span>, </span><span>summary</span></span><span>:</span><span> </span><span>string</span><span>)</span><span>:</span><span> </span><span>string</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span> </span><span>normalized</span><span> </span><span>=</span><span><span> </span><span>raw</span><span>.</span></span><span>replace</span><span>(</span><span>/</span><span>\r\n</span><span>/</span><span>g</span><span>, </span><span>"</span><span>\n</span><span>"</span><span>);</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>fmMatch</span><span> </span><span>=</span><span><span> </span><span>normalized</span><span>.</span></span><span>match</span><span>(</span><span>/</span><span>^</span><span>---</span><span>\n</span><span>(</span><span><span>[</span><span>\s\S</span><span>]</span></span><span><span>*?</span><span>)</span></span><span>\n</span><span>---</span><span>\n</span><span><span>?</span><span>/</span></span><span>);</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>lines</span><span> </span><span>=</span><span><span> </span><span>fmMatch</span><span>[</span></span><span>1</span><span>].</span><span>split</span><span>(</span><span>"</span><span>\n</span><span>"</span><span>);</span></div></div><div><div><div>5</div></div><div><span>  </span><span>let</span><span><span> </span><span>replaced</span><span> </span></span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>6</div></div><div><span>  </span><span>const</span><span> </span><span>output</span><span>:</span><span> </span><span>string</span><span>[] </span><span>=</span><span> [];</span></div></div><div><div><div>7</div></div><div><span>  </span><span>for</span><span> (</span><span>const</span><span> </span><span>line</span><span> </span><span>of</span><span><span> </span><span>lines</span><span>) {</span></span></div></div><div><div><div>8</div></div><div><span>    </span><span>if</span><span> (</span><span>/</span><span>^</span><span>aiSummary</span><span>\s</span><span><span>*</span><span>:/</span></span><span>.</span><span>test</span><span><span>(</span><span>line</span><span>)) {</span></span></div></div><div><div><div>9</div></div><div><span><span>      </span></span><span>output</span><span>.</span><span>push</span><span>(</span><span>`aiSummary: </span><span>${</span><span>toYamlDoubleQuoted</span><span><span>(</span><span>summary</span><span>)</span></span><span>}</span><span>`</span><span>);</span></div></div><div><div><div>10</div></div><div><span><span>      </span></span><span>replaced</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>11</div></div><div><span>      </span><span>continue</span><span>;</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>output</span><span>.</span><span>push</span><span><span>(</span><span>line</span><span>);</span></span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>15</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>replaced</span><span>) </span><span>output</span><span>.</span></span><span>push</span><span>(</span><span>`aiSummary: </span><span>${</span><span>toYamlDoubleQuoted</span><span><span>(</span><span>summary</span><span>)</span></span><span>}</span><span>`</span><span>);</span></div></div><div><div><div>16</div></div><div><span>  </span><span>return</span><span> </span><span>`---</span><span>\n</span><span>${</span><span>output</span><span>.</span><span>join</span><span>(</span><span>"</span><span>\n</span><span>"</span><span>)</span><span>}</span><span>\n</span><span>---</span><span>\n</span><span>${</span><span>normalized</span><span>.</span><span>slice</span><span><span>(</span><span>fmMatch</span><span>[</span></span><span>0</span><span>]</span><span>.</span><span>length</span><span>)</span><span>}</span><span>`</span><span>;</span></div></div><div><div><div>17</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>有个细节：如果原来的值是块标量（<code>&gt;</code> 折叠 / <code>|</code> 保留）写的，替换时得把后面的缩进续行一起吞掉，否则会残留半截 YAML。</p><p>整个脚本失败的文章跳过、不中断整批，跑完汇总：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[AI-SUMMARY] 处理完成汇总：</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>成功 1 篇</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>- AI 文章摘要：从生成脚本到打字机组件的折腾记录</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>失败 0 篇</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div></div><div>Warning</div></div><div><p><code>.env</code> 里的 key 千万别提交进 git。脚本只从 <code>.env</code> 读，日志里也从不打印 key 相关内容。</p></div></div></section></section>
<section><h2>展示：打字机动画，和一场折叠裁切的血案<a href="#展示打字机动画和一场折叠裁切的血案"><span>#</span></a></h2><section><h3>SSR 先渲染完整文本<a href="#ssr-先渲染完整文本"><span>#</span></a></h3><p>组件第一个版本犯了个经典错误：<code>displayed</code> 初始是空串，水合后才开始打字。结果首屏文章顶部先白一块，动画再慢慢填出来，还会带着布局跳动。</p><p>改法很朴素——SSR 阶段直接输出完整摘要，水合后再清空逐字打：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// SSR 先渲染完整文本，水合后再启动动画，避免空白与布局跳动</span></div></div><div><div><div>2</div></div><div><span>let</span><span><span> </span><span>displayed</span><span> </span></span><span>=</span><span> </span><span>$state</span><span><span>(</span><span>summary</span><span>);</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>打字动画就是递归 <code>setTimeout</code> 逐字符追加，光标是一个 CSS 闪烁的 <code>|</code>。<code>prefers-reduced-motion</code> 的用户直接显示全文，不动画，这个属于基本素养。</p></section><section><h3>窄屏折叠的血案<a href="#窄屏折叠的血案"><span>#</span></a></h3><p>摘要 50~100 字，手机上还是有点长，所以加了折叠：窄屏且摘要超过阈值时用 <code>max-height</code> 裁到几行，配一个「展开全部」按钮。</p><p>问题出在折叠的初始值。当时写的是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>let</span><span><span> </span><span>isFolded</span><span> </span></span><span>=</span><span> </span><span>$state</span><span>(</span><span>true</span><span>); </span><span>// 一开始折叠</span></div></div><div><div><div>2</div></div><div><span>// [!code --]</span></div></div><div><div><div>3</div></div><div><span>let</span><span><span> </span><span>isFolded</span><span> </span></span><span>=</span><span> </span><span>$state</span><span><span>(</span><span>summary</span><span>.</span></span><span>length</span><span> </span><span>&gt;</span><span><span> </span><span>foldThresholdChars</span><span>); </span></span><span>// 只有超阈值才折叠</span></div></div><div><div><div>4</div></div><div><span>// [!code ++]</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>而「展开全部」按钮的出现条件是 <code>summary.length &gt; foldThresholdChars</code>。于是出现一个很蠢的组合：<strong>短摘要（80 字以内）在手机端被裁切了，却没有展开按钮</strong>，内容就这么永久缺一块，用户根本看不着。修复就是上面那一行：只有真正超过阈值才默认折叠，短摘要从一开始就完整展示。顺带把折叠预览高度从 4.8em 提到了 7.2em，多两行，没这么憋屈。</p><div><div><div></div><div>Tip</div></div><div><p>折叠阈值 <code>foldThresholdChars</code> 和打字速度 <code>typingSpeedMs</code> 都放在 <code>src/config/aiSummaryConfig.ts</code>，改参数不用动组件。</p></div></div></section><section><h3>图标：离线集里没有的，自己抠<a href="#图标离线集里没有的自己抠"><span>#</span></a></h3><p>这个项目的图标走本地离线集（<code>src/constants/icons-data.json</code>），好处是零网络请求。AI 图标第一反应想用 <code>material-symbols:smart-toy-outline</code>，一查——离线集里根本没有，Icon 组件会渲染成空心圆占位。</p><p>解决：从 <code>@iconify-json/material-symbols</code> 包里的 <code>icons.json</code> 抠出这个图标的 body，塞进离线集，再用统一的 Icon 组件引用：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>Icon</span><span> </span><span>icon</span><span>=</span><span>"material-symbols:smart-toy-outline"</span><span> </span><span>style</span><span>=</span><span>"font-size: 13px"</span><span> /&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>
<section><h2>接入：加密文章不放<a href="#接入加密文章不放"><span>#</span></a></h2><p>在文章页 <code>[...slug].astro</code> 里，正文之前渲染：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span><span>entry</span><span>.</span><span>data</span><span>.</span><span>aiSummary</span><span> </span><span>&amp;&amp;</span><span> </span><span>aiSummaryConfig</span><span>.</span><span>enable</span><span> </span><span>&amp;&amp;</span><span> (</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>&lt;</span><span>PostAiSummary</span></div></div><div><div><div>3</div></div><div><span>    </span><span>client</span><span>:</span><span>visible</span></div></div><div><div><div>4</div></div><div><span>    </span><span>summary</span><span><span>=</span><span>{</span></span><span>entry</span><span>.</span><span>data</span><span>.</span><span>aiSummary</span><span>}</span></div></div><div><div><div>5</div></div><div><span>    </span><span>label</span><span><span>=</span><span>{</span></span><span>aiSummaryConfig</span><span>.</span><span>label</span><span>}</span></div></div><div><div><div>6</div></div><div><span>    </span><span>badge</span><span><span>=</span><span>{</span></span><span>aiSummaryConfig</span><span>.</span><span>badge</span><span>}</span></div></div><div><div><div>7</div></div><div><span>    </span><span>typingSpeedMs</span><span><span>=</span><span>{</span></span><span>aiSummaryConfig</span><span>.</span><span>typingSpeedMs</span><span>}</span></div></div><div><div><div>8</div></div><div><span>    </span><span>foldThresholdChars</span><span><span>=</span><span>{</span></span><span>aiSummaryConfig</span><span>.</span><span>foldThresholdChars</span><span>}</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>/&gt;</span></div></div><div><div><div>10</div></div><div><span>)</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>加密文章走的是另一个分支（EncryptedPost），那里不渲染摘要——不然密码锁着的内容先被摘要剧透完了。</p></section>
<section><h2>踩坑清单<a href="#踩坑清单"><span>#</span></a></h2><p>按时间顺序：</p><ol>
<li><strong>dev 服务器内容不更新</strong>：改完 frontmatter 或 schema，旧的 dev 进程内存里的内容存储感知不到变更，页面上死活没有卡片。折腾半天，重启 dev server 就好了，不用删缓存。</li>
<li><strong>CI 的 biome 检查红了</strong>：本地 <code>pnpm lint</code> 带 <code>--write</code> 会自动修格式，CI 里跑的是 <code>biome ci</code>，只检查不修复。Svelte 的 <code>&lt;script&gt;</code> 块内容要求顶格，本地格式化完没事，推送上去就红了。写完整跑一遍 <code>pnpm exec biome ci ./src</code> 再推。</li>
<li><strong>折叠裁切</strong>：上面说的，短摘要被裁且没有展开按钮，血的教训。</li>
<li><strong>离线图标</strong>：别看到 <code>material-symbols:xxx</code> 就觉得有，先确认它在 <code>icons-data.json</code> 里。</li>
</ol></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>成本方面，Qwen3-8B 很便宜，一次摘要花不了几分钱，生成一次能管到文章大改。静态摘要的取舍也简单：确定性强、无网络依赖、构建期就有；代价是文章改版后摘要可能过时——重跑一次脚本覆盖就行。</p><p>这套东西就长在这个博客里，想照着搬的可以看仓库：</p><a href="https://github.com/kmoretti/firefly" target="_blank"><div><div><div><div></div><div>kmoretti</div></div><div>/</div><div>firefly</div></div><div></div></div><div>🍀Firefly, fresh and aesthetic Astro blog theme template. </div><div><div>—</div><div>—</div><div>MIT</div><span>Astro</span></div></a></section>]]></content>
    </entry>
    <entry>
      <id>https://b.kemeow.top/posts/edgeone-cloudflare-cn-routing/</id>
      <title type="text">国内外分流实践：Cloudflare + EdgeOne 双端部署的踩坑记录</title>
      <published>2026-09-03T00:00:00.000Z</published>
      <updated>2026-09-03T00:00:00.000Z</updated>
      <author><name>克喵Moretti</name></author>
      <link rel="alternate" href="https://b.kemeow.top/posts/edgeone-cloudflare-cn-routing/"/>
      <summary type="text">给博客做国内外分流，把国内用户 302 到 EdgeOne 子域。文章记录了双端部署的完整思路，以及备案限制、CNAME、重定向规则、强制 HTTPS 这一路上的坑。</summary>
      <content type="html"><![CDATA[<section><h2>起因<a href="#起因"><span>#</span></a></h2><p>博客部署在 Cloudflare Pages，国内访问一直不太行。想着给国内用户单独开一条 EdgeOne 的路，谁让 EdgeOne 是腾讯云的呢，理论上国内节点应该会好一些。</p><p>折腾完发现，<strong>理论上</strong>三个字，分量很重。</p><p>先说结论：这套方案能不能显著加速国内访问，取决于你有没有备案。往下看你就明白了。</p></section>
<section><h2>最终架构<a href="#最终架构"><span>#</span></a></h2><p>先说清楚域名关系，这是后面所有配置的基础：</p><ul>
<li><code>kemeow.top</code> —— 根域名，DNS 托管在 Cloudflare</li>
<li><code>b.kemeow.top</code> —— 博客主站，Cloudflare Pages</li>
<li><code>cn.b.kemeow.top</code> —— 国内入口，EdgeOne Pages</li>
</ul><p>整体流程是这样的：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>PagesCMS 改文章</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>→ push master</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>→ GitHub Actions 构建（一次）</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>→ 同时部署到 Cloudflare Pages + EdgeOne Pages</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>→ 国内用户访问 b.kemeow.top，302 跳到 cn.b.kemeow.top</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>→ 其他地区用户正常访问 Cloudflare</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div></div><div>Tip</div></div><div><p>构建只跑一次，<code>dist</code> 是同一份，两个平台内容天然一致，不存在双份代码维护的问题。</p></div></div></section>
<section><h2>GitHub Actions：一次构建，双端部署<a href="#github-actions一次构建双端部署"><span>#</span></a></h2><p>EdgeOne Pages 有一个坑：平台侧每个月只有 500 次构建额度。如果你让 EdgeOne 自己连仓库构建，文章一多、推送一频繁，额度很快就见底。</p><p>所以正确姿势是：<strong>工作流里自己构建，然后把产物直接推上去</strong>（EdgeOne 叫 Direct Upload / 直接上传）。</p><p>工作流改造很简单，在原有的 Cloudflare 部署步骤后面再加一步：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>- </span><span>name</span><span>: </span><span>Deploy to EdgeOne Pages</span></div></div><div><div><div>2</div></div><div><span>  </span><span>env</span><span>:</span></div></div><div><div><div>3</div></div><div><span>    </span><span>EDGEONE_API_TOKEN</span><span>: </span><span>${{ secrets.EDGEONE_API_TOKEN }}</span></div></div><div><div><div>4</div></div><div><span>  </span><span>run</span><span>: </span><span>npx --yes edgeone pages deploy ./dist -n "$EDGEONE_PAGES_PROJECT" -t "$EDGEONE_API_TOKEN" -e production</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>PagesCMS 的工作方式完全不变，还是 push master 触发工作流，只是多了一个部署目标。</p></section>
<section><h2>最大的坑：备案<a href="#最大的坑备案"><span>#</span></a></h2><p>这是整套方案里绕不开的一道坎。</p><p>EdgeOne Pages 对自定义域名有一条硬性规定：</p><ul>
<li>加速区域选 <strong>中国大陆可用区</strong> 或 <strong>全球可用区（含中国大陆）</strong> → 绑定自定义域名<strong>必须 ICP 备案</strong></li>
<li>没备案 → 只能选 <strong>全球可用区（不含中国大陆）</strong></li>
<li>选了这个区域，默认分配的 Pages 域名在国内访问大概率返回 <strong>401</strong>，所以必须绑自己的自定义域名</li>
</ul><p>所以情况就是：</p>

<table><thead><tr><th>你的状态</th><th>能选什么区域</th><th>国内加速效果</th></tr></thead><tbody><tr><td>已备案</td><td>含中国大陆的区域</td><td>✅ 有国内节点</td></tr><tr><td>未备案</td><td>仅全球（不含中国大陆）</td><td>❌ 无国内节点，只是多一条线路</td></tr></tbody></table><div><div><div></div><div>Warning</div></div><div><p>我一开始在 EdgeOne 控制台创建项目，默认就是「中国大陆可用区」。没备案的情况下，这个区域是<strong>改不了</strong>的，只能把项目删掉重新建，创建时选「全球可用区（不含中国大陆）」。别问我怎么知道的。</p></div></div><p>还有个细节：如果你的域名在别家已经备案过，在腾讯云这边用，需要做「接入备案」，不是重新备案一遍，但也要走流程。</p></section>
<section><h2>EdgeOne 侧配置<a href="#edgeone-侧配置"><span>#</span></a></h2><ol>
<li>创建项目，方式选<strong>直接上传</strong>，加速区域选「全球可用区（不含中国大陆）」</li>
<li>手动先部署一次（上传 dist），确认项目可用</li>
<li>域名管理里添加 <code>cn.b.kemeow.top</code>，按提示做归属校验</li>
<li>开启 HTTPS，然后开启<strong>强制 HTTPS</strong>
<ul>
<li>HTTP → HTTPS 的重定向方式选 <strong>301</strong>（永久跳转，符合语义）</li>
</ul>
</li>
<li>创建 API Token，权限包含 Pages 部署</li>
<li>把 Token 配到 GitHub 仓库 Secrets，名字叫 <code>EDGEONE_API_TOKEN</code></li>
</ol></section>
<section><h2>Cloudflare 侧配置<a href="#cloudflare-侧配置"><span>#</span></a></h2><section><h3>1. DNS CNAME<a href="#1-dns-cname"><span>#</span></a></h3><p>添加一条：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>类型：CNAME</span></div></div><div><div><div>2</div></div><div><span>名称：cn.b</span></div></div><div><div><div>3</div></div><div><span>目标：（EdgeOne 控制台提供的 CNAME 值）</span></div></div><div><div><div>4</div></div><div><span>代理状态：DNS only（灰色云朵）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div></div><div>Warning</div></div><div><p>这里有两个坑：</p><ul>
<li>名称要填 <code>cn.b</code>，不是 <code>cn</code>。填 <code>cn</code> 会变成 <code>cn.kemeow.top</code>，直接指向错误</li>
<li>必须 <strong>DNS only</strong>（灰色云朵）。如果开代理（橙色云朵），请求会先过 Cloudflare 再过 EdgeOne，双重代理容易出问题，还可能和重定向规则打架</li>
</ul></div></div></section><section><h3>2. 重定向规则<a href="#2-重定向规则"><span>#</span></a></h3><p>在 Cloudflare 的 <strong>Rules → Redirect Rules</strong> 里新建规则，注意不是 URL 重写规则（那个只改路径、不能跨域跳转）。</p><p>匹配方式选<strong>自定义筛选表达式</strong>，填：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>(ip.geoip.country eq "CN" and http.host eq "b.kemeow.top")</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div></div><div>Tip</div></div><div><p>这个表达式只匹配中国大陆 IP 访问主站的情况。<code>cn.b.kemeow.top</code> 的 Host 不匹配，天然不会触发自己跳自己，不会形成循环。</p></div></div><p>重定向 URL 选<strong>表达式</strong>模式，填：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>concat("https://cn.b.kemeow.top", http.request.uri.path)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>保留查询字符串打开，状态码先用 <strong>302</strong>。</p><p>为什么先 302？因为 301 会被浏览器缓存。调试期用了 301，后面改规则，用户浏览器里还是旧的跳转结果，很烦。确认稳定了再换 301 不迟。</p></section></section>
<section><h2>踩坑汇总<a href="#踩坑汇总"><span>#</span></a></h2><p>按我实际踩的顺序整理一下，全是真金白银换来的：</p><ol>
<li><strong>项目建完发现区域选错，只能删了重建</strong> —— 没备案别选含中国大陆的区域，选完改不了</li>
<li><strong>默认 Pages 域名国内 401</strong> —— 未备案区域下，必须绑自定义域名，别指望默认域名能用</li>
<li><strong>把 API Token 写进代码</strong> —— 绝对不要。放 GitHub Secrets，工作流里通过 <code>${{ secrets.EDGEONE_API_TOKEN }}</code> 引用</li>
<li><strong>CNAME 名称填成 <code>cn</code></strong> —— 正确是 <code>cn.b</code>，指向 <code>cn.b.kemeow.top</code></li>
<li><strong>DNS 开了 Cloudflare 代理</strong> —— 必须 DNS only，否则双重代理、证书错乱、重定向循环都可能来</li>
<li><strong>想用 URL 重写规则代替重定向</strong> —— 不行，重写不改变浏览器地址栏，跨域跳转只能走 Redirect Rules</li>
<li><strong>匹配方式选成”所有传入请求”</strong> —— 会把全球所有地区都跳过去，必须用自定义筛选表达式限定国内 IP</li>
<li><strong>301 用太早</strong> —— 浏览器缓存跳转结果，调试期务必用 302</li>
</ol></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>这套方案做完，代码侧的改动其实很小，大头全在控制台配置。而且最后要诚实地说一句：<strong>没备案的话，EdgeOne 没有国内节点</strong>，分流只是给国内用户换了一条线路，能不能更快取决于 EdgeOne 对国内的连通性，未必有质的飞跃。</p><p>如果你的诉求是”国内访问必须快”，那还是老老实实备案，走含中国大陆的区域。如果只是想要个双线冗余，那这套方案完全够用。</p><p>权当给同样在折腾的人一个参考。</p></section>]]></content>
    </entry>
</feed>
