<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://www.9ll.uk/</id>
  <title type="text">折腾进行时</title>
  <subtitle type="text">9LL.UK</subtitle>
  <updated>2026-10-16T00:00:00.000Z</updated>
  <author><name>HY</name></author>
  <link rel="alternate" href="https://www.9ll.uk/"/>
  <link rel="self" href="https://www.9ll.uk/atom.xml"/>
  <generator uri="https://github.com/CuteLeaf/Firefly">Firefly v6.16.8</generator>
    <entry>
      <id>https://www.9ll.uk/posts/blog/aicommentcontext/</id>
      <title type="text">评论区的小助手开始读正文了：从 23 个字到整篇文章</title>
      <published>2026-10-16T00:00:00.000Z</published>
      <updated>2026-10-16T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/aicommentcontext/"/>
      <summary type="text">评论区那个自动回帖的 AI 小助手，其实一直没读到正文 —— 抓取逻辑取的是每页都有的首页壁纸卡片，稳定拿到 23 个字。这次改成先判断评论发在哪一页，再把整页内容喂给模型。为了确认改对了，我用 jsdom 另写了一版独立实现对账，反倒挖出标签解析里三个会污染正文的 bug。</summary>
      <content type="html"><![CDATA[<p>前阵子给评论区挂了个 AI 小助手：访客留完言，它过一会儿会自己回一条，身份是「Gemini助理」，回复前面挂一枚「AI」徽标和一行免责声明。</p>
<p>它一直能用，我也一直没细看。直到有人在友链页留言问事，它回得像在回文章评论 —— 翻代码才发现，<strong>它根本没读到正文</strong>。</p>
<p>不是”读得少”，是稳定地只读到 23 个字。</p>
<section><h2>它以为自己在读文章<a href="#它以为自己在读文章"><span>#</span></a></h2><p>小助手的工作流是这样：评论落库 → 触发插件 → 去抓这条评论所在页面的 HTML → 从中抽一段正文当上下文 → 连同留言一起发给模型。</p><p>问题出在第三步的”抽”。当时的写法是正则里取页面上第一个 <code>&lt;article&gt;</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>articleMatch</span><span> </span><span>=</span><span><span> </span><span>source</span><span>.</span></span><span>match</span><span>(</span><span>/&lt;article</span><span>[</span><span>^</span><span>&gt;]</span><span><span>*</span><span>&gt;(</span></span><span><span>[</span><span>\s\S</span><span>]</span></span><span><span>*?</span><span>)&lt;</span><span>\/</span><span>article&gt;/</span></span><span>i</span><span>);</span></div></div><div><div><div>2</div></div><div><span>if</span><span><span> (</span><span>articleMatch</span><span>) </span><span>body</span><span> </span></span><span>=</span><span><span> </span><span>articleMatch</span><span>[</span></span><span>1</span><span>];</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>听着挺合理。但我这站每页的第一个 <code>&lt;article&gt;</code> 都不是正文，是首页那张壁纸卡片：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>article</span><span> </span><span>id</span><span>=</span><span>"home-wallpaper-card"</span><span> </span><span>class</span><span>=</span><span>"home-wallpaper-card"</span><span>&gt;…&lt;/</span><span>article</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>里面只有头像、名字和一句 <code>Hello, I'm HY.</code>。所以不管评论发在哪一页、哪篇文章，小助手拿到的”文章内容”永远是这 23 个字。它只能盯着留言那一句话硬编，回复自然就往客服话术上飘。</p><p>把每个页面抽出来的东西打出来看，齐刷刷一模一样：</p>

<table><thead><tr><th>页面</th><th>改造前喂给模型的「文章内容」</th></tr></thead><tbody><tr><td><code>/friends/</code> 友链页</td><td>23 字</td></tr><tr><td><code>/posts/deploy/deployfolia</code> 文章页</td><td>23 字</td></tr><tr><td><code>/booknav/</code> 书签导航</td><td>23 字</td></tr><tr><td><code>/guestbook/</code> 留言板</td><td>23 字</td></tr><tr><td><code>/about/</code> 关于页</td><td>23 字</td></tr></tbody></table></section>
<section><h2>先判断「这是哪一页」，再决定喂什么<a href="#先判断这是哪一页再决定喂什么"><span>#</span></a></h2><p>这次改的核心不是”把正文抽得更全”，而是先回答一个问题：<strong>这条评论发在站内的什么界面。</strong></p><p>有了这一层，后面才有得选 —— 文章页该喂全文，友链页该喂友链清单，留言板根本不该硬塞内容。</p><p>所以给每条评论先做一次页面分类：</p>

<table><thead><tr><th>路径</th><th>判定</th><th>提示词里带给模型的场景</th></tr></thead><tbody><tr><td><code>/posts/**</code></td><td>文章详情页</td><td>页面正文就是这篇文章全文，访客大概率在聊它</td></tr><tr><td><code>/friends/</code></td><td>友情链接页</td><td>列出博主的朋友站点（站名/简介/网址）与自助申请流程</td></tr><tr><td><code>/booknav/</code></td><td>书签导航页</td><td>按分类整理的网站合集，每条含站名、简介、网址</td></tr><tr><td><code>/dynamic/&lt;memo id&gt;/</code></td><td>动态（随手记）</td><td>博主的一条短动态，访客说的「这条」就是它</td></tr><tr><td><code>/guestbook/</code></td><td>留言板</td><td>自由留言，不针对某篇具体文章</td></tr><tr><td><code>/gallery/</code>、<code>/about/</code>、首页…</td><td>各自归类</td><td>对应页面一句话说明</td></tr></tbody></table><p>只有”哪一页”还不够。真正要连着喂的是<strong>页面上有什么内容</strong>，而不同页面的内容根本不在同一个 DOM 节点里：</p>

<table><thead><tr><th>页面类型</th><th>内容从哪取</th></tr></thead><tbody><tr><td>文章页 / 关于页</td><td><code>[data-pagefind-body]</code>（主题给正文打的标记）</td></tr><tr><td>友链页 / 书签页 / 相册页 / 列表页</td><td><code>#content-wrapper</code>（主内容列）</td></tr><tr><td>留言板</td><td>抓不到 —— 整页由前端渲染，改用站点说明补语境</td></tr><tr><td>动态</td><td>页面根本不存在，从 <code>/dynamic/rss.xml</code> 按 memo id 反查</td></tr></tbody></table></section>
<section><h2>核心机制<a href="#核心机制"><span>#</span></a></h2><section><h3>一、友链页的卡片不在 <code>pagefind-body</code> 里<a href="#一友链页的卡片不在-pagefind-body-里"><span>#</span></a></h3><p>主题给正文打的 <code>data-pagefind-body</code> 本该是最干净的抓手，但它只标”进搜索索引的文字”。友链页上它标的是「申请友链」那段说明，<strong>真正的友链卡片在它外面</strong> —— 只认这个标记，友链页就只能喂一段申请流程说明，对方问”某个友链站的地址”照样答不出来。</p><p>所以规则定成：文章页和关于页优先 <code>data-pagefind-body</code>，其余页面优先 <code>#content-wrapper</code>，再退化 <code>&lt;main&gt;</code> → <code>&lt;body&gt;</code>。</p><p>顺带一个容易看错的细节：文章页其实有<strong>两个</strong> <code>data-pagefind-body</code>。一个是标题（带 <code>data-pagefind-meta="title"</code>，给搜索记标题用的），一个是正文。两个都要。</p></section><section><h3>二、动态页是伪路径，页面上压根没有这个 URL<a href="#二动态页是伪路径页面上压根没有这个-url"><span>#</span></a></h3><p>动态页的评论区不是内嵌在页面里的，是滚到那条动态时才挂一个 iframe 上去，评论用 <code>/dynamic/&lt;memo id&gt;/</code> 这个<strong>合成路径</strong>投进 Waline。也就是说这个 URL 请求出去只会拿到 404 页 —— 想靠抓 HTML 拿动态正文，路一开始就是死的。</p><p>改从 <code>/dynamic/rss.xml</code> 反查：那份 RSS 里每条都带 <code>#dynamic-&lt;memo id&gt;</code> 锚点，按 id 匹配就能拿到那条随手记的正文。</p></section><section><h3>三、留言板抓不到内容，那就别硬喂<a href="#三留言板抓不到内容那就别硬喂"><span>#</span></a></h3><p>留言板整页是 Svelte 在前端渲染的，静态 HTML 里只有一个空壳。硬喂进去会得到一句”留言板 · 正在连接留言服务”，还不如告诉模型「这一页是留言板，访客在这随便聊」。</p><p>补语境的活交给 <code>/llms.txt</code>：那份文件里本来就有站点名、一句话简介，还有每个功能页的官方说明（「友链：这里是我的朋友们，欢迎互相访问交流」「留言：欢迎在这里留下你的足迹」）。读一次缓存 5 分钟，留言板、相册这类抓不到正文的页面全靠它撑住场景。</p><p>顺便加了个判断：<strong>正文短于 30 字就当没抓到</strong>，直接走”只给场景”的分支，免得把空壳当正文塞进去。</p></section><section><h3>四、剥标签不能用正则<a href="#四剥标签不能用正则"><span>#</span></a></h3><p>这是整件事里最费劲的地方，单独放一节讲。</p></section></section>
<section><h2>三个被对账挖出来的坑<a href="#三个被对账挖出来的坑"><span>#</span></a></h2><p>为了确认抽取逻辑真的对，我另写了一版<strong>独立实现</strong>：用 jsdom 建真 DOM 树、递归遍历出文本，再和插件里那套正则实现<strong>逐字符比对</strong>。本地构建产物 109 页、线上 17 页、<code>astro dev</code> 渲染 11 页，一开始只对了 74/109。</p><p>不一致的地方全指向同一类错误：<strong>正则用 <code>[^&gt;]*&gt;</code> 匹配标签，属性值里只要出现 <code>&lt;</code> 或 <code>&gt;</code> 就会切错。</strong></p><p>我这站的代码块有个复制按钮，长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>button</span><span> </span><span>title</span><span>=</span><span>"Copy to clipboard"</span><span> </span><span>data-code</span><span>=</span><span>"http://</span><span>&lt;</span><span>你的NAS-IP&gt;:8080"</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>三个坑都是从它开始的。</p><p><strong>一、属性值里的 <code>&gt;</code> 让标签提前收尾。</strong> <code>&lt;button\b[^&gt;]*&gt;</code> 会停在 <code>data-code</code> 里面的那个 <code>&gt;</code> 上，剩下一截 <code>:8080"&gt;</code> 被当成正文。实测 30 多篇文章的正文里都混着这种碎片，长得像 <code>--dump-dom"&gt;</code>、<code>展开收起</code> 之类 —— 模型拿到这些只会更糊涂。</p><p><strong>二、属性值里的 <code>&lt;div</code> 被当成真开标签。</strong> 这个更隐蔽：容器切片靠统计 <code>&lt;div&gt;</code> 的嵌套深度找收尾，属性值里冒出一个 <code>&lt;div</code> 就让深度失衡，<strong>整个正文多抓 2–3 KB</strong>。有一篇实测切了 53205 字符，jsdom 那边真实的只有 51240。</p><p><strong>三、嵌套隐藏元素删两层时下标错位。</strong> 这个是我修前两个时新引入的：去掉隐藏节点时先算出所有要删的区间、再倒序删；如果内外两层都要删，倒序删完内层，外层的结束下标就偏了 —— 结果多删一段、还留下半个标签。<code>/archive/</code> 的正文被啃出 <code>onclass="archive-year-toggle…"</code> 这种鬼东西，就是它干的。</p><p>修法是换掉整个标签处理：先写个 <code>shieldTagGt()</code> 把属性值里的 <code>&gt;</code> 顶成占位符，再写个引号感知的分词器 <code>scanTags()</code>，扫到标签就跳到它的结束位置。这样属性值里出现的 <code>&lt;div</code> 永远不会被当成标签。容器切片、属性查找、整块删除全部改走分词器。</p><p>顺手补的几个：</p><ul>
<li><code>&lt;div hidden&gt;</code> 这种<strong>布尔属性</strong>要认（统计页图表的空状态就是它），光查 <code>class="hidden"</code> 会漏 —— 但也不能把 <code>overflow-hidden</code> 一起误伤，得按 class 分词精确匹配。</li>
<li><code>&lt;button&gt;</code>、<code>select</code>、<code>option</code> 整块去掉：展开、收起、复制、筛选标签都是 UI 外壳，不是正文。</li>
<li><strong>图说要捞回来</strong>。我文章的图说是写在 <code>&lt;img alt&gt;</code> 里的，剥标签时会连图说一起丢。全站文章页 325 个 alt，其中 272 个是有意义的图说（「混淆后的样图一，画面被像素块覆盖」这种），现在会补成 <code>〔图：…〕</code>。</li>
<li>正文里 <code>&lt;4&gt;</code>、<code>设备名&lt;端口&gt;</code> 这种伪标签按浏览器语义处理：<code>&lt;4&gt;</code> 留成正文、<code>&lt;/4&gt;</code> 当注释丢掉。否则我写在正文里的占位符会被吃成「设备名」。</li>
</ul></section>
<section><h2>怎么证明它真的读进去了<a href="#怎么证明它真的读进去了"><span>#</span></a></h2><p>改完之后有三条互相独立的验证，都是”逐字符相等”这种硬指标，不是看着像：</p>

<table><thead><tr><th>验证</th><th>做法</th><th>结果</th></tr></thead><tbody><tr><td>离线自测</td><td>桩掉模型和 Waline 接口跑完整链路</td><td>41 项全过（其中 11 项是这轮踩的坑逐条固化的回归用例）</td></tr><tr><td><strong>独立实现对账</strong></td><td>jsdom 真 DOM 树遍历 vs 插件实现，要求逐字符相等</td><td>本地 109 页 / 线上 17 页 / dev 渲染 11 页全部一致</td></tr><tr><td>线上端到端</td><td>真发评论，等真模型真回复</td><td>4 类页面 4/4</td></tr></tbody></table><p>第三条最有意思。我在四种页面上各留了一条测试评论，看它回什么：</p><p><strong>友链页</strong>，我留言说”朝朝听雨的站我这边好像打不开”：</p><blockquote><p>朝朝听雨的博客地址确实是 rainzt.cn，偶尔可能会遇到服务器维护或节点波动。我已经把这个情况转达给站长了，稍后会去检查一下对方站点的可访问性。</p></blockquote><p><code>rainzt.cn</code> 这串地址只存在于友链列表里，留言里没提过。老代码给不出这个答案。</p><p><strong>文章页</strong>（SearXNG 那篇部署教程），我故意问”那三处必改的配置到底是哪三处”：</p><blockquote><p>其实这篇教程里并没有单列”三处必改”，而是直接提供了一份调好的 settings.yml 下载覆盖。要是覆盖后容器起不来，可以先看下 Docker 容器日志报什么错，或者排查下宿主机挂载目录的读写权限与端口冲突。</p></blockquote><p>它把<strong>我的提问前提给纠正了</strong> —— 说明它读到的是整篇正文，不是一段摘要。</p><p><strong>动态</strong>，我问”后来找工作的事有眉目了吗”：</p><blockquote><p>当时发这条动态时他确实挺迷茫的，至于后来找工作的具体进展，得等站长本人上线亲自来跟大家更新啦～</p></blockquote><p>这条动态的正文是从 RSS 反查出来的，页面上根本没有对应 URL。</p><p>留言板那条没说错什么，但也没硬扯文章，知道是在闲聊。</p></section>
<section><h2>上手与环境变量<a href="#上手与环境变量"><span>#</span></a></h2><p>不想删代码回滚的话，把 <code>AI_ENABLED</code> 设成 <code>false</code> 重新部署就行，插件完全静默。</p><p>想调的几个开关（都在 Vercel 的环境变量里）：</p>

<table><thead><tr><th>变量</th><th>默认</th><th>说明</th></tr></thead><tbody><tr><td><code>AI_FETCH_CONTEXT</code></td><td><code>true</code></td><td>是否抓页面内容喂给模型</td></tr><tr><td><code>AI_CONTEXT_CHARS</code></td><td><code>12000</code></td><td>正文最多喂多少字；超了掐中间留头尾，不是直接砍尾巴</td></tr><tr><td><code>AI_SITE_SUMMARY</code></td><td><code>true</code></td><td>是否读一次 <code>/llms.txt</code> 补站点与页面说明</td></tr><tr><td><code>AI_PATH_ALLOW</code> / <code>AI_PATH_DENY</code></td><td>空</td><td>只让某些路径触发</td></tr></tbody></table><p><code>AI_CONTEXT_CHARS</code> 给到 12000 是拿数据定的：我这边文章正文中位数 3000 字出头、九成在 6000 字以内，12000 基本等于整篇都喂过去。真遇到两万字的长文才会截断，截的时候掐中间留首尾，因为结论通常在结尾。</p></section>
<section><h2>总结<a href="#总结"><span>#</span></a></h2><ul>
<li>老版本不是”上下文少”，是抽取逻辑抓错了节点，每页稳定拿到 23 个字的壁纸卡片文案。</li>
<li>新版本先分类页面类型，再按页面类型选容器；友链页要 <code>#content-wrapper</code>，文章页要 <code>data-pagefind-body</code>，动态页走 RSS 反查，留言板靠 <code>/llms.txt</code> 补场景。</li>
<li>剥 HTML 标签别用 <code>[^&gt;]*&gt;</code> 这种正则 —— 属性值里一个 <code>&gt;</code> 就能让整段正文跑偏。引号感知的分词器才稳。</li>
<li>值得花时间写一版独立实现对账。我原本只想”确认没写错”，结果它反过来抓出三个真 bug，其中一个还是我自己改出来的。</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/cursortheme/</id>
      <title type="text">全站鼠标指针换成《俺妹》主题：亮色桐乃、深色黑猫</title>
      <published>2026-10-16T00:00:00.000Z</published>
      <updated>2026-10-16T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/cursortheme/"/>
      <summary type="text">这次更新把整站的鼠标指针换了一遍：亮色主题配高坂桐乃、深色主题配五更琉璃（黑猫），各 28 枚共 56 枚，正常选择、链接、文本、禁用、拖拽这些场景全部覆盖。素材来自两张总览图，切图时发现它们不是普通白底图 —— 人物衣服和箭头内部都是纯白，跟我页面的白同色，按我处理贴纸那套「全局去白底」会把图形整个掏空。CSS 那边我把「用哪张图 + 热点坐标」全塞进了自定义属性，28 条规则只写一遍，切换主题只换一组变量。</summary>
      <content type="html"><![CDATA[<p>这次把整站的鼠标指针换了一遍。</p>
<p>亮色主题配高坂桐乃，深色主题配五更琉璃（黑猫），各 28 枚、一共 56 枚。正常选择和链接、正文和表单的文本光标、禁用状态、拖拽、忙碌与加载，以及滚动和调整大小那一批，全都换掉了；代码块除外 —— 那里我特意留着系统原生的 I 型光标，下面会说为什么。切换亮暗主题时整套指针自动跟着换，不需要手动挑。</p>
<p></p><figure><img alt="亮色桐乃 / 深色黑猫，各 28 枚" loading="lazy" width="1300" height="700" src="/_astro/cover.CJP22LQ7_ZHMTdk.webp" /><figcaption>亮色桐乃 / 深色黑猫，各 28 枚</figcaption></figure><p></p>
<section><h2>素材：两张总览图，切成 56 枚<a href="#素材两张总览图切成-56-枚"><span>#</span></a></h2><p>手上只有两张总览图：一张桐乃、一张黑猫，每张 7 列 4 行的图标阵，底下压着中文标签。要变成能用的素材，得先把 28 个格子一个个切出来、去掉背景、缩到光标尺寸。</p><p>剪贴板里存的那份只有 1920 宽，翻回原始文件是 6144×4096，我直接用后者当源。</p><p>版面我用投影扫出来的：先按行统计「非白像素」，得到图标带和标签带交替的几条横带；再在图标带里按列扫一遍。这活看着机械，实际有两个坑等着我。</p><section><h3>坑一：这批图不能按我的老办法去白底<a href="#坑一这批图不能按我的老办法去白底"><span>#</span></a></h3><p>我以前处理贴纸用的是「全局去白」—— 把所有接近纯白的像素设成透明，那套在贴纸上很好使。放到这批图上，一跑就废。</p><p>桐乃那枚「正常选择」是个<strong>深色描边 + 白色填充</strong>的箭头，箭头内部是白的，人物的衣服也是白的，而且跟我页面背景一个色号（都是 <code>255,255,255</code>，我数了一下，那一格 63% 的像素是纯白）。全局去白的结果是箭头被掏成空壳，只剩一圈线。</p><p>我换了个思路：<strong>被描边围住的白色，本来就是图形的一部分。</strong> 先用阈值取「墨」（<code>min(R,G,B) &lt; 244</code>），再对墨迹做<strong>孔洞填充</strong>，只补那些不与图像边缘连通的空白区域：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>ink </span><span>=</span><span> np.</span><span>asarray</span><span>(rgb).</span><span>min</span><span>(</span></span><span>axis</span><span>=</span><span>2</span><span><span>) </span><span>&lt;</span><span> </span></span><span>244</span></div></div><div><div><div>2</div></div><div><span># 四周先补一圈纯白，保证背景一定与边缘连通</span></div></div><div><div><div>3</div></div><div><span><span>pad </span><span>=</span><span> np.</span><span>zeros</span><span>((h </span><span>+</span><span> </span></span><span>320</span><span><span>, w </span><span>+</span><span> </span></span><span>320</span><span>), </span><span>bool</span><span>)</span></div></div><div><div><div>4</div></div><div><span>pad[</span><span>160</span><span><span>:</span><span>-</span></span><span>160</span><span>, </span><span>160</span><span><span>:</span><span>-</span></span><span>160</span><span><span>] </span><span>=</span><span> ink</span></span></div></div><div><div><div>5</div></div><div><span><span>filled </span><span>=</span><span> ndimage.</span><span>binary_fill_holes</span><span>(pad)[</span></span><span>160</span><span><span>:</span><span>-</span></span><span>160</span><span>, </span><span>160</span><span><span>:</span><span>-</span></span><span>160</span><span>]</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这下对了：箭头内部的白色补了回来，人物衣服也保住了，只有外圈那片真正连到边缘的白变成透明。我又给边缘补了一道 1px 高斯羽化，缩放走预乘 alpha（先乘再除回来），免得透明区的白色从描边外侧渗出一圈白边。</p></section><section><h3>坑二：两张图的行距不一样，我一开始偷懒用了同一套<a href="#坑二两张图的行距不一样我一开始偷懒用了同一套"><span>#</span></a></h3><p>第一版我给两张图套了同一组裁切框，省事。结果黑猫那张有一半格子报警。</p><p>翻回原图才看明白：黑猫那张比桐乃多了一圈外框和一个页脚标，表头也略高一点。它的<strong>表头底边正好落在第 1 行裁切框里</strong> —— 那一整行都是墨，填充之后整个框被填满；第 3 行的中文标签顶边又比桐乃那张高 7px，切进去就把「斜向调整大小 2」的标签文字粘成了图形的一部分。</p><p>所以裁切框我改成按主题各测一套：行框取「上一行标签底 + 6px」到「本行标签顶 − 6px」，列框取相邻列中心的中点、两端避开图集外框。改完两组各 28 格，零告警。</p><p>热点（点击判定点）我也让它跟着自动算：箭头和手指这类有尖端的，取内容区里 <code>x + y</code> 最小的那个像素；圆形、十字、调整大小这类取正中。桐乃和黑猫的指尖差 1~2px，所以两套分别算，不共用。</p></section></section>
<section><h2>28 条规则，我只写了一遍<a href="#28-条规则我只写了一遍"><span>#</span></a></h2><p>我要的是「亮色桐乃、深色黑猫」。最直白的写法是每条规则写两遍 —— 一套挂 <code>html, body</code>，一套挂 <code>html.dark</code>，28 个指针就是 56 条，以后改一个热点还得改两处。这种迟早会漂。</p><p>我试了一下 CSS 自定义属性，Chromium 里能跑通：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>:root</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>--ffc-normal-1</span><span>: </span><span>url</span><span>(</span><span>"/images/cursors/kirino/normal-32.png"</span><span>);</span></div></div><div><div><div>3</div></div><div><span>  </span><span>--ffc-normal-2</span><span>: </span><span>url</span><span>(</span><span>"/images/cursors/kirino/normal-64.png"</span><span>);</span></div></div><div><div><div>4</div></div><div><span>  </span><span>--ffc-normal-hx</span><span>: </span><span>4</span><span>;</span></div></div><div><div><div>5</div></div><div><span>  </span><span>--ffc-normal-hy</span><span>: </span><span>2</span><span>;</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div><div><div><div>7</div></div><div><span>html</span><span>.dark</span><span> {</span></div></div><div><div><div>8</div></div><div><span>  </span><span>--ffc-normal-1</span><span>: </span><span>url</span><span>(</span><span>"/images/cursors/kuroneko/normal-32.png"</span><span>);</span></div></div><div><div><div>9</div></div><div><span>  </span><span>--ffc-normal-2</span><span>: </span><span>url</span><span>(</span><span>"/images/cursors/kuroneko/normal-64.png"</span><span>);</span></div></div><div><div><div>10</div></div><div><span>  </span><span>/* 黑猫的箭头尖位置不同，热点跟着换 */</span></div></div><div><div><div>11</div></div><div><span>  </span><span>--ffc-normal-hx</span><span>: </span><span>4</span><span>;</span></div></div><div><div><div>12</div></div><div><span>  </span><span>--ffc-normal-hy</span><span>: </span><span>2</span><span>;</span></div></div><div><div><div>13</div></div><div><span>}</span></div></div><div><div><div>14</div></div><div><span>html</span><span>,</span></div></div><div><div><div>15</div></div><div><span>body</span><span> {</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>cursor: </span><span>var</span><span>(</span><span>--ffc-normal-1</span><span>) </span><span>var</span><span>(</span><span>--ffc-normal-hx</span><span>) </span><span>var</span><span>(</span><span>--ffc-normal-hy</span><span><span>), </span><span>auto</span><span>;</span></span></div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>cursor: </span><span>-webkit-image-set</span><span>(</span><span>var</span><span>(</span><span>--ffc-normal-1</span><span>) </span><span>1</span><span>x, </span><span>var</span><span>(</span><span>--ffc-normal-2</span><span>) </span><span>2</span><span>x)</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>var(</span><span>--ffc-normal-hx</span><span>) var(</span><span>--ffc-normal-hy</span><span>), auto;</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>cursor: </span><span>image-set</span><span>(</span><span>var</span><span>(</span><span>--ffc-normal-1</span><span>) </span><span>1x</span><span>, </span><span>var</span><span>(</span><span>--ffc-normal-2</span><span>) </span><span>2x</span><span>)</span></div></div><div><div><div>20</div></div><div><span>    </span><span>var</span><span>(</span><span>--ffc-normal-hx</span><span>) </span><span>var</span><span>(</span><span>--ffc-normal-hy</span><span><span>), </span><span>auto</span><span>;</span></span></div></div><div><div><div>21</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><p>一是<strong>变量能承载整个 <code>&lt;image&gt;</code></strong>，也能承载热点那两个数字 —— <code>cursor: &lt;image&gt; &lt;number&gt; &lt;number&gt;, &lt;keyword&gt;</code> 里的每一段都可以是 <code>var()</code>，替换发生在计算值阶段，解析器不挑。于是「用哪张图」和「点在哪儿」全进了变量，切主题只换变量，28 条规则写一遍就够。顺手也确认了变量名写错时的兜底：整条声明失效、回退到 <code>auto</code>，不会把光标搞成一个奇怪的样子。</p><p>二是<strong>两种倍率共用同一组 CSS px 热点</strong>。2x 图渲染时也缩到 32 CSS px，所以热点只写一份（3 2 / 6 4 / 16 16），不用跟着倍率换算 —— 这也是我没选「直接塞一张 64px 大图」的原因：CSS 的 <code>cursor</code> 压根不能声明图片的固有尺寸，塞进去会得到一个 64px 的巨无霸光标。</p><p>每条规则我写了三档声明：纯 <code>url()</code> 兜底 → <code>-webkit-image-set()</code> → 标准 <code>image-set()</code>。不认识 <code>image-set</code> 的浏览器自动落回 1x 位图，而不是把整条声明丢掉。</p></section>
<section><h2>代码块为什么没换<a href="#代码块为什么没换"><span>#</span></a></h2><p>正文和表单的文本光标我换成了魔法棒，但 <code>pre</code>、<code>code</code> 我特意留了系统原生的 I 型光标 —— 代码块是选命令行、选配置去复制的地方，32px 的位图不如一根细十字好瞄。</p><p>本来一句 <code>pre, code { cursor: text }</code> 就该收工，结果我一看 <code>pre</code> 上的 computed style，是 body 那个黄箭头。查下来是 expressive-code 自带的一条规则：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.expressive-code</span><span> </span><span>:not</span><span>(</span><span>:is</span><span>(</span><span>svg</span><span><span>,</span><span> </span></span><span>svg</span><span> </span><span>*</span><span>))</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>cursor: </span><span>revert</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><code>revert</code> 会把属性回滚到用户代理那一层 —— 对 <code>pre</code> 来说等于「我没写过」，于是它顺着继承链一路向上，继承到了 <code>body</code> 的箭头。它的特异性是 <code>0,1,1</code>，正好压过我那条 <code>0,0,1</code> 的 <code>pre</code>。</p><p>好在 <code>revert</code> 属于非 important 声明，所以 <code>pre, code, kbd, samp, pre *, code * { cursor: text !important }</code> 才压得住。<code>pre</code> 定住之后，内部那些同样写着 <code>revert</code> 的后代自然一路继承下来；而 expressive-code 的复制按钮在 <code>&lt;figcaption&gt;</code> 里、不在 <code>pre</code> 内，不会被这条误伤 —— 我单独回查了一遍，它仍然是链接指针。</p></section>
<section><h2>怎么确认它真的生效了<a href="#怎么确认它真的生效了"><span>#</span></a></h2><p>这一环比实现麻烦：光标是浏览器在合成层上画出来的一层东西，不属于页面内容，任何截图方式都抓不到它。所以「指针换了没有」没法用一张图自证，能拿到的只有<strong>计算样式</strong>和<strong>图片能不能解码</strong>。</p><p>于是我把验证脚本写成这样：拿目标元素的 <code>getComputedStyle(el).cursor</code>，把里面那个文件路径抓出来，<code>new Image()</code> 加载一遍，确认 <code>naturalWidth</code> 是 32 —— 这一条同时证明了「声明命中了」和「图片存在且格式正确」。</p><p>在本地 dev 上跑下来：</p>

<table><thead><tr><th>元素</th><th>结果</th></tr></thead><tbody><tr><td><code>body</code></td><td><code>kirino/normal</code> 热点 4 2，兜底 <code>auto</code></td></tr><tr><td><code>a</code></td><td><code>kirino/link</code> 热点 8 3，兜底 <code>pointer</code></td></tr><tr><td><code>p</code> / <code>h1</code> / <code>td</code> / <code>input</code> / <code>textarea</code></td><td><code>kirino/text</code> 热点 16 16，兜底 <code>text</code></td></tr><tr><td><code>pre</code> / <code>pre code</code> / <code>.ec-line</code></td><td><code>text</code>（原生 I 型光标，没被覆盖）</td></tr><tr><td><code>abbr[title]</code></td><td><code>kirino/help</code> 兜底 <code>help</code></td></tr><tr><td><code>:disabled</code></td><td><code>kirino/unavailable</code> 兜底 <code>not-allowed</code></td></tr><tr><td><code>[draggable]</code></td><td><code>kirino/move</code> 兜底 <code>move</code></td></tr><tr><td><code>.expressive-code button</code></td><td><code>kirino/link</code>（复制按钮没被 <code>pre</code> 那条误伤）</td></tr><tr><td>28 个 <code>[data-cursor="…"]</code></td><td>全部命中对应图标</td></tr><tr><td>加上 <code>html.dark</code> 之后</td><td>整套换成 <code>kuroneko</code>，热点跟着变</td></tr></tbody></table><p>112 个 PNG（28 枚 × 2 套 × 2 倍率）我逐个请求了一遍，全是 200，没有一个 404。这类「路径拼错、但因为一直没人点所以从没暴露」的问题，不挨个点一遍是发现不了的。</p><p>28 枚我全都挂上了 <code>[data-cursor="…"]</code> 点名，其中十来个有天然触发点、不用手写属性：</p><p></p><figure><img alt="28 枚指针的落地范围" loading="lazy" width="1400" height="827" src="/_astro/grid-light.BO4xpdcK_ZUbUll.webp" /><figcaption>28 枚指针的落地范围</figcaption></figure><p></p><p>深色主题下同一页换整套素材，热点也跟着换：</p><p></p><figure><img alt="切到深色主题后换成黑猫那套" loading="lazy" width="1400" height="827" src="/_astro/grid-dark.zQygbt53_2dpEI7.webp" /><figcaption>切到深色主题后换成黑猫那套</figcaption></figure><p></p><p>既然拍不到光标本身，我至少能拍「鼠标划过去会变成什么」。顺手做了个自包含的单文件演示页，56 张图全部内嵌成 data URI，打开就能划，右侧那个按钮切主题 —— 这篇的配图也是从那上面截的。</p><p></p><figure><img alt="演示页：空白、链接、按钮、正文、代码块各是什么指针" loading="lazy" width="1400" height="377" src="/_astro/scene-light.BfkjTFTY_Zmv9cS.webp" /><figcaption>演示页：空白、链接、按钮、正文、代码块各是什么指针</figcaption></figure><p></p></section>
<section><h2>我把它落在哪儿<a href="#我把它落在哪儿"><span>#</span></a></h2>

<table><thead><tr><th>文件</th><th>状态</th><th>干什么</th></tr></thead><tbody><tr><td><code>src/config/FooterConfig.html</code></td><td>改（追加）</td><td>光标样式块：变量定义 + 28 条规则 + 代码块兜底</td></tr><tr><td><code>public/images/cursors/kirino/*</code></td><td>新增</td><td>桐乃 28 枚 × 2 倍率</td></tr><tr><td><code>public/images/cursors/kuroneko/*</code></td><td>新增</td><td>黑猫 28 枚 × 2 倍率</td></tr><tr><td><code>public/assets/js/dynamic-comment-enhance.js</code></td><td>改</td><td><code>STYLE_IDS</code> 补一条，让动态评论 iframe 也吃同一套</td></tr></tbody></table><p>还是本站用惯的路子 —— 注入点仍在 <code>src/config/FooterConfig.html</code>，一个主题源文件都没动。素材总共 700 KB，单张 1.4~9 KB，浏览器按需取用。</p><p>最后一条我想单独说。动态页的评论区跑在一个同源 iframe 里，而那个页面走的是不带页脚的 <code>Layout</code>，页脚注入到不了它。之前给回复折叠和回复引用补过一个「父页面代投」，所以这次我只往那份代投名单里加了一个 <code>ff-cursor-style</code> 就完事 —— 那个样式块内部自己分了两套主题，iframe 那边同样从 <code>localStorage</code> 拿到 <code>dark</code> 类，两边自然一致。我点开动态页的评论面板回查过，iframe 里的 <code>body</code> 和链接也都是俺妹指针。</p></section>
<section><h2>最后<a href="#最后"><span>#</span></a></h2><p>两张总览图我也收进了相册（<a href="/gallery/oreimo-cursor/">俺妹指针主题</a>），原图字节一致、零压缩。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/dynamicpageredesign/</id>
      <title type="text">把动态页改成了朋友圈的样子</title>
      <published>2026-10-16T00:00:00.000Z</published>
      <updated>2026-10-16T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/dynamicpageredesign/"/>
      <summary type="text">动态页想换个样子，参照的是另一个同主题的站 —— 但数据源不变，还是继续接 memos。扒完两边才发现，上游动态卡片的 DOM 本来就齐 —— 头像、作者、时间、地点、置顶徽章、图片网格、评论按钮一个不缺，差的只是样式。于是这轮改造零改动主题源文件：一份只在动态页加载的样式表，加一个把页脚外壳搬进正文的小脚本。中间踩了三个安静的坑，都是「不报错、但就是不生效」那种。</summary>
      <content type="html"><![CDATA[<p>动态页想换个样子。参照的是另一个同主题的站，数据源不变 —— 还是继续接 memos，只换皮。</p>
<p></p><figure><img alt="改完的动态页：封面区、卡片、评论区一起换了" loading="lazy" width="1100" height="1230" src="/_astro/cover.CHp4u1yA_29gk2Y.webp" /><figcaption>改完的动态页：封面区、卡片、评论区一起换了</figcaption></figure><p></p>
<section><h2>动手前先扒两边<a href="#动手前先扒两边"><span>#</span></a></h2><p>参考站也是 Firefly 的 fork，但它那套动态是<strong>自己写的组件</strong>（<code>MomentCard.astro</code> 加一个独立页面）。按这个路子走，得新开一个页面、把数据管线重接一遍 —— 动静不小。</p><p>所以先把我们的动态页 DOM dump 出来看了一眼：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>dynamic-entry.page-entry.card-base &gt; article</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>├ page-entry-header → page-avatar / page-identity(page-author + page-meta)</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>│                     / page-pinned-badge / page-entry-decoration</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>├ page-content</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>├ dynamic-gallery → page-gallery-grid</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>└ dynamic-inline-comments → page-comment-toggle + page-comment-panel</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>头像、作者、时间、地点、置顶徽章、图片网格、评论按钮 —— <strong>一个不缺</strong>。参考站那套 <code>card-header</code> / <code>card-avatar</code> / <code>card-tags</code> 跟它几乎是逐项对应的。</p><p>差的只是样式。那就不用新开页面了：<strong>纯样式覆盖，加一点脚本补它没有的东西</strong>。</p><p>约束还是老一套：<code>src/pages/</code>、<code>src/components/</code>、<code>src/styles/</code> 这些红线一根不碰。做法是——</p>

<table><thead><tr><th>文件</th><th>状态</th><th>干什么</th></tr></thead><tbody><tr><td><code>public/assets/css/dynamic-card.css</code></td><td>新增</td><td>全部样式，只在 <code>/dynamic/</code> 加载</td></tr><tr><td><code>public/assets/js/dynamic-hero.js</code></td><td>新增</td><td>把页脚的封面区外壳搬进正文</td></tr><tr><td><code>src/config/FooterConfig.html</code></td><td>改（追加）</td><td>封面区外壳与文案、PAGE_STYLES 条目</td></tr></tbody></table></section>
<section><h2>卡片：先从竖条开始<a href="#卡片先从竖条开始"><span>#</span></a></h2><p>参考站卡片最显眼的是<strong>左边一根主题色竖条</strong>。上游的 <code>.page-entry</code> 是 <code>overflow: visible</code>，竖条贴边会被裁掉，所以先把它裁住：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>dynamic-entry</span><span>.page-entry</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>position: </span><span>relative</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>overflow: </span><span>hidden</span><span>;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>padding: </span><span><span>1</span><span>rem</span></span><span>;</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>dynamic-entry</span><span><span>.page-entry</span><span>::before</span></span><span> {</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>position: </span><span>absolute</span><span>;</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>inset: </span><span>0</span><span><span> </span><span>auto</span><span> </span></span><span>0</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>width: </span><span><span>4</span><span>px</span></span><span>;</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>border-radius: </span><span>0</span><span> </span><span><span>2</span><span>px</span></span><span> </span><span><span>2</span><span>px</span></span><span> </span><span>0</span><span>;</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>background: </span><span>var</span><span>(</span><span>--primary</span><span>);</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>opacity: </span><span>0.35</span><span>;</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>content: </span><span>""</span><span>;</span></div></div><div><div><div>15</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>其余都是对着参考站抄：头像从 48px 收到 40px 并加一圈 2px 主题色描边、昵称改成主题色、时间与地点之间补一个 <code>·</code>（上游只靠 <code>gap</code> 分隔）、正文取消那个跟头像对齐的 <code>3.75rem</code> 缩进、右上角那枚纯装饰的引号撤掉。</p><p><strong>所有选择器都挂 <code>dynamic-entry</code> 前缀</strong>。上游的 <code>.page-*</code> 在友链页、书签导航那批自建页里也在用，不加前缀会误伤一片。</p><section><h3>图片网格踩到特异性<a href="#图片网格踩到特异性"><span>#</span></a></h3><p>参考站的网格是固定 100px 的方块（按张数排 1 / 2 / 3 列）。我们上游本来是自适应宽度，所以要把尺寸压成固定的：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>dynamic-entry</span><span> </span><span>.page-gallery</span><span> </span><span>&gt;</span><span> </span><span>.page-gallery-grid</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>width: </span><span>fit-content</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>grid-auto-rows: </span><span><span>100</span><span>px</span></span><span>;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>grid-template-columns: </span><span>repeat</span><span>(</span><span>3</span><span>, </span><span><span>100</span><span>px</span></span><span>);</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>写完一测——格子还是被摊开。原因在<strong>特异性</strong>：</p>

<table><thead><tr><th>选择器</th><th>特异性</th></tr></thead><tbody><tr><td>上游 <code>.page-gallery-grid[data-count="2"]</code>（还写着 <code>width: min(100%, 28rem)</code>）</td><td><strong>0-2-0</strong></td></tr><tr><td>我写的 <code>dynamic-entry .page-gallery-grid</code></td><td>0-1-1</td></tr></tbody></table><p><strong>0-2-0 赢。</strong> 容器宽度始终是 448px（=28rem），格子设成 100px 也只是被这 448px 平分。改成 <code>dynamic-entry .page-gallery &gt; .page-gallery-grid[data-count=…]</code>（0-3-1）才拿下。</p><p>最终实测：单图 100px、2 图 206、3 图 312、4 图 206（两列）、6 与 9 图 312（三列）—— 跟参考站的规则对上了。窄屏（≤480px）转自适应均分。</p><p><strong>单图我保留了上游的「完整显示不裁剪」</strong>，没照搬成 100px 方块。一张风景照被裁成小方块，实在难看。</p></section></section>
<section><h2>封面区：上游没有的东西<a href="#封面区上游没有的东西"><span>#</span></a></h2><p>参考站顶部那一整块——封面大图、右下角浮出的头像、压在封面上的昵称、右对齐的签名，下面一排按钮——上游<strong>完全没有</strong>。</p><p>补它的路子是「页脚注入外壳 + 脚本搬运」：DOM 连同全部文案、链接先写在页脚的 <code>FooterConfig.html</code> 里，由 <code>dynamic-hero.js</code> 在动态页把它搬进正文顶部。</p><p>搬的时候有个要点：<strong>用 <code>cloneNode</code>，原壳留在页脚</strong>。因为正文整个在 swup 的替换范围里，直接把节点搬过去，切一次页就没了；原壳留着，下次切回来再克隆一份。</p><section><h3>防重复插入，安静地失效<a href="#防重复插入安静地失效"><span>#</span></a></h3><p>页面重进会触发好几个事件（<code>astro:page-load</code>、<code>swup:page:view</code>、<code>swup:content:replace</code>），每个都会跑一次搬运。所以插之前得先认门。</p><p>第一版我写的是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span><span> (</span><span>host</span><span>.</span></span><span>querySelector</span><span>(</span><span>":scope &gt; .ff-wx-cover"</span><span>)) </span><span>return</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>看着挺对。实测：连触发三次事件，<strong>插出了三份封面</strong>。</p><p>换成锚点上的标记才拦住：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span><span> (</span><span>anchor</span><span>.</span></span><span>dataset</span><span>.</span><span>ffHeroMounted</span><span> </span><span>===</span><span> </span><span>"1"</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>2</div></div><div><span><span>anchor</span><span>.</span></span><span>dataset</span><span>.</span><span>ffHeroMounted</span><span> </span><span>=</span><span> </span><span>"1"</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>swup 换页时 <code>.page-header</code> 是新元素、没有标记，会重新插一份；同一份 DOM 反复触发则不会再插。实测三连触发后仍然是「页脚壳子 1 份 + 正文副本 1 份」。</p></section><section><h3>封面图：每次进来随机一张<a href="#封面图每次进来随机一张"><span>#</span></a></h3><p>想要的效果是：跟壁纸用同一批图，但不轮播，每次进来随机一张。</p><p>壁纸池在 <code>backgroundWallpaper.ts</code> 的 <code>desktop</code> 数组里 —— 但那是 <code>src/assets/</code> 下的图，构建后路径是哈希化的 <code>/_astro/xxx.webp</code>，静态 HTML 里写不出来。</p><p>仓库里那张图其实有<strong>三份</strong>，用途完全不同：</p>

<table><thead><tr><th>位置</th><th>内容</th><th>能不能当封面</th></tr></thead><tbody><tr><td><code>src/assets/images/DesktopWallpaper/</code></td><td>壁纸本体，优化过的 webp（160–290KB）</td><td>路径写不出来</td></tr><tr><td><code>public/gallery/desktop-wallpaper/</code></td><td>相册原图（<strong>1.4–2.4MB</strong>）</td><td>太重</td></tr><tr><td><code>public/images/desktop-wallpaper/</code></td><td>复制来的轻量副本</td><td>✅ 用这个</td></tr></tbody></table><p>池子写在注入 HTML 的 <code>data-covers</code> 上（逗号分隔），脚本在挂载时随机挑一张 —— 只在挂载时挑，页面内不轮播；切进切出算一次「进来」，会重新挑。</p><p>顺带剔了一张：壁纸数组里的 <code>cover.webp</code> 其实是「暂无封面」那张占位图，第一版收进了池子，随机到它非常难看。</p></section></section>
<section><h2>页面头部：拆掉那层壳<a href="#页面头部拆掉那层壳"><span>#</span></a></h2><p>封面区下面紧跟着上游的页面头部（图标 + 「站长动态」+ 描述 + RSS + 计数 + 搜索 + 年份筛选）。它自己是一张卡片，叠在封面卡片下面就是<strong>两层壳</strong>。</p><p>功能全留，壳拆了：背景、边框、圆角、阴影全撤，那个大图标也隐藏，两块的间距收到 4px —— 视觉上变成封面卡片的下半截。</p><p>「写说说」按钮本来在封面区下面单独占一行，只有一个按钮，又空又白吃一行高度。挪进了标题行右侧，跟「订阅RSS」「17 动态」排成一排（那格本来就是页面级操作区），尺寸收小到跟旁边两个小字协调。</p></section>
<section><h2>评论区：三个小改动<a href="#评论区三个小改动"><span>#</span></a></h2><p><strong>① 按钮上标评论数。</strong> 之前只有「评论」两个字，不点开不知道有没有人聊过。做法是进页面时批量问一次 Waline 的计数接口：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GET /api/comment?type=count&amp;url=/dynamic/xxx/&amp;url=/dynamic/yyy/…</span></div></div><div><div><div>2</div></div><div><span>→ {"data":[2,0,5,…]}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>一次请求拿回全部，按传入顺序返回 ✓。有评论就显示「评论 2」，0 条不带数字 —— 有数字本身就是在说有人聊过。</p><p><strong>② 只自动展开真有评论的。</strong> 一版之前做过「全部自动展开」，自己回看一遍又推翻了 —— 那时一进页面每条动态下面都摊着评论区，而其中大部分是空的。现在只展开 <code>count &gt; 0</code> 的，空面板保持收起；懒展开照旧走 <code>IntersectionObserver</code>（离视口 400px 才展开），一页十几条全展开会把首屏拖垮。自动展开只做一次，用户手动收起后不会再被弹开。</p><p><strong>③ 编辑器压成一行。</strong> 展开之后，Waline 那个「昵称 / 邮箱 / 网址 + 大输入框 + 表情栏」顶在最上面太挡视野。现在默认收成一个 35px 的输入框（整块 77px），点进去自动还原成完整表单（227px）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>editor</span><span>.</span></span><span>classList</span><span>.</span><span>add</span><span>(</span><span>"ff-compact"</span><span>);          </span><span>// 默认压缩</span></div></div><div><div><div>2</div></div><div><span><span>editor</span><span>.</span></span><span>addEventListener</span><span>(</span><span>"focusin"</span><span>, () </span><span>=&gt;</span><span><span> </span><span>editor</span><span>.</span></span><span>classList</span><span>.</span><span>remove</span><span>(</span><span>"ff-compact"</span><span>));</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>用「加类 / 摘类」而不是 <code>:not(:focus-within)</code> 反转，是因为后者得去猜 Waline 原本的 <code>display</code> 到底是 flex 还是 block —— 摘类让规则干脆不生效，它自己什么样就什么样。</p><section><h3>输入框的类名不是猜的那个<a href="#输入框的类名不是猜的那个"><span>#</span></a></h3><p>第一版我按 <code>.wl-textarea</code> 写选择器，压缩样式<strong>一声不响地没生效</strong>：编辑器该多高还多高。</p><p>dump 出来才知道，Waline v3 的输入框是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"wl-comment"</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>"wl-panel"</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"wl-header item3"</span><span>&gt;…昵称 / 邮箱 / 网址…&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>&lt;</span><span>textarea</span><span> </span><span>class</span><span>=</span><span>"wl-editor"</span><span>&gt;          ← 是它</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"wl-preview"</span><span>&gt;…</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"wl-footer"</span><span>&gt;…表情 / 字数 / 提交…</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>.wl-textarea</code> 压根不存在。改成 <code>.wl-editor</code> 才对。</p><p></p><figure><img alt="评论区：编辑器收成一行、按钮带评论数、回复引用照常" loading="lazy" width="1100" height="720" src="/_astro/comment.CB9w3Anz_Z2v4tup.webp" /><figcaption>评论区：编辑器收成一行、按钮带评论数、回复引用照常</figcaption></figure><p></p><p>顺带还藏掉了「评论区 / 分享你的想法，与大家交流讨论」那行标题 —— 动态页一条动态配一个评论区，标题只是白占地方。那块来自全站共用的 <code>src/components/comment/index.astro</code>（上游文件），文章页里它是正文与评论的分界、有用，所以<strong>只在动态这个 iframe 里藏</strong>，没全站删。</p></section></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>这一轮从头到尾<strong>没改一个主题源文件</strong>。红线自检（<code>src/pages</code>、<code>src/components</code>、<code>src/styles</code> 等目录）始终是空的。</p><p>三个坑有个共同点：<strong>都不报错</strong>。特异性不够只是样式被压过去、<code>:scope</code> 选择器不生效、类名写错只是匹配不到 —— 没有一个会给你红字提示。所以这类改造真正花时间的不是写样式，是验证「它到底有没有生效」：算一遍特异性、dump 真实 DOM 看类名、给每个改动配一条实测。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/weathercard/</id>
      <title type="text">全站加了个「天气预报」：接口免费，而且按访客 IP 定位</title>
      <published>2026-10-16T00:00:00.000Z</published>
      <updated>2026-10-16T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/weathercard/"/>
      <summary type="text">刷参考站时看到他右栏挂着一张天气预报卡，从标题竖条到「收起」按钮整个搬了过来。接口在 uapis.cn，免费、不用 key，而且不传参数时按访客 IP 定位 —— 谁来串门看到的就是谁头顶上的天。踩了两个坑：参考站那套图标是 Astro 构建期渲染的 symbol、动态插进去取不到；指标网格的 auto-fit 在 280px 右栏只排得出一列，卡片被顶到 925px。</summary>
      <content type="html"><![CDATA[<p>刷参考站的时候看到他右栏挂着一张天气预报卡 —— 地点、大号温度、一屏指标，底下还有个「收起」按钮，看着挺舒服。</p>
<p>琢磨了一下，这东西对博客来说几乎是白捡的：不用服务器、不用注册 key、不用维护，访客点进来还能顺手瞄一眼自己那儿的天气。于是照着他的样子整了一个，全站都有。</p>
<p></p><figure><img alt="参考站右栏那张天气预报卡" loading="lazy" width="439" height="1185" src="/_astro/image-001.BojA7KQb_2wgLwu.webp" /><figcaption>参考站右栏那张天气预报卡</figcaption></figure><p></p>
<section><h2>参考站长什么样<a href="#参考站长什么样"><span>#</span></a></h2><p>一张标准的侧栏卡片：标题「天气预报」左边一根主题色竖条，下面第一行是地点图标 + 「广州市 番禺区」，右上角一枚 3rem 的大天气图标；再往下是大号温度 29° 配一个「晴」字，接着一行「高温 32°C / 低温 25°C」—— 高温走红、低温走蓝；然后一条虚线分界，两列指标网格：风力、能见度、体感温度、湿度、气压、空气质量、云量、紫外线、降水量、降水概率、日出、日落，共 12 项，空气质量那格还带个「良」的等级标签；网格下面是全宽的「污染物浓度」，摊开 PM2.5 / PM10 / O₃ / NO₂ / SO₂ / CO 六个数值；卡片底部一行小字「9 分钟前发布」，最后是「收起」按钮。</p><p>我的版本结构上没怎么动 —— 参考站已经把信息密度调得挺合适了，我主要是换成了自己的注入方式，另外把展开/收起做成了默认展开。</p><p></p><figure><img alt="整张卡片展开的样子" loading="lazy" width="560" height="1386" src="/_astro/image-002.C4BPBrF4_Z1ChuIs.webp" /><figcaption>整张卡片展开的样子</figcaption></figure><p></p></section>
<section><h2>我的做法：只动两个文件<a href="#我的做法只动两个文件"><span>#</span></a></h2><p>这类侧栏组件想插进上游主题，得改 <code>SideBar.astro</code> 的 <code>componentMap</code>（它写死了支持的组件类型），那是主题源文件，不能碰。所以还是走本站用惯了的页脚注入：</p>

<table><thead><tr><th>文件</th><th>状态</th><th>干什么</th></tr></thead><tbody><tr><td><code>src/config/FooterConfig.html</code></td><td>改（追加）</td><td>全部样式 + PAGE_SCRIPTS 全站加载条目</td></tr><tr><td><code>public/assets/js/weather-card.js</code></td><td>新增</td><td>拼卡片 DOM、拉接口、渲染、挂载/搬运、折叠</td></tr></tbody></table><p>跟「最近评论」那次不太一样的是，这个组件的 DOM 我没放在页脚 —— 卡片要拼几十个节点，几乎每个都得按接口字段回填，摆在页脚那堆 HTML 里反而更难维护，索性全交给脚本拼。页脚只留样式和一个加载条目：</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>// 「天气预报」：全站加载 —— 桌面挂右栏、移动端挂底部栏，挂载逻辑在 js 里</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>path</span><span>:</span><span> /</span><span>.</span><span><span>*</span><span>/</span></span><span>,</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>id</span><span>: </span><span>"ff-weather-js"</span><span>,</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>src</span><span>: </span><span>"/assets/js/weather-card.js?v=1"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>git diff</code> 对 <code>FooterConfig.html</code> 是 470 行纯新增、0 删除，主题里一个文件都没改。</p></section>
<section><h2>核心机制<a href="#核心机制"><span>#</span></a></h2><p><strong>数据</strong>：参考站用的是 <code>uapis.cn</code> 的天气接口，前端直连一次拿全：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GET https://uapis.cn/api/v1/misc/weather?extended=true&amp;forecast=true</span></div></div><div><div><div>2</div></div><div><span>→ 省市 / 区县 / adcode / 天气 / weather_icon / 温度 / 风向风力 / 湿度</span></div></div><div><div><div>3</div></div><div><span><span>   </span></span><span>/ 高温低温 / 7 天预报 / 体感 / 能见度 / 气压 / 紫外线 / 降水量 / 云量</span></div></div><div><div><div>4</div></div><div><span><span>   </span></span><span>/ AQI + 等级 + 主要污染物 / PM2.5 PM10 O₃ NO₂ SO₂ CO / 发布时间</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>免费、不用注册 key，响应头带 <code>Access-Control-Allow-Origin</code>，浏览器直连不需要代理。中文描述现成（「晴」「多云」「东南风」都是接口直接给的），省得自己拿代码去翻。</p><p><strong>按访客 IP 定位</strong>是这个接口最舒服的地方：不传参数时，它按请求方 IP 反查位置。也就是说每个访客看到的是自己所在地的天气，不是我这儿的。等于白捡一个「个性化」。想固定成站长常驻地也行，<code>?city=广州</code> 或 <code>?adcode=440105</code>，脚本顶部留了个 <code>FIXED_CITY</code> 常量。</p><p><strong>挂载点按视口分两套</strong>，跟主题自己的布局规则走：≥768px 进右栏 top 组件容器的末尾（站点信息之后），&lt;768px 主题会把左右侧栏整块隐藏、改用页面底部的 <code>#mobile-bottom-sidebar</code>，组件就挂进里面的 <code>#bottom-sidebar</code>。两类容器都在 swup 替换范围之外，搬一次就永久有效：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>findMountPoint</span><span>() {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>if</span><span> (</span><span>isMobileNow</span><span>()) {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>var</span><span><span> </span><span>bar</span><span> </span></span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>"bottom-sidebar"</span><span>);</span></div></div><div><div><div>4</div></div><div><span>    </span><span>if</span><span><span> (</span><span>bar</span><span>) </span></span><span>return</span><span> { </span><span>parent</span><span>:</span><span><span> </span><span>bar</span><span>, </span></span><span>before</span><span>:</span><span> </span><span>null</span><span> };</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>6</div></div><div><span>  </span><span>var</span><span><span> </span><span>sidebar</span><span> </span></span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>"right-sidebar"</span><span>);</span></div></div><div><div><div>7</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>sidebar</span><span>) </span></span><span>return</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>8</div></div><div><span>  </span><span>var</span><span><span> </span><span>wrapper</span><span> </span></span><span>=</span><span><span> </span><span>sidebar</span><span>.</span></span><span>firstElementChild</span><span>; </span><span>// top 组件容器</span></div></div><div><div><div>9</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>wrapper</span><span>) </span></span><span>return</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>10</div></div><div><span>  </span><span>return</span><span> { </span><span>parent</span><span>:</span><span><span> </span><span>wrapper</span><span>, </span></span><span>before</span><span>:</span><span> </span><span>null</span><span> };</span></div></div><div><div><div>11</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>再挂一个 <code>matchMedia("(max-width: 767px)")</code> 的 change 监听，跨断点时把节点搬到另一侧。实测 swup 无刷新切 <code>/archive/</code> → <code>/about/</code> → 回首页三跳之后，节点还是同一个、折叠状态也保持，而且接口<strong>一次都没重新请求</strong>。</p><p><strong>折叠</strong>没去用上游 <code>WidgetLayout.astro</code> 那套 <code>&lt;widget-layout&gt;</code>。那个组件的展开逻辑写在自定义元素的 constructor 里，靠 <code>querySelector</code> 取自己的子节点 —— 我这边是脚本动态插进去的，构造时机不好控，赌浏览器在子节点就位前先把 constructor 跑掉就白搭。自己写反而简单，一个 class 切换加两行文案：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>applyCollapsed</span><span><span>(</span><span>next</span><span>) {</span></span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>collapsed</span><span> </span><span>=</span><span><span> </span><span>next</span><span>;</span></span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>root</span><span>.</span><span>classList</span><span>.</span><span>toggle</span><span>(</span><span>"is-collapsed"</span><span><span>, </span><span>collapsed</span><span>);</span></span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>label</span><span>.</span><span>textContent</span><span> </span><span>=</span><span><span> </span><span>collapsed</span><span> </span></span><span>?</span><span> </span><span>"展开"</span><span> </span><span>:</span><span> </span><span>"收起"</span><span>;</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>sessionStorage</span><span>.</span><span>setItem</span><span>(</span><span>VIEW_KEY</span><span><span>, </span><span>collapsed</span><span> </span></span><span>?</span><span> </span><span>"1"</span><span> </span><span>:</span><span> </span><span>"0"</span><span>);</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>默认<strong>展开</strong>（参考站是收起的，只露温度，我觉得天气卡的信息量就是它的卖点，没必要先藏起来），点一下压到 9.5rem —— 正好露出地点、温度和那条高低温；状态记在 <code>sessionStorage</code> 里，同一次会话内切页不会弹回默认。</p><p></p><figure><img alt="点「收起」之后只剩地点和温度" loading="lazy" width="560" height="504" src="/_astro/image-003.D7c_iXMj_X13Lt.webp" /><figcaption>点「收起」之后只剩地点和温度</figcaption></figure><p></p><p><strong>缓存给到 30 分钟</strong>，原因不是性能，是配额。这接口的响应头里有 <code>Ratelimit-Policy</code>：<code>visitor-quota 1500 credits / 31 天</code>、<code>ip-daily 2000 / 天</code>，每次请求扣 1 credit。个人博客的流量按 30 分钟的本地缓存算，一个访客半小时内翻多少页都只请求一次，撑得住；真哪天被跑爆了，就得在 CF Worker 上再加一层共享缓存。</p></section>
<section><h2>两个坑<a href="#两个坑"><span>#</span></a></h2><p><strong>一、图标不能照抄 <code>&lt;use&gt;</code>。</strong> 参考站那些图标是 Astro 用 <code>astro-icon</code> 在构建期按需渲染出来的 <code>&lt;symbol&gt;</code>，符号表只包含当页真正用到的几枚。我在脚本里动态插入节点，既不在构建期的渲染树里、页面上的符号表也没有这些 id，写 <code>&lt;use href="#ai:material-symbols:air"&gt;</code> 只会渲染成一片空白。</p><p>解法是把 path 内联进来。从 iconify 的 <code>material-symbols</code> 集合把用到的 34 枚图标的 path 全拉下来，塞进脚本当常量：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>var</span><span> </span><span>ICONS</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"air"</span><span>:</span><span> </span><span>'&lt;path fill="currentColor" d="M11.5 20q-1.25 0-2.125-.875T8.5 17h2q..."/&gt;'</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>// ... 33 枚</span></div></div><div><div><div>4</div></div><div><span>};</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>function</span><span> </span><span>ico</span><span><span>(</span><span>name</span><span>, </span><span>cls</span><span>) {</span></span></div></div><div><div><div>7</div></div><div><span>  </span><span>return</span><span> </span><span>'&lt;svg class="ff-w-ico '</span><span> </span><span>+</span><span><span> </span><span>cls</span><span> </span></span><span>+</span><span> </span><span>'" viewBox="0 0 24 24"&gt;'</span><span> </span><span>+</span><span> </span><span>ICONS</span><span><span>[</span><span>name</span><span>] </span></span><span>+</span><span> </span><span>"&lt;/svg&gt;"</span><span>;</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>代价是脚本从 18.7 KB 长到 31.4 KB（图标常量独占 12.7 KB）—— 换来的是一份完全自包含、不依赖任何运行环境的图标表，值。顺便把天气代码到图标的映射也补全了：接口给的是三位数代码（100 晴 / 101 多云 / 104 阴 / 3xx 雨 / 4xx 雪 / 5xx 雾霾），先查精确码、落空再按百位兜底，以后接口新增代码也不会显示成空白。</p><p><strong>二、<code>auto-fit</code> 在窄栏只排得出一列。</strong> 指标网格一开始写的是自适应的老配方：</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>grid-template-columns</span><span>: repeat(</span><span>auto-fit</span><span><span>,</span><span> minmax(8</span></span><span>.5rem</span><span><span>,</span><span> 1fr</span></span><span>));</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>8.5rem</code> 是 136px，当时觉得 280px 的右栏怎么也能塞两列。结果算下来要 136×2 + 8(gap) = 280px，而卡片内边距还要吃掉 32px，可用宽度只有 248px —— 一格都放不下第二个，12 项全竖着排，整张卡片被顶到 <strong>925px</strong> 高。</p><p>改成写死两列就完事，每列约 124px：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>grid-template-columns</span><span><span>: repeat(2</span><span>,</span><span> minmax(0</span><span>,</span><span> 1fr</span></span><span>));</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>卡片高度从 925px 落到 <strong>613px</strong>，差不多砍掉三分之一。教训是「自适应」在窄容器里经常不如硬编码 —— 右栏就这么宽，没什么好自适应的。</p></section>
<section><h2>上手步骤<a href="#上手步骤"><span>#</span></a></h2><ul>
<li><strong>换数据源</strong>：改 <code>weather-card.js</code> 顶部的 <code>API_URL</code>（字段名要跟着 <code>render()</code> 一起改）。</li>
<li><strong>固定城市</strong>：把 <code>FIXED_CITY</code> 填成城市名或 adcode，留空就是按访客 IP 定位。</li>
<li><strong>改默认展开 / 收起</strong>：<code>DEFAULT_COLLAPSED</code> 改成 <code>true</code>。</li>
<li><strong>改外观</strong>：样式全在 <code>FooterConfig.html</code> 末尾的 <code>.ff-w-*</code> 段，类名统一带前缀，不会跟别的组件撞。</li>
<li><strong>改了 JS 记得升 <code>?v=</code></strong>：<code>vercel.json</code> 给 <code>/assets/*</code> 配了 30 天强缓存，不加版本号老访客拿到的还是旧文件。</li>
</ul><p>验证走的是老一套：<code>astro dev</code> 起服务，headless 浏览器分别看亮 / 暗主题、1440 与 1280 两种右栏宽度、390px 移动端挂载点，再叠一次 swup 往返（三跳之后节点还是同一个、接口一次都没重新请求）。错误路径也单独试了：把接口请求掐断再重载，卡片会切到错误态并给一个「重试」按钮；重试时接口还挂着就继续停在错误态，不会白屏也不会卡在「加载中」；接口恢复后再点一下才回到内容。</p><p>暗色主题下颜色都是跟着主题变量走的，文字和图标不用单独调：</p><p></p><figure><img alt="暗色主题下的同一张卡" loading="lazy" width="560" height="1386" src="/_astro/image-004.C5OdyDoR_1uq41q.webp" /><figcaption>暗色主题下的同一张卡</figcaption></figure><p></p><p>移动端挂在页面底部的组件栏里，宽度拉满，指标网格还是两列，污染物那行三列：</p><p></p><figure><img alt="移动端底部组件栏里的天气预报" loading="lazy" width="752" height="1216" src="/_astro/image-005.DlXI3uEr_MKr8v.webp" /><figcaption>移动端底部组件栏里的天气预报</figcaption></figure><p></p><p>卡片在右栏用的是和其它侧栏组件同一套 <code>card-base</code>，圆角、描边和暗色适配都跟着主题走，不用单独调。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/commentreplycollapse/</id>
      <title type="text">评论区回复默认展开 2 条，长串收在「展开更多」后面</title>
      <published>2026-10-15T00:00:00.000Z</published>
      <updated>2026-10-15T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/commentreplycollapse/"/>
      <summary type="text">友链页有条评论下面挂了 13 条回复，往下翻要滑过整屏；可全收起来又太狠，访客第一眼什么都看不到。最后定成默认展示 2 条，多出来的才收着，还有剩就给个「展开更多」，摊完能收回去。做法照旧只加文件不动上游。踩坑集中在一点上：Waline 是 Vue 渲染的，DOM 会重绘、会随 swup 换页被整体替换，所以折叠状态不能只靠一次性的 DOM 操作。</summary>
      <content type="html"><![CDATA[<p>留言板里一条评论下面挂着 13 条回复，往下翻要滑过整整一屏。评论区本来是用来交流的，被长回复串这么一撑，反倒成了劝退现场。</p>
<p>第一版我做的是「回复全部默认收起，只留一个展开按钮」。真跑起来一看，方向偏了：访客第一眼看到的评论下面全是空的，每条想看的都得先点一下，比滑屏还烦。</p>
<p>于是改成<strong>默认展示 2 条</strong>，多出来的才收着 —— 有两条内容垫底，一眼能看出这条评论有没有人聊过，长度也可控。还有剩就给个「展开更多（还剩 M 条）」，每次再放 5 条；全部摊开后按钮变「收起回复」，箭头翻上去，点一下回到默认的 2 条。</p>
<p></p><figure><img alt="默认展示 2 条，剩下的收在「展开更多」后面" loading="lazy" width="1300" height="700" src="/_astro/cover.vfZ7_4t7_1zYxCO.webp" /><figcaption>默认展示 2 条，剩下的收在「展开更多」后面</figcaption></figure><p></p>
<section><h2>只处理回复这一层<a href="#只处理回复这一层"><span>#</span></a></h2><p>顶层评论保持原样、全部显示 —— 那是评论区的主体，收起来访客第一眼就什么都看不到了。要处理的是每条评论下面那一串回复。这条线划清楚之后，剩下的都是实现问题。</p><p>还有一条边界没得商量：<strong>回复本来就不超过 2 条的评论，我们完全不碰</strong> —— 不折叠、不打标记、连按钮都不插，跟没这个功能一样。只在真正会长的地方动手，是让这个功能不惹人烦的前提。</p>

<table><thead><tr><th>文件</th><th>状态</th><th>干什么</th></tr></thead><tbody><tr><td><code>src/config/FooterConfig.html</code></td><td>改（追加）</td><td>按钮样式 + PAGE_SCRIPTS 全站加载条目</td></tr><tr><td><code>public/assets/js/comment-reply-collapse.js</code></td><td>新增</td><td>插按钮、切可见条数、盯着 DOM 变化</td></tr></tbody></table><p>还是本站用惯的路子：页脚在 <code>#swup-container</code> 之外，切页不会被替换掉，所以样式写在它的 <code>&lt;style&gt;</code> 块里；脚本挂进 <code>PAGE_SCRIPTS</code> 全站加载（正则 <code>/.*/</code>，跟「最近评论」那条一样），没有 <code>#waline</code> 的页面自动空转。没有改任何主题源文件。</p></section>
<section><h2>先搞清楚 Waline 渲染成什么<a href="#先搞清楚-waline-渲染成什么"><span>#</span></a></h2><p>Waline 是 Vue 应用，DOM 不是写死的，动手前得先把渲染结果 dump 出来看。第一次抓就抓错了：<code>#waline .wl-comment</code> 看起来最像「一条评论」，其实它是<strong>编辑器面板</strong>（<code>.wl-comment &gt; .wl-panel</code>，里面是昵称、邮箱、网址三件套加一个 textarea）。</p><p>真正的结构是这样：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#waline</span></div></div><div><div><div>2</div></div><div><span>└── div</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>├── .wl-comment                  ← 编辑器（发评论那个框）</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>└── div</span></div></div><div><div><div>5</div></div><div><span><span>        </span></span><span>├── .wl-meta-head            ← 「25 评论」+ 按正序 / 倒序 / 热度</span></div></div><div><div><div>6</div></div><div><span><span>        </span></span><span>├── .wl-cards                ← 评论列表</span></div></div><div><div><div>7</div></div><div><span><span>        </span></span><span>│   └── .wl-card-item × 8    ← 顶层评论（id 就是评论的 objectId）</span></div></div><div><div><div>8</div></div><div><span><span>        </span></span><span>│       ├── .wl-user             ← 左侧头像列</span></div></div><div><div><div>9</div></div><div><span><span>        </span></span><span>│       └── .wl-card</span></div></div><div><div><div>10</div></div><div><span><span>        </span></span><span>│           ├── .wl-head         ← 昵称 / 时间 / 点赞 / 回复</span></div></div><div><div><div>11</div></div><div><span><span>        </span></span><span>│           ├── .wl-meta         ← 归属地 / 浏览器 / 系统</span></div></div><div><div><div>12</div></div><div><span><span>        </span></span><span>│           ├── .wl-content      ← 正文</span></div></div><div><div><div>13</div></div><div><span><span>        </span></span><span>│           └── .wl-quote        ← 回复全挂在这里</span></div></div><div><div><div>14</div></div><div><span><span>        </span></span><span>│               └── .wl-card-item × N</span></div></div><div><div><div>15</div></div><div><span><span>        </span></span><span>└── .wl-meta-foot            ← RSS 订阅 · Powered by Waline v3.16.1</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>这里有两个必须先确认的事实。</p><p>一是<strong>回复不是嵌套的顶层评论</strong>，二级条目并不是 <code>.wl-cards</code> 的直接子节点，而是塞在每张卡片自己的 <code>.wl-quote</code> 里。友链页显示「25 评论」= 8 条顶层 + 17 条回复，其中只有 4 条评论带回复，最长的那条挂了 13 条。</p><p>二是<strong>没有回复时 <code>.wl-quote</code> 依然存在</strong>。8 条顶层评论对应 8 个 <code>.wl-quote</code>，但只有 4 个里面有东西。所以判定条件不能写成「有没有 <code>.wl-quote</code>」，得看它有没有 <code>.wl-card-item</code> 直接子节点。</p></section>
<section><h2>折叠的时候只认直接子<a href="#折叠的时候只认直接子"><span>#</span></a></h2><p>Waline 里点「回复」会在原位插入一个编辑框，回复的回复也会往 <code>.wl-quote</code> 里塞条目。如果按 <code>querySelectorAll(".wl-card-item")</code> 一把捞，捞出来的可能混着深层嵌套的条目，层级就乱了 —— 数出来的「N 条回复」是错的，按序号切可见条数也会切错位置。</p><p>所以取子项一律走直接子：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>directItems</span><span><span>(</span><span>quote</span><span>) {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>var</span><span><span> </span><span>out</span><span> </span></span><span>=</span><span> [];</span></div></div><div><div><div>3</div></div><div><span>  </span><span>for</span><span> (</span><span>var</span><span><span> </span><span>i</span><span> </span></span><span>=</span><span> </span><span>0</span><span><span>; </span><span>i</span><span> </span></span><span>&lt;</span><span><span> </span><span>quote</span><span>.</span></span><span>children</span><span>.</span><span>length</span><span><span>; </span><span>i</span></span><span>++</span><span>) {</span></div></div><div><div><div>4</div></div><div><span>    </span><span>var</span><span><span> </span><span>el</span><span> </span></span><span>=</span><span><span> </span><span>quote</span><span>.</span></span><span>children</span><span><span>[</span><span>i</span><span>];</span></span></div></div><div><div><div>5</div></div><div><span>    </span><span>if</span><span><span> (</span><span>el</span><span>.</span></span><span>classList</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>el</span><span>.</span></span><span>classList</span><span>.</span><span>contains</span><span>(</span><span>"wl-card-item"</span><span><span>)) </span><span>out</span><span>.</span></span><span>push</span><span><span>(</span><span>el</span><span>);</span></span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>7</div></div><div><span>  </span><span>return</span><span><span> </span><span>out</span><span>;</span></span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>好处是那个编辑框压根不会被算进来，它没有 <code>wl-card-item</code> 这个类。按钮也一样，找它的时候只翻直接子，避免抓到深层同名元素。</p><p>显示控制也不走内联 <code>style</code>，而是加一个类，把样式留在页脚那份 CSS 里：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.wl-quote</span><span> </span><span>&gt;</span><span> </span><span>.ff-rp-hidden</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>display: </span><span>none</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>不写内联是因为不想跟 Vue 自己的 style 绑定打架；选择器前面挂 <code>.wl-quote &gt;</code> 是把优先级抬到 (0,2,0)，稳过 Waline 自带的 <code>.wl-card-item</code>，同时又是未分层 CSS，也压得过主题 <code>@layer</code> 里的规则。</p></section>
<section><h2>观察器挂 body，不挂 #waline<a href="#观察器挂-body不挂-waline"><span>#</span></a></h2><p>Waline 的渲染是异步的：<code>#waline</code> 这个容器先由 Astro 吐出来，评论列表要等客户端脚本从服务端拉完数据才长出来。而本站又套了 swup 无刷新切页，<code>#waline</code> 会随着页面容器被整个换掉。</p><p>如果观察器挂在 <code>#waline</code> 上，换页那一刻它监视的节点就没了，之后新渲染出来的评论区再也没人管 —— 表现就是「首页进去有折叠，站内点进文章页就没了」。挂在 <code>document.body</code> 上就没这问题，换页后重新长出来的节点照样能接住：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>new</span><span> </span><span>MutationObserver</span><span><span>(</span><span>schedule</span><span>).</span></span><span>observe</span><span><span>(</span><span>document</span><span>.</span></span><span>body</span><span>, {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>childList</span><span>:</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>subtree</span><span>:</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>4</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>代价是回调会被全站所有 DOM 变动触发，所以加了 160ms 防抖，并且第一件事就是 <code>getElementById("waline")</code>，没有就直接返回。</p></section>
<section><h2>Vue 重绘会把按钮带走<a href="#vue-重绘会把按钮带走"><span>#</span></a></h2><p>这是最需要防的一条。折叠状态是我们手动打在 DOM 上的（一个按钮节点 + 一个 <code>data-ff-shown</code>），而 Waline 会在切换排序、提交评论、加载分页时重绘列表。节点被重建之后，我们插的按钮可能没了，<code>dataset</code> 也可能残留 —— 要是只认 <code>dataset</code>，就会出现「标记说处理过了、但按钮其实不在」，那条评论的回复从此永久失踪。</p><p>所以判定写成了双重条件：<strong>标了已处理，且按钮确实还在</strong>，才算处理过。任一条不满足就重新接一遍。</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>if</span><span><span> (</span><span>quote</span><span>.</span></span><span>dataset</span><span>.</span><span>ffRp</span><span> </span><span>===</span><span> </span><span>"1"</span><span> </span><span>&amp;&amp;</span><span> </span><span>findButton</span><span><span>(</span><span>quote</span><span>)) </span></span><span>return</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>按钮只在会长的地方出现<a href="#按钮只在会长的地方出现"><span>#</span></a></h2><p>判定很糙，但很关键：</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>if</span><span><span> (</span><span>items</span><span>.</span></span><span>length</span><span> </span><span>&lt;=</span><span> </span><span>DEFAULT_SHOWN</span><span>) </span><span>return</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>注意这里<strong>连标记都不打</strong>。打过标记的意思是「这条我处理过了，以后不用再看」，而短回复串是会变长的 —— 有人接着回两条，它就超了。留着未处理状态，下一轮扫描（Waline 重绘时会触发）就能把它接上，不用刷新页面。</p><p>代价是按钮位置会随回复内容高度浮动，不像固定在每张卡片底部那样整齐。换来的是评论区里只有真正长的地方才多出一行东西。</p></section>
<section><h2>实测<a href="#实测"><span>#</span></a></h2><p>在本地 dev 上对着友链页跑了几轮：</p><p>友链页 8 条顶层评论，回复数分别是 2 / 13 / 1 / 0 / 0 / 1 / 0 / 0：</p>

<table><thead><tr><th>场景</th><th>结果</th></tr></thead><tbody><tr><td>首屏渲染</td><td>只有那条 13 回复的插了按钮：「展开更多（还剩 11 条）」，前 2 条平铺；其余 7 条（回复 2 / 1 / 0 条）完全没碰</td></tr><tr><td>点「展开更多」</td><td>7 条，按钮变「还剩 6 条」</td></tr><tr><td>再点</td><td>12 条，变「还剩 1 条」</td></tr><tr><td>再点</td><td>13 条全部显示，变「收起回复」，箭头翻转</td></tr><tr><td>再点</td><td>回到默认的 2 条，变回「展开更多（还剩 11 条）」</td></tr><tr><td>swup 无刷新切页（首页 → 友链页）</td><td>按钮照常出现，无丢失</td></tr><tr><td>切换排序（整个列表重渲染）</td><td>按钮数量不变、每条评论最多 1 个、无重复插入，回到默认 2 条</td></tr></tbody></table><p></p><figure><img alt="点开一次变成 7 条，按钮跟进到「还剩 6 条」" loading="lazy" width="1300" height="700" src="/_astro/result.D4rXJ-Pz_Z1z751E.webp" /><figcaption>点开一次变成 7 条，按钮跟进到「还剩 6 条」</figcaption></figure><p></p><p>折叠的是噪声，不是内容 —— 想聊的人照样点一下就看全，不想看的人不用再被一条 13 连回复的串推着往下滑。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/commentreplyquote/</id>
      <title type="text">回复带上引用：别再让读者猜「@昵称」在回哪一句</title>
      <published>2026-10-15T00:00:00.000Z</published>
      <updated>2026-10-15T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/commentreplyquote/"/>
      <summary type="text">Waline 的二级回复只渲染一行「@昵称：」，谁在回谁很清楚，回的是哪一句全靠猜。翻 DOM 发现它其实留了线索 —— 回复里那个 @昵称 的链接 href 就是被回复评论的 objectId。顺着锚点把目标评论捞回来，摘成引用块插在下面，点它还能滚过去高亮。踩了个坑：剥 @ 前缀时忘了 clone，把页面上那行整个删了。</summary>
      <content type="html"><![CDATA[<p>友链页一条评论下面挂了十几条你来我往，读起来像在拼拼图：</p>
<blockquote><p>@朝朝听雨：好简短的域名，是因为UK不能备案吗
@HY：uk是英国顶级域，备不了一点
@朝朝听雨：哈哈哈，我备案等了15天了</p></blockquote>
<p>谁在回谁，一眼看得出；回的是对方哪句话，全靠猜。人一多、话一长，<code>@昵称</code> 就彻底不够用了 —— 你能定位到人，定位不到「他那句话里的哪一句」。</p>
<section><h2>Waline 其实留了线索<a href="#waline-其实留了线索"><span>#</span></a></h2><p>Waline 是 Vue 渲染的，DOM 里翻一圈，每条回复的正文第一行是这样的：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>p</span><span> </span><span>class</span><span>=</span><span>"wl-reply-to"</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>&lt;</span><span>a</span><span> </span><span>href</span><span>=</span><span>"#4508715"</span><span>&gt;@朝朝听雨&lt;/</span><span>a</span><span>&gt;&lt;</span><span>span</span><span>&gt;: &lt;/</span><span>span</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span>&lt;/</span><span>p</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>那个 href 是 <code>#4508715</code> —— <strong>被回复那条评论的 objectId</strong>。而页面里每条评论的节点，id 正好也是它：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"4508715"</span><span> </span><span>class</span><span>=</span><span>"wl-card-item"</span><span>&gt;…&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"4508716"</span><span> </span><span>class</span><span>=</span><span>"wl-card-item"</span><span>&gt;…&lt;/</span><span>div</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>也就是说，Waline 早就把「我在回哪一条」写在 DOM 里了，只是<strong>它自己没用</strong>（浏览器原生锚点跳转一跳就不知道跳哪去了，不如不用）。那我来用。</p></section>
<section><h2>摘出「谁 + 说了什么」<a href="#摘出谁--说了什么"><span>#</span></a></h2><p>流程很短：拿到 <code>href</code> 里的 id → <code>getElementById</code> 找到目标评论 → 摘出昵称和正文 → 插一个引用块在 <code>@昵称：</code> 下面。</p><p>摘正文时有一件事必须做：<strong>把 <code>@昵称：</code> 那行前缀剥掉</strong>。因为目标本身可能也是一条回复，它自己的正文里也带着 <code>回复 @某某：</code>，直接取会让引用里再套一层 @，像回音。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 只剥副本里的 .wl-reply-to（那是「回复 @某某：」前缀，不是他说的话）</span></div></div><div><div><div>2</div></div><div><span>var</span><span><span> </span><span>clone</span><span> </span></span><span>=</span><span><span> </span><span>content</span><span>.</span></span><span>cloneNode</span><span>(</span><span>true</span><span>);</span></div></div><div><div><div>3</div></div><div><span>var</span><span><span> </span><span>prefix</span><span> </span></span><span>=</span><span><span> </span><span>clone</span><span>.</span></span><span>querySelector</span><span>(</span><span>".wl-reply-to"</span><span>);</span></div></div><div><div><div>4</div></div><div><span>if</span><span><span> (</span><span>prefix</span><span>) </span><span>prefix</span><span>.</span></span><span>remove</span><span>();</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>var</span><span><span> </span><span>text</span><span> </span></span><span>=</span><span><span> (</span><span>clone</span><span>.</span></span><span>textContent</span><span> </span><span>||</span><span> </span><span>""</span><span>).</span><span>replace</span><span>(</span><span>/</span><span>\s</span><span><span>+</span><span>/</span></span><span>g</span><span>, </span><span>" "</span><span>).</span><span>trim</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>cloneNode(true)</code> 这个 <code>true</code> 是关键，下面会讲我漏了它的下场。</p><p>引用块本身是个 <code>&lt;button&gt;</code>，点了就滚过去并高亮：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>target</span><span>.</span></span><span>scrollIntoView</span><span>({ </span><span>block</span><span>:</span><span> </span><span>"center"</span><span>, </span><span>behavior</span><span>:</span><span> </span><span>"smooth"</span><span> });</span></div></div><div><div><div>2</div></div><div><span><span>target</span><span>.</span></span><span>classList</span><span>.</span><span>remove</span><span>(</span><span>FLASH</span><span>);</span></div></div><div><div><div>3</div></div><div><span>void</span><span><span> </span><span>target</span><span>.</span></span><span>offsetWidth</span><span>;   </span><span>// 强制回流，让动画能连着重放</span></div></div><div><div><div>4</div></div><div><span><span>target</span><span>.</span></span><span>classList</span><span>.</span><span>add</span><span>(</span><span>FLASH</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>void target.offsetWidth</code> 那行是为了连点同一条时动画还能重放 —— 不强制一次回流，浏览器会把「移除类再立刻加回来」合并掉，第二次点就没反应了。</p><p></p><figure><img alt="「@昵称：」下面多了一行引用块" loading="lazy" width="1300" height="700" src="/_astro/cover.DP8DZkcn_2hutfr.webp" /><figcaption>「@昵称：」下面多了一行引用块</figcaption></figure><p></p></section>
<section><h2>踩坑：忘了 clone，把页面上的东西删了<a href="#踩坑忘了-clone把页面上的东西删了"><span>#</span></a></h2><p>第一版 <code>quoteOf</code> 是这么写的：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>var</span><span><span> </span><span>prefix</span><span> </span></span><span>=</span><span><span> </span><span>content</span><span>.</span></span><span>querySelector</span><span>(</span><span>".wl-reply-to"</span><span>);</span></div></div><div><div><div>2</div></div><div><span>if</span><span><span> (</span><span>prefix</span><span>) </span><span>prefix</span><span>.</span></span><span>remove</span><span>();   </span><span>// ← content 是真实节点</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>content</code> 是从<strong>真实 DOM</strong> 上取的，所以这行 <code>remove()</code> 删的不是副本，是页面上那行 <code>@朝朝听雨：</code> 本身。</p><p>而我要引用的正是它旁边的内容 —— 结果就是：<strong>每被引用一次，那条评论的 <code>@昵称：</code> 就消失一次</strong>。写的时候完全没意识到，直到跑完一看统计对不上：17 条回复插了 17 个引用块，页面上却只剩 8 个 <code>.wl-reply-to</code>。</p><p>这类 bug 的危险在于它不报错、页面也照常渲染 —— 只是少了点东西，而且少得很自然，不专门去数根本发现不了。</p><p>修法就一个字：<code>cloneNode(true)</code>。</p></section>
<section><h2>和「回复折叠」串起来<a href="#和回复折叠串起来"><span>#</span></a></h2><p>上一篇给回复做了折叠（默认展示 2 条，多出来的收起来）。这两个功能会撞车：</p><p>一条回复的引用块指向的目标，很可能正被折叠收着 —— 它 <code>display: none</code>，<code>scrollIntoView</code> 对它是没有任何反应的，点了就像没点一样。</p><p>所以折叠脚本那边开了个全局钩子：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>window</span><span>.</span></span><span>__ffReplyCollapseExpand</span><span> </span><span>=</span><span> </span><span>function</span><span><span> (</span><span>item</span><span>) {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>var</span><span><span> </span><span>quote</span><span> </span></span><span>=</span><span><span> </span><span>item</span><span>.</span></span><span>parentElement</span><span>;</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>quote</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>quote</span><span>.</span></span><span>classList</span><span>.</span><span>contains</span><span>(</span><span>"wl-quote"</span><span>)) </span><span>return</span><span>;</span></div></div><div><div><div>4</div></div><div><span>  </span><span>var</span><span><span> </span><span>items</span><span> </span></span><span>=</span><span> </span><span>directItems</span><span><span>(</span><span>quote</span><span>);</span></span></div></div><div><div><div>5</div></div><div><span>  </span><span>var</span><span><span> </span><span>index</span><span> </span></span><span>=</span><span><span> </span><span>items</span><span>.</span></span><span>indexOf</span><span><span>(</span><span>item</span><span>);</span></span></div></div><div><div><div>6</div></div><div><span>  </span><span>if</span><span><span> (</span><span>index</span><span> </span></span><span>&lt;</span><span> </span><span>0</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>7</div></div><div><span>  </span><span>if</span><span><span> (</span><span>index</span><span> </span></span><span>&lt;</span><span> </span><span>Number</span><span><span>(</span><span>quote</span><span>.</span></span><span>dataset</span><span>.</span><span>ffShown</span><span> </span><span>||</span><span> </span><span>0</span><span>)) </span><span>return</span><span>;</span></div></div><div><div><div>8</div></div><div><span>  </span><span>apply</span><span><span>(</span><span>quote</span><span>, </span><span>Math</span><span>.</span></span><span>min</span><span><span>(</span><span>items</span><span>.</span></span><span>length</span><span><span>, </span><span>index</span><span> </span></span><span>+</span><span> </span><span>1</span><span>));  </span><span>// 展开到这一条为止</span></div></div><div><div><div>9</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>引用脚本点下去之前先问一句：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span><span> (</span><span>target</span><span>.</span></span><span>classList</span><span>.</span><span>contains</span><span>(</span><span>"ff-rp-hidden"</span><span>)) {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>window</span><span>.</span><span>__ffReplyCollapseExpand</span><span><span>(</span><span>target</span><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>两个脚本各自独立加载、互不依赖，钩子没挂上就退化成「只滚不展开」，不会报错。展开后折叠按钮的计数也会跟着更新（实测：默认 2 条时点一个指向第 4 条的引用，会展开到 4 条，按钮同步变成「展开更多（还剩 9 条）」）。</p></section>
<section><h2>边界<a href="#边界"><span>#</span></a></h2>

<table><thead><tr><th>情况</th><th>处理</th></tr></thead><tbody><tr><td>目标不在当前页（分页翻走了、评论被删）</td><td>找不到节点，静默跳过，不插引用块，Waline 原样显示</td></tr><tr><td>目标是顶层评论</td><td>正常引用</td></tr><tr><td>目标本身也是回复</td><td>取其正文，但先剥掉它自己的 <code>@</code> 前缀，避免套娃</td></tr><tr><td>目标被折叠收着</td><td>点引用时先自动展开到那一条</td></tr><tr><td>引用的是自己</td><td>跳过（<code>target === item</code>）</td></tr><tr><td>正文太长</td><td>摘 120 字，CSS 再截到两行 —— 引用是提示「在回哪句」，不该跟正文抢长度</td></tr></tbody></table><p>考虑到本机首页贴纸、相册那些页面的经验，取子元素我一律走<strong>直接子</strong>而不是 <code>querySelector</code>：一条顶层评论的 DOM 里还嵌着十几条回复，<code>item.querySelector('.wl-content')</code> 捞到谁都全看文档顺序。同一个道理，判断「这条是不是已经加过引用块」也要看直接子，不然会把自己递归判断成「已经处理过」。</p><p>实测友链页：17 条回复全部带上引用块，<code>@昵称：</code> 一行不少、位置也对（引用块紧跟在它后面），点引用能滚到原评论并高亮。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/modssummary/</id>
      <title type="text">把 Firefly 魔改了五十一版：一份完整的功能清单</title>
      <published>2026-10-14T00:00:00.000Z</published>
      <updated>2026-10-16T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/modssummary/"/>
      <summary type="text">博客是 fork 的 CuteLeaf/Firefly，从 V1.0 到 V1.49 一共 51 版改动。这篇不再讲故事，直接把改过的功能全部列出来：首页那套贴纸卡片、自己加出来的那批页面、友链与书签的悬停预览和自助申请、全站卡片描边与页脚统计、评论区的折叠与引用、右栏那几个自建组件、一长串性能优化，还有内容与主题分离这类基础设施。每条都附上它到底干什么、关键数字是多少。</summary>
      <content type="html"><![CDATA[<p>这份清单回答一个问题：这个站到底改了哪些东西。</p>
<p>博客是 fork 的 <a href="https://github.com/CuteLeaf/Firefly" target="_blank">CuteLeaf/Firefly</a>，从 V1.0 到 V1.49 一共 51 版。下面按板块把所有功能列出来，每条附一句它干什么、关键数字是多少。逐条的完整背景、明细和日期都在站内的<a href="/blog-changelog/">更新日志页</a>（现在 V1.49、51 个更新日、248 条明细），封面和正文里的截图都是从站上实拍的。</p>
<p></p><figure><img alt="博客日志页：当前版本 V1.49、51 个更新日、248 条详细记录" loading="lazy" width="1600" height="952" src="/_astro/image-001.C93HeZnK_Z1PmEdY.webp" /><figcaption>博客日志页：当前版本 V1.49、51 个更新日、248 条详细记录</figcaption></figure><p></p>
<section><h2>首页<a href="#首页"><span>#</span></a></h2><ul>
<li><strong>玻璃个人卡片</strong>（V1.0）—— 首页壁纸区叠一层毛玻璃卡片：头像、站名、一行快捷导航，下面挂一排彩色标签的社交按钮（GitHub / Email 一键复制 / Bilibili / RSS / 打赏），卡片顶部还有一块胶带装饰。卡片、贴纸、便签依次浮入，从其他页面回到首页时重播。</li>
<li><strong>可拖拽的场景贴纸</strong>（V1.0 → V1.18）—— 一开始是 7 张，按住能拖、位置限制在壁纸范围内、刷新复位。后来素材扩到 19 张、桌面固定铺 11 张，排布也从「前后两层错位插空」改成「一排脚踩同一条地面线」——<code>left</code> 不能按等距算，各张基准宽度不同、图片在盒子里居中，按 <code>left</code> 等距排会让视觉中心最多漂 ±11px（1920 宽下）。</li>
<li><strong>贴纸按需加载</strong>（V1.10）—— 服务端只输出 <code>data-*</code> 属性、不发图片请求，脚本给抽中的那几张赋 <code>src</code> 才开始下载。手机端只显示 4 张，之前是 12 个请求 / 149 KB，现在就是那 4 张。</li>
<li><strong>第一张动图贴纸</strong>（V1.10）—— 「夏娜·甩发」，6 帧、300ms 一轮的动画 webp，从 GIF 转的（帧按并集 bbox 统一裁剪，缩放走预乘 alpha 防止红头发外圈挂黑边）。顺手把体积从 60.2 KB 压到 32.3 KB —— 这张图的 alpha 几乎只有 0/255，有损压缩碰不到它，所以质量可以放心降。</li>
<li><strong>卡片开场自动收起</strong>（V1.13）—— 卡片完整停留约 2 秒后整体向右滑出屏幕，露出纯壁纸。收起只做水平位移、不做缩放：手机端贴纸布局读的是卡片 rect 的上下边界，横向挪动不改这两个值，实测收起前后贴纸坐标零漂移，不需要重排。</li>
<li><strong>手动收起与叫回</strong>（V1.13）—— 卡片右上角一颗半透明 <code>−</code>，点它立刻收（用减号不用叉，因为这按钮是「先收起来」不是「永久关掉」）；收起后右边缘挂出一根竖排小签（默认「关于我」），点它、按回车或空格都能把卡片滑回来，带 <code>aria-expanded</code>。</li>
<li><strong>「隐藏贴纸」开关</strong>（V1.13）—— 右下角一颗开关，点一下把首页那排小人收走、再点放回来。收起时从左往右依次淡出、放回时从右往左接上，错开 40ms（可调成 0 = 整排一起动），状态存本地。</li>
<li><strong>贴纸给下滑箭头让路</strong>（V1.13）—— 贴纸层 <code>z-index</code> 压在箭头上面，居中那张正好把箭头盖死。没去动箭头层级（那样屏幕中央会多出一颗压在贴纸身上的圆钮），改成让贴纸让路：底部这排从素材里随机抽 10 张、左右各 5 张分列，正中央空出来。</li>
<li><strong>移动端卡片让位</strong>（V1.27）—— 屏幕越矮卡片和箭头离得越近，而卡片下方那排社交标签是刻意垂到面板外的吊牌，先被糊住的就是箭头。脚本按实测算出「卡片视觉底边 + 12px 净空」到箭头之间差多高，把差值写进 CSS 变量抬升卡片；屏够高（390×800 及以上）算出来是 0，等于一点都不动。</li>
<li><strong>首页动态滚动条</strong>（V1.23）—— 横幅下方一条横向滚动的条子：左边「动态」胶囊、中间滚最近 12 条站长动态、右边「更多」，悬停暂停，点某条跳到对应那条。它当 <code>#main-grid</code> 的 grid item 横跨左右侧栏，这样点下滑箭头滚到它时不会被甩出视口，塞进文章列又会太窄。DOM 默认 <code>display:none</code>、数据在页面渲染完之后才异步拉，拉不到整条不显示；实测掐断接口后 FCP 仍是 1112ms、CLS 为 0。</li>
<li><strong>关闭水波纹动效</strong>（V1.4）—— 桌面端与移动端的水波纹动画都关了，少一块持续占用主线程的 canvas 绘制。</li>
</ul></section>
<section><h2>新增页面<a href="#新增页面"><span>#</span></a></h2><ul>
<li><strong>朋友圈 <code>/moments/</code></strong>（V1.1）—— 把所有启用友链的最新文章聚成一条时间线，按时间倒序、支持分页。构建期抓各站 RSS/Atom，友链配置里没填 <code>rss</code> 的会去探常见路径和首页的 <code>&lt;link rel="alternate"&gt;</code>；抓取结果写成本地快照兜底，单站失败不阻断构建。页面里另有一张「随机文章」卡片，每 5 秒换一篇，本站标蓝徽章、推荐友链标金徽章。快照刷新走 GitHub Actions 定时任务，每周六 06&lt;00&gt; 跑一次并自动提交。</li>
<li><strong>朋友圈的来源配比</strong>（V1.6）—— 原本整体按发布时间倒序，结果被聚合站霸榜。改成按「本站 30% / Blog 40% / 博客社区 30%」交错：每一步挑「当前实际落后目标比例最多」的那一组取下一篇，组内仍是新的在前。每个友链的取文上限也从 6 篇提到 12 篇 —— 本站只有一个源，上限 6 篇时最多只能贡献 6 篇，而 40 篇里要占 3 成就得 12 篇，怎么配都凑不出来。已知副作用：几个月没更新的友链会掉出 40 篇名额。</li>
<li><strong>站点统计看板 <code>/analytics/</code></strong>（V1.12）—— 七个模块：四色总览卡、访问脉冲（7 / 30 / 90 天切换 + 手动刷新 + 数字滚动）、推送节奏热力图（按 GitHub 提交时间，带贪吃蛇动画）、访客活跃时间、设备与浏览器环形图、地域分布（中国地图 + 流量来源占比）、Waline 评论统计。数据走 Umami 公开分享接口、浏览器直连，取数要同时带 <code>x-umami-share-token</code> 和 <code>x-umami-share-context</code> 两个头，少一个就 401。</li>
<li><strong>看板改成本站配色</strong>（V1.14）—— 整页主色从写死的 <code>#2d7cff</code> 换成 <code>var(--primary)</code>，kicker、小标题、折线图、时间范围切换一次性全部跟随。推送数从 1695 修正到 252（仓库是 fork，<code>git log</code> 不加 <code>--author</code> 会把上游 CuteLeaf/Firefly 的历史全算进来）；推送热力图改用 GitHub 原版绿（这块本来就在模仿贡献图，跟主题色反而怪），「访客活跃时间」那块仍跟主题色；两个状态角标从右上角挪进标题块 —— 那两处 <code>header</code> 都是 <code>space-between</code>，右侧恰好都是角色插画，角标一直被顶上去压住它们。</li>
<li><strong>番组计划 <code>/bangumi/</code></strong>（V1.16 / V1.17）—— 最早开的是 <code>/bilibili/</code>（构建期拉追番追剧清单，221 条、均分 8.7），后来关掉换成番组计划：浏览器实时拉 <code>api.bgm.tv</code> 的收藏，动画 308 部、看过 277 部。改成访客侧拉取是刻意的 —— 构建期不请求任何外部接口，收藏更新也不用重新部署，代价是首屏靠 JS 渲染。封面图床有防盗链，得把 <code>*.bgm.tv</code> 加进 <code>noReferrerDomains</code>，不加会整页裂图。</li>
<li><strong>Steam 游戏库 <code>/steam/</code></strong>（V1.25）—— 拥有游戏数、总时长、近两周时长、等级徽章，以及按时长排序的全部游戏列表。两条取数路径自动切换：有 <code>STEAM_API_KEY</code> 走 Web API 拿完整列表，没有就退回 steamcommunity 公开 XML 的精简模式（只有常玩几款，快照打 <code>partial</code> 标记、页面上给提示）。封面图按 <code>apps/&lt;appid&gt;/header.jpg</code> 直接拼，省掉「先拉商店详情拿 hash」这一步。</li>
<li><strong>留言板群聊风格</strong>（V1.22）—— 把 Waline 默认评论框换成聊天窗口：气泡列表、公告栏、成员列表、表情包、图片上传、回复 / 编辑 / 删除、30 秒自动同步。后端一个字节没动，还是原来那个 Waline，但这一页的评论前端从完整客户端（100.9 KB gzip，走第三方 CDN）换成自写组件（23.4 KB brotli，同域托管），净减约 82 KB；50 KB 的组件样式也从全站 <code>main.css</code> 挪进这一页，只有 <code>/guestbook/</code> 加载。137 个新文案走自建语言表，没动 <code>src/i18n/</code>。</li>
<li><strong>留言板的一串修复</strong>（V1.22 / V1.26 / V1.30）—— 旧评论框和 Waline 后台发的回复显示不出引用块（解析回退到原生 <code>pid</code> / <code>reply_user</code> 字段，判据用 <code>typeof pid === 'number'</code>，因为一级评论的 <code>pid</code> 是 <code>null</code> 而不是缺失）；移动端公告栏被左上「留言板」、右上「成员」两个悬浮按钮压住（给会话区垫出 3.25rem 让开浮层式头部）；桌面端面板顶边正好贴着首屏壁纸下沿、太挤（往下压 2rem，只改 <code>margin-top</code> 不动高度）。公告弹窗还加了「不再显示」，写进 localStorage 之后该访客不再自动弹。</li>
</ul><p></p><figure><img alt="留言板：群聊风格的聊天窗口，右侧是成员列表" loading="lazy" width="1600" height="1000" src="/_astro/image-002.CCVtOZE6_ZuDyAC.webp" /><figcaption>留言板：群聊风格的聊天窗口，右侧是成员列表</figcaption></figure><p></p><ul>
<li><strong>博客日志页 <code>/blog-changelog/</code></strong> —— 就是这份清单的来源：每条改动记一行版本号、一句话摘要、背景说明和明细。V1.32 给它瘦过一次身：「背景」段落收进折叠区，默认只留版本徽章和标题，顺手把最长的两条描述从 1835 字压到 246 字。</li>
<li><strong>站长动态 RSS <code>/dynamic/rss.xml</code></strong>（V1.24）—— 动态页有了自己的订阅源，和页面同一份数据（当前 12 条），每条跳回 <code>/dynamic/</code> 对应锚点。动态页头部「N 动态」旁边注入了一个 RSS 按钮（小屏只留图标），<code>head</code> 里同步埋了 <code>rel="alternate"</code>。</li>
<li><strong>书签导航悬停预览</strong>（V1.31）—— <code>/booknav/</code> 的卡片悬停展开该站首页截图：卡片从 flex 改成 grid，封面独占一整行、高度 0 → 7rem 展开，图片首屏不加载，真悬停 / 聚焦过才请求。截图是离线跑脚本批量截的，带拦截校验 —— 渲染后 HTML 的前 4000 字符命中 Cloudflare 403 / 验证页特征就丢弃，再用 sharp 算通道标准差、纯色空白页也丢弃，否则悬停展开会是一张白图。</li>
</ul></section>
<section><h2>友链<a href="#友链"><span>#</span></a></h2><ul>
<li><strong>卡片悬停展开首页截图</strong>（V1.20）—— 鼠标停在友链卡片上，顶部展开一张对方首页的截图（高 7rem，截页面上半部分而不是居中裁剪，更像「首页」）。12 张 webp 总共 190 KB 左右，全部离线截好存在站内，首屏一张都不加载，脚本本体也只在 <code>/friends/</code> 才按需拉。</li>
<li><strong>自助申请</strong>（V1.21）—— 友链页第 2 步多了个「进入申请表」按钮，点进去是 GitHub Issue 表单。提交后机器人自己去访问对方站点，校验三项：主站可达、友链页可达且与主站同域、页面里确实挂了本站链接 —— 全过才写入配置并上线，没过就在 Issue 里回复具体原因。写入只往数组头部插一条，已有条目的字段和注释一个字节不动，并按域名去重；校验脚本零第三方依赖（只用 Node 内置 fetch），不需要装 Chromium，单次运行约 20 秒。</li>
<li><strong>社区单独分区</strong>（V1.33）—— 把「博客社区」那批（友链社区、博客聚合站）从主网格里挪到下方独立一栏，不再和个人博客混在一起。分区配置就是脚本顶部一个数组（标签、标题、说明），顺序即页面顺序，没归入任何分区的卡片进「其他」兜底；主题原有的标签筛选与搜索照常跨区块生效。</li>
<li><strong>头像防盗链</strong>（V1.1）—— 友链页与朋友圈的头像统一加 <code>referrerpolicy="no-referrer"</code>，解决 BlogsClub 头像 404；它那张头像另外改成本地托管，不再依赖对方站点资源。</li>
</ul></section>
<section><h2>全站视觉与交互<a href="#全站视觉与交互"><span>#</span></a></h2><ul>
<li><strong>卡片深色描边</strong>（V1.29）—— 参考站的卡片有一圈很明显的黑描边，扒下来发现主题自带 <code>enable-card-border</code> 开关、本站早就开着，只是边框色用了 <code>--line-divider</code>（亮色下是 8% 的黑，淡到看不出轮廓）。换成纯黑、宽度 2px，样式走页脚注入。难点全在补清单：文章卡片、侧栏组件、分页按钮先统一，自建页再逐个补（留言板主面板与两个弹窗、Steam 四类卡片、朋友圈的三种变体、书签导航、友链卡片、Bilibili 卡片、更新日志页）；顺便发现有几类卡片 <code>border</code> 只写了宽度和颜色、没写 <code>border-style</code>，那样是画不出来的。暗色下自动切纯白。代价是悬停时边框不再变主题色，反馈改由位移和阴影承担。</li>
<li><strong>页脚两行数字</strong>（V1.11）—— 第一行是建站运行时间（天 / 小时 / 分 / 秒，秒数逐秒跳，切到后台标签页直接停掉定时器、切回来立刻重算）；第二行是今日访客、今日访问、本站访客、本站访问四个指标，数字从 0 滚到目标值。令牌缓存 24 小时、今日数据 60 秒、全站数据 5 分钟，请求挂在 <code>window.load</code> 之后的 <code>requestIdleCallback</code> 上，不进首屏关键路径；取数失败时整行保持隐藏，既不显示 0 也不显示「加载中」。</li>
<li><strong>全站「最近评论」</strong>（V1.37）—— 全站右栏「最新动态」下方挂一个最近评论（移动端在底部组件栏）：直连 Waline 公开接口拉最新 8 条，每条含头像、昵称、相对时间、「评论于 /路径」。接口没给头像时用昵称首字 + 哈希色相的文字头像垫底，图片加载失败自动露出文字；结果在本地缓存 2 分钟。</li>
<li><strong>最近评论收窄到首页</strong>（V1.38）—— 全站显示改成只在博客首页（含分页）显示，文章页不再出现、也不占布局。</li>
<li><strong>开往入口</strong>（V1.36）—— 申请加入开往（Travellings）之后，顶栏加了一个外链入口，页脚徽标放在萌 ICP 备案号下方（黑白两版都自托管、跟着明暗模式切，不依赖对方站点可用性，也不因图床抽风而空白），移动端抽屉里也有。</li>
<li><strong>顶栏挤压的老毛病</strong>（V1.36）—— 顶栏在 1024–1599px 会把标签压成竖排。根因是「1280px 起导航图标出现、同一档搜索框展宽」，两头一挤就溢出；现在这个区间隐藏导航项图标（下拉面板里的条目图标保留）并把项目内边距从 16px 收到 8px。</li>
<li><strong>开往补回图标</strong>（V1.39）—— 加的是 <code>material-symbols:train-rounded</code>（列车正面，呼应页脚徽标里的车头造型）。上一版「省空间所以只留文字」的理由在这档宽度已经过期：紧凑模式的选择器会命中所有下拉容器的直接子项，开往同样被隐藏，实测余量仍是 1024px +40px、1280px +194px，一字没变 —— 图标只影响 ≥1600px，那里余量本来就够。</li>
<li><strong>「关于」菜单补自建服务</strong>（V1.40）—— 音乐播放器是自建服务，书签导航里有、顶栏右上角也有个图标按钮，但从「关于」这一排站内服务入口反而点不进去。补上外链项，位置跟书签导航里那条对齐；顺手把「本站评论系统」这个名字砍成「本站评论」。</li>
<li><strong>评论回复折叠</strong>（V1.42）—— 每条评论下的回复默认展示 2 条，多出来的先收着，还有剩就给个「展开更多（还剩 M 条）」、每次再放 5 条；全部摊开后变「收起回复」，点一下回到 2 条。回复本来不超过 2 条的评论完全不碰，连按钮都不插。折叠只认 <code>.wl-quote</code> 的直接子节点 —— Waline 点「回复」时插进来的编辑框不是这个类，不会被误伤；观察器挂在 <code>body</code> 而不是 <code>#waline</code>，因为后者会随无刷新切页被整个替换掉。</li>
<li><strong>回复带上引用</strong>（V1.43 / V1.44）—— Waline 的二级回复原本只有一行「@昵称：」，回的是他那句话里的哪一句全靠猜。其实 Waline 在 DOM 里留了线索：回复正文第一行 <code>wl-reply-to</code> 里那个 a 标签的 href 就是被回复评论的 objectId，而每条评论节点的 id 正好也是它 —— 顺着锚点把目标找回来，摘成引用块插在下面；点它会滚到原评论并闪一下，目标正被折叠收着时先自动展开到那一条。V1.44 又瘦了一次身：原生那行「@昵称：」不再显示（引用块里已经写明回的是谁），引用块去掉底色只留左边一根主题色竖线、前面补一枚 ↩ 符号，浅底色挪到回复正文上。</li>
<li><strong>页脚去掉开往徽标</strong>（V1.45）—— 顶栏那个入口已经在，页脚这枚就成了重复，撤掉，顺带删了自托管的两张黑白图。</li>
<li><strong>动态页评论跟上全站</strong>（V1.46）—— 动态页每条动态的评论跑在同源 iframe 里，而那个页面走的是不带页脚的布局，拿不到全站注入的样式与脚本，于是成了唯一一套「不折叠、无引用」的评论区。现在由父页面把样式和两条脚本代投进 iframe：样式按 id 从页脚 clone 过来（单一来源，改一处两边一起变），脚本直接复用现成的。回复折叠与回复引用在动态里照常生效。</li>
<li><strong>动态页照着朋友圈重做</strong>（V1.48）—— 参照一个同主题的站把动态页整套换了样子。动手前先扒了两边的 DOM，发现上游动态卡片的元素本来就齐（头像、作者、时间、地点、置顶徽章、图片网格、评论按钮一个不缺），差的只是样式 —— 所以没新开页面，纯样式覆盖加一点脚本注入就够，主题源文件零改动。卡片加了左侧主题色竖条、头像改成 40px 圆并描一圈主题色、昵称走主题色、图片网格换成固定 100px 方块（单图仍保留完整显示不裁剪）；封面区是上游压根没有的东西（封面大图、浮出的头像、昵称签名、写说说按钮、置顶条），外壳写在页脚、由一个脚本搬进正文，封面图从壁纸池里随机挑、每次进页面换一张；页面头部那层卡片壳连同大图标一起拆掉，内容收上去当封面卡片的下半截；评论区加了三件事 —— 按钮标评论数、只自动展开真有评论的条目、把 Waline 那个大编辑器压成一行（点进去自动还原）。</li>
<li><strong>俺妹指针主题</strong>（V1.47）—— 全站鼠标指针换成《俺妹》：亮色用高坂桐乃、深色用五更琉璃（黑猫），各 28 枚，共 112 个文件约 700 KB。切图时踩到一个反转：这批图标是「深色描边 + 白色填充」，人物衣服和箭头内部都是纯白、跟页面背景同色，按处理贴纸那套「全局去白底」会把图形掏成空壳 —— 改用孔洞填充，被描边围住的白色算图形内部，只把连到裁切框边缘的那片白做成透明。CSS 那边把「用哪张图 + 热点坐标」全塞进自定义属性（<code>:root</code> 放桐乃、<code>html.dark</code> 放黑猫），28 条规则只写一遍、切主题只换变量；每条三档声明覆盖高分屏，代码块则保留系统原生 I 型光标。</li>
<li><strong>右栏天气预报</strong>（V1.49）—— 参考站右栏挂着一张天气卡，从标题竖条到大号温度、两列指标（风力、能见度、体感温度、湿度、气压、空气质量、云量、紫外线、降水量、降水概率、日出日落）、污染物浓度和「收起」按钮整套复刻。数据走 uapis.cn 的免费接口，一次请求返回全部字段、中文描述现成、浏览器直连不用 key；最舒服的是不传参数时按请求方 IP 定位 —— 每个访客看到的是自己所在地的天气，想固定成常驻地就填 <code>FIXED_CITY</code>。全站显示，桌面挂右栏 top 容器末尾、小于 768px 换挂到移动端底部组件栏，跨断点自动搬运；默认展开，点一下压到 9.5rem 只露地点与温度，状态记在会话存储里。接口有配额（1500 credits / 31 天），所以本地缓存给到 30 分钟。两个坑：参考站那套图标是 Astro 构建期按需渲染的 symbol、动态插入的节点取不到，最后把 34 枚图标的 path 内联进脚本；指标网格的 <code>auto-fit</code> 在 280px 右栏算下来只排得出一列，把卡片顶到 925px，改成固定两列后落到 613px。</li>
</ul></section>
<section><h2>性能<a href="#性能"><span>#</span></a></h2><ul>
<li><strong>CDN 分层缓存</strong>（V1.2）—— HTML 加 <code>s-maxage=86400</code> 边缘缓存（浏览器端保持 <code>max-age=0</code>，发新文章立即可见）；<code>/_astro/</code> 哈希资源缓存一年并标 <code>immutable</code>，重新部署自动换哈希无需手动刷新；<code>/assets/</code>、<code>/pagefind/</code>、<code>/pio/</code> 缓存 30 天；<code>/api/</code> 边缘缓存 1 小时、浏览器不缓存。另加一个部署后自动从 sitemap 提取全站 URL 预热边缘缓存的工作流。</li>
<li><strong>壁纸瘦身</strong>（V1.3）—— 桌面 4 张 + 移动 3 张全部转 WebP q90：14.3 MB → 1.0 MB，首屏图片加载量减 93%。配套写了个一键压缩脚本，支持强制重跑，以后换壁纸跑一遍就行。同期给 HTML 缓存加了 <code>stale-while-revalidate=604800</code>：CDN 缓存过期后先给访客旧页面秒开，后台同时拉新页面更新缓存。</li>
<li><strong>首屏提速</strong>（V1.4）—— 贴纸图压到实际显示尺寸（源图 360×480，被 CSS 的 <code>max-height: 104px</code> 夹到约 78×104），8 张从 331 KB 降到 127 KB；修掉「装饰层入场动画要等 9 张图全部加载完才开始」的阻塞（改成只等卡片自身的图，兜底超时从 1400ms 收到 400ms）；favicon 从 88 KB 换成 8.9 KB —— 原来那个 <code>.ico</code> 其实是一张 221×183 的 PNG 改名的，重新生成了真多尺寸 ICO。首屏合计少传 283 KB。</li>
<li><strong>预取刹车</strong>（V1.45）—— 把 Lighthouse 抓的逐帧截图导出来一张张看才发现，首屏最大的那笔流量根本不是图片：Swup 视口预取会让链接只要露出 20% 面积并停留 500ms 就被提前下载，而首页第一屏正好有 7 个链接满足条件（卡片导航 5 个 + RSS / 打赏），一次抓回 483 KB 的页面 HTML，占移动端总流量的 34%。关掉之后首屏少发起 14 个请求。</li>
<li><strong>标题不再姗姗来迟</strong>（V1.5）—— 之前一直以为拖慢首屏的是壁纸或毛玻璃卡片，读了 trace 才确认最大元素是卡片里那行标题文字：它借用了通用内容关键帧、起点是全透明，而浏览器不把全透明的元素算作「已经画出来」，最大元素的计时就被硬生生推迟；更绕的是这个 0.36 秒的起点还得先等卡片里的小图加载完。换成专属关键帧（起点 <code>opacity: 1</code>，只保留 12px 的位移）后延迟从 0.36 秒砍到 0.08 秒，入场逻辑也拆成两段 —— 文字动画不再等图片。</li>
<li><strong>壁纸请求为什么晚</strong>（V1.45）—— 那张 89 KB 的壁纸请求直到 638ms 才发出：它藏在 <code>&lt;template&gt;</code> 标签里，浏览器解析 HTML 时根本看不到它，必须等内联脚本把它克隆进 DOM 才开始下载。</li>
<li><strong>别让小图抢带宽</strong>（V1.45）—— 侧栏头像改成懒加载并去掉 <code>fetchpriority=high</code>：一个小头像被标成高优先级，而真正的首屏标题和壁纸反而只有默认优先级。同时给 <code>/images/</code> 和 <code>/favicon/</code> 补上 30 天缓存头，这两类之前落在默认规则上、响应头是 <code>max-age=0, must-revalidate</code>，等于每次访问都要重新校验一遍。</li>
<li><strong>站内搜索提速</strong>（V1.41）—— 搜索一直有种说不清的「慢」：敲完字要等好几秒。两件事叠在一起。一是资源链太深 —— <code>pagefind.js</code>、worker、wasm、索引分片严格串行依赖，而它只在用户碰到搜索框时才开始加载，几跳的等待全落在输入之后；二是取数请求数和结果条数同阶，主题对全部结果逐个调 <code>data()</code>，而每个 <code>data()</code> 就是一次 fragment 请求，搜个常见词命中 45 条就是 45 个请求。现在新增一个预热脚本：首屏空闲或鼠标碰到搜索框时就把 worker 起来、wasm 编译好、索引读进内存；再用一层 Proxy 只让前 6 条真拉正文（面板本来就只显示 5 条），实测 45 → 6，结果数组长度不变所以「查看更多 N 条」的计数仍然准确。顺带补上「回车 = 进第一条结果」（<code>isComposing</code> 为真时放行，不干扰中文输入法）。移动网络与省流模式下跳过预热，不抢带宽。</li>
</ul></section>
<section><h2>素材与相册<a href="#素材与相册"><span>#</span></a></h2><ul>
<li><strong>贴纸素材的抠底与压缩</strong>（V1.9 / V1.18）—— 白底原图用「亮度 + 低饱和度谓词 + 四角洪水填充」抠底：靠连通性保住角色内部的白衬衫，同时吃掉落地灰影。画笔感强、浅色部件直接贴背景的那 6 张，本地抠图会咬掉剑刃和衣领，改用云端前景提取后再本地收边；云端抠图会把日期和画师签名当成前景留下，用「只保留面积 ≥ 最大连通域 5% 的块」一次清掉。</li>
<li><strong>相册「首页贴纸原图」</strong>（V1.8）—— 12 张全分辨率原稿：9 张 1440~2048px 的原始 PNG，加上 3 张只有 192px 版本的早期素材（那就是现存最清晰的形态）。相册索引自动收录，封面手动指定为桐乃那张欢呼。</li>
<li><strong>相册「夏娜表情」</strong>（V1.10）—— 9 张表情画稿：发怒、叹气、大哭、慌乱、懵圈、挥手、星星眼、脸红、蜜瓜包，原图不压缩存档。</li>
<li><strong>相册「统计页插画」</strong>（V1.15）—— 把 <code>/analytics/</code> 页面里的 7 张插画归档成新相册：页首横幅、两张角色立绘（保留透明通道）、四张概览卡片插画。三张 PNG 转 webp（质量 90、不放大、保留透明通道），四张本来就是 webp 的原样字节拷贝，避免二次有损压缩。</li>
<li><strong>壁纸池扩充</strong>（V1.7 / V1.18）—— 桌面池加了夏娜横版（1920×1005）、黑猫夏装横图（1920×1080），移动池加了夏娜竖版（1080×1920）和一张抠掉品红底、保留透明通道的黑猫（1080 宽，背后露的是页面底色，浅色 / 深色模式各自适应）。V1.4 起开启多张壁纸自动轮播，每 5 秒切一张。</li>
<li><strong>相册清理</strong>（V1.19）—— 「移动壁纸」相册删掉品红底的那张原图，「贴纸」相册删掉与单张高清版重复的九宫格源。</li>
<li><strong>相册「俺妹指针主题」</strong>（V1.47）—— 两张指针主题总览图（桐乃 / 黑猫各 28 枚）原图字节一致、零压缩归档，站点实际加载的是据此切出的 <code>public/images/cursors/</code>。这类原样直出的相册不另建 <code>gallery-originals/</code> 副本 —— 那个目录只给压过 webp 的相册用。</li>
</ul></section>
<section><h2>工程与基础设施<a href="#工程与基础设施"><span>#</span></a></h2><ul>
<li><strong>内容与主题分离</strong>（V1.28）—— 文章搬进独立的私有仓库，主仓库 <code>src/site-content/</code> 进 <code>.gitignore</code>，从此合并上游不用再为文章目录解冲突。构建前脚本从 <code>CONTENT_REPO_URL</code> 浅克隆内容（<code>--depth=1</code>），本地已有内容时自动跳过；本地开发走 <code>CONTENT_DIR</code> 加增量同步脚本，<code>dev.sh</code> / <code>dev.bat</code> 一键完成「同步 → 后台」。</li>
<li><strong>内容推送自动部署</strong>（V1.28）—— 内容仓库加了一个 workflow：push 之后调用 Vercel Deploy Hook 和 Cloudflare 部署挂链，文章推上去就自动重新构建上线。平台只监听主仓库，内容仓库推它感知不到，这一步是必需的。</li>
<li><strong>Tailwind 扫不到内容目录</strong>（V1.34）—— 友链页的申请模板本该一条字段一行，实际挤成一整段。查下来不是 CSS 写错：内容目录在 <code>.gitignore</code> 里，而 Tailwind v4 的自动内容探测会跳过被 git 忽略的路径 —— 于是「只出现在内容文件、主题源码从没用过」的工具类永远不会生成，类名照旧挂在 HTML 上、规则却不存在。解法是加一个登记表：同步脚本顺带扫内容里出现的 <code>class</code> 名、写进一个没被忽略的文件，内容仓那边一个字都没改。</li>
<li><strong>构建不再被接口拖垮</strong>（V1.35）—— 有一轮推了改动、等了十几分钟线上还是旧页面，翻日志才发现部署根本没成功：站长动态的 RSS 在构建期拉 Memos，实例解析到国内 IP、构建机在海外，直连必然超时；落到回退分支后又踩了个 bug —— <code>processor.render()</code> 是异步的、返回 <code>{ code, metadata }</code>，之前把 Promise 当 HTML 字符串往下传，走到剥标签那步就抛 <code>html.replace is not a function</code>。更麻烦的是上游适配器里那句 <code>promise.finally()</code> 派生出的 promise 没人接管，fetch 一失败就是未处理的 Promise 拒绝，在 Node 24 下直接把构建进程带走，调用方的 <code>try/catch</code> 拦不住。现在构建期先做一次带 4 秒超时的可达性探测，连不上就完全不调接口；另外对 Memos 请求失败产生的未处理拒绝加了窄范围兜底，只忽略超时、连接重置这类网络错误并打印告警，其它拒绝照旧抛出。</li>
<li><strong>给悬停封面做体检</strong>（V1.31）—— 书签导航的悬停封面走「构建期生成一份静态 JSON」，清单里没有对应条目时前端是 <code>if (coverUrl)</code> 直接跳过，<strong>静默不展开、零报错</strong>。所以把体检塞进那个静态 JSON 路由：比对配置与清单，缺了就 <code>console.warn</code> 列出来并附补图命令，零联网、零耗时。</li>
</ul></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>这五十一版几乎全部落在 <code>src/config/**</code>、<code>public/**</code> 素材和新增的独立文件上 —— 跟上游的实际差异是 522 个新增文件加 66 个改动，而真正落进组件和页面目录的只有 5 个（<code>CoverImage</code> / <code>WallpaperSection</code> / <code>Profile</code> / <code>friends.astro</code> 加了 1 行 / <code>guestbook.astro</code>），剩下的全是数据文件（<code>github-card-data.json</code> / <code>lqips.json</code>）。原因很实际：上游更新勤，改动挂在外面才好合并，出问题也能整段摘掉。</p><p>这套「挂外面」的做法最近又救了一次场：动态页那条链路要补的是全站评论增强，而动态的评论跑在 iframe 里、那个页面没有页脚 —— 换作直接改页面或组件，就得跟上游的 <code>comments.astro</code>、<code>DynamicInlineComments.astro</code>、<code>dynamic-inline-comments.ts</code> 三个文件纠缠；挂在页脚注入上，只多了一个新脚本就解决了。</p><p>逐条的完整明细还是看<a href="/blog-changelog/">更新日志页</a>。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/deployfolia/</id>
      <title type="text">music.9ll.uk 上线：全屏歌词播放器，顺手搭了个网易云 API</title>
      <published>2026-10-14T00:00:00.000Z</published>
      <updated>2026-10-15T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/deployfolia/"/>
      <summary type="text">把以全屏歌词为核心的音乐播放器 Folia 搭到了自己域名下。Web 版必须自备一个网易云 API，所以这次是两段部署；顺便记一下 Cloudflare 的「构建变量」和「运行时变量」不是一回事。</summary>
      <content type="html"><![CDATA[<p>我的书签页里有个「自建服务」分组，评论、图床、图片工具、监控、统计都在里面。这次往里加的是个音乐站。</p>
<p>地址先放这儿：</p>
<p><strong><a href="https://music.9ll.uk/" target="_blank">https://music.9ll.uk/</a></strong></p>
<p>图的是入口统一：浏览器打开就能听，手机上加到桌面就是个 PWA，不用每台设备都装一遍客户端。</p>
<p>而且这个站是<strong>对外开放</strong>的 —— 不是自用测试，也不是搭着玩，谁想听直接打开就行，不用打招呼。</p>
<section><h2>Folia 是什么<a href="#folia-是什么"><span>#</span></a></h2><p>项目叫 <a href="https://github.com/chthollyphile/folia-major" target="_blank">Folia</a>，AGPL-3.0。卖点不是曲库，是<strong>全屏歌词</strong>——歌词动画在这个项目里是主角，不是附属的滚动条。</p><p></p><figure><img alt="Folia 的全屏歌词：歌词动画占满整屏，底部只留一条进度条" loading="lazy" width="2048" height="1077" src="/_astro/image-001.DuKoksj__Z1DS11Y.webp" /><figcaption>Folia 的全屏歌词：歌词动画占满整屏，底部只留一条进度条</figcaption></figure><p></p><p>它有几个东西挺戳我：</p>

<table><thead><tr><th>能力</th><th>说明</th></tr></thead><tbody><tr><td>全屏歌词动画</td><td>十几种模式（浮名、流光、心象、云阶、群唱、镜台、时计……），可调参数，自适应窗口尺寸</td></tr><tr><td>多音源</td><td>网易云、酷狗、QQ 音乐、Navidrome、本地音乐库</td></tr><tr><td>本地歌库</td><td>导入本地音频，只上传索引不上传文件；自动匹配在线歌词和封面</td></tr><tr><td>AI 主题</td><td>按歌曲情绪和歌词内容生成沉浸式背景</td></tr><tr><td>多端</td><td>Electron 桌面端有现成安装包；Web 版能当 PWA 装</td></tr></tbody></table><p>我走的是 Web 版。界面就是一个封面网格，登录之后能直接看到自己网易云那边的歌单，卡片展开是播放和加入队列：</p><p></p><figure><img alt="Folia 的歌单页：封面网格，一张卡片展开了播放与加入队列按钮" loading="lazy" width="2048" height="1077" src="/_astro/image-002.CcrlUq8P_Z2wHI1v.webp" /><figcaption>Folia 的歌单页：封面网格，一张卡片展开了播放与加入队列按钮</figcaption></figure><p></p><p>点开一首，网格中间会浮出播放面板，歌词在封面下方跟着走，再点一下就是上面那张全屏歌词：</p><p></p><figure><img alt="点开一首歌，网格中间浮出播放面板，歌词跟着走" loading="lazy" width="2048" height="1077" src="/_astro/image-003.CmSFpON0_KiJSX.webp" /><figcaption>点开一首歌，网格中间浮出播放面板，歌词跟着走</figcaption></figure><p></p></section>
<section><h2>Web 版要自备一个网易云 API<a href="#web-版要自备一个网易云-api"><span>#</span></a></h2><p>这是整件事里最容易被忽略的一点。官方文档写得很直白：Web 版至少要有两样东西——一个放前端的地方，和<strong>一个可用的网易云 API 地址</strong>。</p><p>桌面端内置了前后端所以开箱能用，Web 版没有，你得自己准备。所以这次实际上是两段部署。</p><section><h3>第一段：网易云 API<a href="#第一段网易云-api"><span>#</span></a></h3><p>fork <a href="https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced" target="_blank">NeteaseCloudMusicApiEnhanced/api-enhanced</a>，丢 Vercel 导入就行，Framework Preset 认成 Express 是对的。</p><p>环境变量只需要一条：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>ENABLE_GENERAL_UNBLOCK</span><span>=</span><span>false</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>注意这是<strong>网易云 API 项目</strong>的变量，不是 Folia 前端的。官方文档特意点了这一句，两个项目的环境变量不是一套。</p><p>部署完 Vercel 会先给一个 <code>&lt;项目名&gt;-&lt;后缀&gt;.vercel.app</code> 的默认域名，能用，但地址又长又不好看，所以我在这个项目上另外绑了自己的域名：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://wyyapi.9ll.uk</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>第二段：Folia 前端<a href="#第二段folia-前端"><span>#</span></a></h3><p>fork <a href="https://github.com/chthollyphile/folia-major" target="_blank">chthollyphile/folia-major</a> 到自己的账号，然后 Cloudflare 后台：<strong>Workers 和 Pages → 创建应用 → Continue with GitHub → 选仓库</strong>。</p><p>仓库自带 <code>wrangler.jsonc</code>，所以不用像有些纯静态项目那样自己补配置：</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>"name"</span><span>: </span><span>"folia-major"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"main"</span><span>: </span><span>"./worker/index.ts"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"compatibility_date"</span><span>: </span><span>"2026-04-02"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>"assets"</span><span>: {</span></div></div><div><div><div>6</div></div><div><span>    </span><span>"directory"</span><span>: </span><span>"./dist"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>    </span><span>"binding"</span><span>: </span><span>"ASSETS"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>    </span><span>"not_found_handling"</span><span>: </span><span>"single-page-application"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>    </span><span>"run_worker_first"</span><span>: [</span><span>"/api/*"</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></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Cloudflare 自己就把构建命令识别成了 <code>npm run build</code>，部署命令 <code>npx wrangler deploy</code>，不用改。</p></section><section><h3>唯一必须填的构建变量<a href="#唯一必须填的构建变量"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>VITE_NETEASE_API_BASE</span><span>=</span><span>https://wyyapi.9ll.uk</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>
<section><h2>踩坑：Cloudflare 的「变量」和「构建变量」是两回事<a href="#踩坑cloudflare-的变量和构建变量是两回事"><span>#</span></a></h2><p>这个坑我差点踩进去。</p><p>Folia 仓库里有个 <code>runtime-config.js</code>，看名字像是”运行时才读配置”，那种思路下环境变量填在 Worker 的<strong>运行时变量</strong>里就够了。但我把它抓下来看了一眼，内容是这样的：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 当前文件：非 Docker Web 部署的空运行时配置，具体值继续由 Vite 环境变量提供。</span></div></div><div><div><div>2</div></div><div><span><span>window</span><span>.</span></span><span>__FOLIA_RUNTIME_CONFIG__</span><span> </span><span>=</span><span><span> </span><span>window</span><span>.</span></span><span>__FOLIA_RUNTIME_CONFIG__</span><span> </span><span>||</span><span> {};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>空壳。真正的值还是 <code>import.meta.env.VITE_*</code> 那一套，走 <strong>Vite 构建期</strong>注入。</p><p>也就是说 <code>VITE_NETEASE_API_BASE</code> 必须填在 Cloudflare 的<strong>构建变量</strong>（Settings → 构建 → 变量和机密）里。Cloudflare 自己的文档也写得很清楚：构建变量只在构建期可见，运行时读不到；反过来，运行时变量构建期也读不到。</p><div><div><div></div><div>Warning</div></div><div><p>这个填错位子不会报错，构建照样成功，只是产物里的 API 地址是空的，页面能打开但搜不到歌。排查起来挺费劲。</p></div></div><p><strong>怎么验证填对了</strong>：打开线上页面，看它实际请求的是不是你自己那个 API 域名。我这边在 <code>music.9ll.uk</code> 上抓到的请求是 <code>https://wyyapi.9ll.uk/...</code>，这才算真的通了。</p><div><div><div></div><div>换完地址重新部署之后，已经开着的页面或者装成 PWA 的可能会继续跑旧的那份资源——Service Worker 会把旧包喂回来。我这次就撞上了：老标签页上还在请求旧域名，换一个没有缓存的干净上下文打开才是新的。关掉重开或者强制刷新一下就行。</div></div><div></div></div></section>
<section><h2>第三段：绑域名<a href="#第三段绑域名"><span>#</span></a></h2><p>Worker 建好之后在「域」标签页里加 <code>music.9ll.uk</code>。因为 <code>9ll.uk</code> 本来就在同一个 Cloudflare 账号里，DNS 记录是自动建的，证书也一起下来了，没等多久。</p></section>
<section><h2>用之前先知道这几件事<a href="#用之前先知道这几件事"><span>#</span></a></h2><p><strong>第一，不登录只能试听 30 秒。</strong> Folia 的在线播放要登录网易云才完整，未登录状态下部分歌曲只能试听 30s，歌单、收藏、电台这些也看不到。</p><p><strong>第二，AI 主题现在是用不了的。</strong> 它要另外一组 key：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>VITE_AI_PROVIDER</span><span>=</span><span>google</span><span>        </span><span># 或者 openai</span></div></div><div><div><div>2</div></div><div><span>GEMINI_API_KEY</span><span>=</span><span>你的_key</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>我这次没配，所以 AI 主题那个入口点了没用。想用的话把这几个变量加进构建变量再重新部署一次就行。</p><p><strong>第三，QQ 音乐和酷狗这两条线有额外门槛。</strong></p><ul>
<li>QQ 音乐 API 需要常驻 Node 进程（扫码登录依赖 MQTT over WebSocket 长连接），跑不了 Vercel Serverless 或者 Cloudflare Workers，得另找一台机器或 Docker 环境，再把地址填进 <code>VITE_QQ_API_BASE</code>。</li>
<li>酷狗那边是播放节点默认 HTTP + CDN 证书错配，HTTPS 页面里混不进 HTTP，有时候会播放失败，属于上游的老问题。</li>
</ul><p>我这两个都没接，现在能用的是网易云在线 + 本地音乐库。</p><p><strong>第四，构建令牌那条告警可以先放着。</strong> Cloudflare 的构建设置里有一条「提取 GitHub 用户或组织详细信息时出错」。这次构建是正常的，先记一笔，真出问题再回头查。</p></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>代码都是原作者的，AGPL-3.0，我做的是 fork 一份、拆成前后端两段部署、再挂到自己域名下。许可证和署名都在仓库里留着。</p><p>部署过程这次同样是丢给 AI agent 用浏览器点完的，流程和前面那篇<a href="/posts/ai/aiagentautodeploy/">把浏览器交给 AI 之后</a>一样，就不重复了。区别是这次多了「网易云 API 得自己先搭」这一段，比纯静态项目麻烦一档。</p><p>地址再放一次：<strong><a href="https://music.9ll.uk/" target="_blank">https://music.9ll.uk/</a></strong></p><p>这个站是开放给大家用的，谁都能来听 —— 不是自用测试实例，也不是搭完摆着看的。</p><p>配套的网易云 API 单独挂了个域名：<a href="https://wyyapi.9ll.uk/" target="_blank">https://wyyapi.9ll.uk/</a></p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/deployimagetools/</id>
      <title type="text">img.9ll.uk 上线：压缩、AI 放大、AI 抠图，图片全程不上传</title>
      <published>2026-10-14T00:00:00.000Z</published>
      <updated>2026-10-14T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/deployimagetools/"/>
      <summary type="text">给自己域名挂了个纯浏览器端的图片工具箱：压缩格式转换、AI 超分放大、AI 抠图、点选抠图、裁剪切片，五项功能全在本机跑，图片一个字节都不上传。顺带记一下从 Pages 改成 Workers 的部署改动。</summary>
      <content type="html"><![CDATA[<p>平时处理图片的需求其实很碎：博客封面要压一下体积、截图要转 webp、偶尔想把一张糊图放大、做动态卡片又得抠个背景。</p>
<p>以前这些活儿我散在好几个站上干。压图用 Squoosh，抠图用某个在线抠图站，放大再换一个。麻烦倒不算麻烦，就是每次都得把图片传上去。传私人照片、传还没发的东西，心里总归有点别扭。</p>
<p>前几天刷到一个叫 <a href="https://github.com/haihaipypy/image-tools" target="_blank">image-tools</a> 的项目。</p>
<p>所以现在它挂在我自己域名底下了：</p>
<p><strong><a href="https://img.9ll.uk/" target="_blank">https://img.9ll.uk/</a></strong></p>
<section><h2>它能干什么<a href="#它能干什么"><span>#</span></a></h2><p>一共五个工具，共用同一个页面外壳，顶上胶囊标签直接切，不刷新页面：</p>

<table><thead><tr><th>工具</th><th>地址</th><th>说明</th></tr></thead><tbody><tr><td>图片压缩 / 格式转换</td><td><code>/</code></td><td>输出 AVIF、JPEG(MozJPEG)、JPEG XL、PNG(OxiPNG)、WebP，支持批量、拖拽、整个文件夹丢进去</td></tr><tr><td>AI 超分放大</td><td><code>/upscale/</code></td><td>Real-ESRGAN x4，分块推理</td></tr><tr><td>AI 抠图去背景</td><td><code>/cutout/</code></td><td>MODNet / BiRefNet Lite 两档</td></tr><tr><td>点选抠图</td><td><code>/segment/</code></td><td>MobileSAM，自己点一下指定要留的主体</td></tr><tr><td>裁剪 / 旋转 / 九宫格切片</td><td><code>/edit/</code></td><td>纯 Canvas，不加载模型</td></tr></tbody></table><p>关键点是：<strong>压缩走 WASM 编解码器，放大和抠图走浏览器里的 ONNX Runtime</strong>。也就是说计算全在你自己的设备上，图片从头到尾没离开过本机，服务器那边只是发静态文件。</p><p>我实测过一遍，抠图的边缘处理比不少在线站干净，尤其头发丝那种，BiRefNet 那一档能看出来是认真调过的。放大的速度看你有没有 WebGPU，有的话几秒出图，没有的话降级单线程 WASM，会慢好几倍。</p></section>
<section><h2>部署：从 Pages 改成 Workers<a href="#部署从-pages-改成-workers"><span>#</span></a></h2><p>项目本身是给 Cloudflare Pages 准备的，仓库里的 <code>wrangler.toml</code> 写的是 <code>pages_build_output_dir</code>，README 的推荐路线也是连 Git 建 Pages。</p><p>我要求部署到 Worker 里，所以这里得改一手。</p><p>fork 到我自己仓库之后，配置改成 Workers 静态资源那套：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>name</span><span> = </span><span>"image-tools"</span></div></div><div><div><div>2</div></div><div><span>compatibility_date</span><span> = </span><span>"2026-09-21"</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>[</span><span>assets</span><span>]</span></div></div><div><div><div>5</div></div><div><span>directory</span><span> = </span><span>"./dist"</span></div></div><div><div><div>6</div></div><div><span>not_found_handling</span><span> = </span><span>"404-page"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>package.json</code> 里的 <code>deploy</code> 脚本从 <code>wrangler pages deploy dist --project-name=image-tools</code> 换成 <code>wrangler deploy</code>。然后 Cloudflare 后台建 Worker、连仓库、构建命令 <code>npm run build</code>、部署命令 <code>npx wrangler deploy</code>，环境变量加一个 <code>NODE_VERSION=22</code>（Vite 8 要 Node 22.12+）。</p></section>
<section><h2>用之前先知道三件事<a href="#用之前先知道三件事"><span>#</span></a></h2><p><strong>第一，第一次用 AI 功能要等模型下载。</strong> 权重是运行时从 HuggingFace 拉的，我钉了 revision，缓存之后就好了。第一次点放大或者抠图，会看到进度条在下载，别以为卡死了。</p><p><strong>第二，你的设备决定速度。</strong> 有 WebGPU 的浏览器（Chrome / Edge 新版）最快；没有的话会自动降级，能用，就是慢。手机也能跑，但别拿它处理几十兆的图。</p><p><strong>第三，别拿它当全功能修图软件。</strong> 它只干「压缩、转格式、放大、抠图、切图」这五件事，没有图层、没有调色、没有画笔。想干这些还是得开 PS。</p></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>代码是<a href="https://github.com/haihaipypy/image-tools" target="_blank">原作者的</a>，MIT 协议，我做的只是 fork 一份、把部署目标从 Pages 换成 Workers、再挂到自己域名下。署名和许可证都在仓库里留着，放大和抠图用的权重版权归各自作者。</p><p>地址再放一次：<strong><a href="https://img.9ll.uk/" target="_blank">https://img.9ll.uk/</a></strong></p><p>压缩、转格式、放大、抠图、切图，五个功能都能用，不用注册、不用登录、不传图。有需要直接用，有什么问题留言告诉我。</p><p></p><figure><img alt="图片压缩页面，拖进去就行" loading="lazy" width="1400" height="1084" src="/_astro/image-001.Cg721lYO_LpUIO.webp" /><figcaption>图片压缩页面，拖进去就行</figcaption></figure><p></p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/ai/aiagentautodeploy/</id>
      <title type="text">把浏览器交给 AI 之后，我连 fork 都懒得点了</title>
      <published>2026-10-12T00:00:00.000Z</published>
      <updated>2026-10-12T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/ai/aiagentautodeploy/"/>
      <summary type="text">从 fork 仓库、补 wrangler 配置、点 Cloudflare 部署，到解析域名、加 Worker 路由、回头改自己博客的导航栏，这一整套我一次鼠标都没碰。记录一下 AI Agent 接管浏览器之后我懒成了什么样。</summary>
      <content type="html"><![CDATA[<p>前几天刷到一个叫 <code>grimenc</code> 的小工具，作者叫星彩，干的事是把图片混淆成人眼认不出来的样子，也能反着解回来。整个项目就是一个 <code>index.html</code>，纯静态，MIT 协议。</p>
<p>我想在自己域名下面挂一份。</p>
<p>然后我发现了一件有点可怕的事：<strong>我连 fork 都懒得点了。</strong></p>
<p>不是”想了想觉得麻烦，找人代打”。是真的一秒钟都没考虑过自己动手，脑子里直接就是——丢出去。</p>
<p>这篇不是教程，是记录我到底懒成什么样了。</p>
<section><h2>先说结果<a href="#先说结果"><span>#</span></a></h2>

<table><thead><tr><th>项目</th><th>结果</th></tr></thead><tbody><tr><td>线上地址</td><td><a href="https://hunxi.9ll.uk/" target="_blank">hunxi.9ll.uk</a></td></tr><tr><td>载体</td><td>Cloudflare Worker（纯静态资源）</td></tr><tr><td>源码</td><td>fork 到 <code>hy4962/grimenc</code></td></tr><tr><td>我的操作</td><td>说了四句话，没点过一次鼠标</td></tr></tbody></table></section>
<section><h2>我总共只说了四句话<a href="#我总共只说了四句话"><span>#</span></a></h2><p>把聊天记录翻出来数了一下，从头到尾我提的需求就这么几句：</p><ol>
<li>帮我在我的 cf worker 部署这个玩意</li>
<li>别本地上传，先去 fork 一份，然后链接 GitHub 创建</li>
<li>域名用 hunxi.9ll.uk，去用路由，别直接绑定域名，然后解析 CNAME 和 9ll.uk 一样就行</li>
<li>在我博客的书签里面添加这个网站，然后在关于子选项里面也添加这个网站</li>
</ol><p>就这些。</p><p>剩下的全在它那边：开浏览器、翻 Cloudflare 后台、fork 仓库、写提交、点部署、等构建、加 DNS、加路由、再回头改我博客的两个配置文件。</p></section>
<section><h2>它自己都干了什么<a href="#它自己都干了什么"><span>#</span></a></h2><section><h3>一、自己接上我正在用的 Chrome<a href="#一自己接上我正在用的-chrome"><span>#</span></a></h3><p>这是前置条件，之前专门写过一篇配置过程（<a href="/posts/ai/aiagentchrome/">让 AI Agent 接管你真实的 Chrome</a>），这里不重复。</p><p>关键在于：它拿到的不是我截图给它的信息，是<strong>我浏览器里真实的登录态</strong>。Cloudflare 后台、GitHub，都是我已经登录着的状态，它直接就能进。</p><p>进去第一件事是把我账户里已有的 Worker 列了一遍：<code>firefly</code>、<code>cloudflare-imgbed</code>、<code>cloud-mail</code>、<code>substracker</code>。它还顺便把账户 ID 和这几个项目的更新时间都读出来了。</p></section><section><h3>二、自己去 fork<a href="#二自己去-fork"><span>#</span></a></h3><p>它一开始想走 GitHub 的 API 直接 fork，结果吃了一个 403：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>POST https://api.github.com/repos/gascy37/grimenc/forks: 403 Resource not accessible by integration</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>那个连接器的 token 权限不够，fork 和建文件都不给干。</p><p>它没卡在这儿，直接转头去开浏览器，自己找到 fork 页面，填好 owner，点了「Create fork」。</p><p></p><figure><img alt="fork 完成后的仓库，多出来的 wrangler.jsonc 和 .assetsignore 是 AI 自己提交的" loading="lazy" width="1600" height="1000" src="/_astro/image-001.dtzkbLon_Z1eWULk.webp" /><figcaption>fork 完成后的仓库，多出来的 wrangler.jsonc 和 .assetsignore 是 AI 自己提交的</figcaption></figure><p></p></section><section><h3>三、自己发现 fork 完也部署不了，把配置补上<a href="#三自己发现-fork-完也部署不了把配置补上"><span>#</span></a></h3><p>fork 完之后它没直接点部署，而是先去仓库里看了一眼——这个项目只有一个 <code>index.html</code> 和一堆历史版本的 HTML，没有 <code>wrangler.jsonc</code>、没有 <code>package.json</code>。而 Cloudflare 默认的部署命令是 <code>npx wrangler deploy</code>，没有配置文件这条命令跑不起来。</p><p>于是它自己往仓库里加了两个文件，还用 GitHub 网页版写了两次提交：</p><ul>
<li><code>wrangler.jsonc</code>：声明 <code>assets.directory</code> 是仓库根目录</li>
<li><code>.assetsignore</code>：把 <code>.git</code>、<code>README.md</code>、<code>LICENSE.md</code> 这些不该对外发布的东西排除掉</li>
</ul><p>两个都是新增文件，不碰上游任何一行。这样以后从上游同步更新，不会因为我这两次提交产生冲突。</p></section><section><h3>四、自己点完了 Cloudflare 那一整套<a href="#四自己点完了-cloudflare-那一整套"><span>#</span></a></h3><p>创建应用 → Continue with GitHub → 选仓库 → 填项目名 → 部署。</p><p>构建跑完之后版本号是 <code>eb17a999</code>，状态「就绪」，22 秒。Cloudflare 在 Worker 概况页上给了一句提示，说这是<strong>仅包含静态资产的 Worker，这类请求是免费提供的</strong>——不用管请求数。</p></section><section><h3>五、自己加 DNS 和 Worker 路由<a href="#五自己加-dns-和-worker-路由"><span>#</span></a></h3><p>我要求别用自定义域，走路由。它先去把 <code>9ll.uk</code> 这个域下已有的记录抄了一遍，发现我原来的玩法是「CNAME 指向优选域名 + 灰云 + Worker 路由」，于是照着复制了一份：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>hunxi.9ll.uk  CNAME  1.cloudflare.182682.xyz   仅 DNS</span></div></div><div><div><div>2</div></div><div><span>hunxi.9ll.uk/*  →  grimenc</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>顺带说一句，那个「灰云 CNAME 指向优选域名居然还能触发 Worker 路由」这件事，我之前一直觉得是玄学。它给我的解释是：灰云只影响 Cloudflare 怎么应答 DNS，优选域名最后解析到的还是 Cloudflare 的 anycast IP，流量照样落在边缘，边缘按 Host 匹配 zone 之后该命中路由还是命中。我原有 <code>firefly</code> 那两个域名本来就是这么跑的，只是我没想明白而已。</p></section><section><h3>六、最后还去改了我的博客<a href="#六最后还去改了我的博客"><span>#</span></a></h3><p>我第四句话说完，它去读了博客仓库的配置，把入口加到了两个地方：导航栏「关于」的子菜单，和书签导航的「自建服务」分组。加完还起了个 dev server 自查了一遍，确认渲染出来的链接是 <code>https://hunxi.9ll.uk/</code> 才提交。</p></section></section>
<section><h2>懒的代价<a href="#懒的代价"><span>#</span></a></h2><p>写到这里得说实话，这事儿没有上面看着那么爽。</p><p><strong>第一，我事后才知道为什么。</strong> 为什么 fork 完还得补 <code>wrangler.jsonc</code>、为什么要走灰云——这些我都是看它做完之后才反应过来的。过程我是”看着的”，但不是”懂的”。</p><p><strong>第二，这次所有弯路都是它自己绕过去的，我一个都没帮上忙。</strong> 这句话听着像夸奖，其实是隐患：能力判断完全建立在我自己的感觉上，我没有一次能验证它到底是在解决问题还是在瞎试。</p><p><strong>第三，如果哪天它绕不过去，我可能连问题都描述不清。</strong> 那些报错和后台的页面结构，我要是自己没上手做过一遍，出问题时连去哪查都不知道。</p><p>所以我现在给自己划了三条线，这三类事我不放手：</p><ul>
<li>涉及钱的操作</li>
<li>有公开可见后果的（发帖、评论、对外通知）</li>
<li>删数据</li>
</ul><p>其它的，包括这次的部署，我确实懒得自己动手了。</p></section>
<section><h2>如果你也想这么懒<a href="#如果你也想这么懒"><span>#</span></a></h2><p>前提只有两个：</p><ol>
<li><strong>浏览器是通的</strong>。AI 得能操作你真实的 Chrome，带着登录态。这一步不解决，后面全是空谈。</li>
<li><strong>心态上接受”先跑起来再说”</strong>。它会在过程中撞墙、退回去换方案，你要能容忍那几分钟的不确定。</li>
</ol><p>工具限制这块提前知道两个能少走弯路：连接器要看一下是不是只有读权限；本机浏览器工具如果配了工作区白名单，文件上传和截图这类要落盘的功能可能直接用不了。</p></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>这次最让我有感觉的不是”它能干活”，而是<strong>它干活的过程里撞了墙、自己找路绕开，而且一次都没回头问我</strong>。</p><p>我唯一的作用是划边界和提需求。剩下的它自己找路。</p><p>省下来的那点时间，说实话我也没拿去干什么正经事。这大概就是这篇最诚实的结论了。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/recentcomments/</id>
      <title type="text">全站加了个「最近评论」：数据直连 Waline，主题文件一个没改</title>
      <published>2026-10-12T00:00:00.000Z</published>
      <updated>2026-10-12T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/recentcomments/"/>
      <summary type="text">在参考站的右栏看到「最近评论」组件，想在自己站上也挂一个，而且要求全站都有。评论数据就在自己的 Waline 后端里，直接调它的公开接口；DOM 和样式走页脚注入，脚本全站加载，桌面挂右栏、移动端挂底部组件栏。踩了三个坑：Waline 的 orig 字段是渲染前的 HTML、类名和外包装撞车吃掉 32px 内边距、窄栏 flex 布局把昵称挤成一个字。</summary>
      <content type="html"><![CDATA[<p>留言板的互动全都藏在各个页面的评论区里。有人留了言，除了当场那一下，别处再也看不到 —— 新访客点进来，完全感知不到「这个站是活的」。</p>
<p>右栏本来就有个「最新动态」，把站长的更新摆在台面上。那就再挂一个「最近评论」，把访客的互动也摆在台面上 —— 而且<strong>全站都要有</strong>，不止某一个页面。</p>
<p></p><figure><img alt="落在右栏「最新动态」下方的「最近评论」" loading="lazy" width="1300" height="700" src="/_astro/cover.cQSgXLMz_1CWgIO.webp" /><figcaption>落在右栏「最新动态」下方的「最近评论」</figcaption></figure><p></p>
<section><h2>参考站长什么样<a href="#参考站长什么样"><span>#</span></a></h2><p>拿来的参考图是一个标准的右栏小组件：顶部一行小字眉题加一枚「Waline · 最新 8 条」徽章，大标题「最近评论」，右边一个「去留言板」链接；下面是评论列表，每条 = 圆形头像 + 昵称 + 相对时间 + 「评论于 /某路径」+ 两行摘要；卡片底部一行小字「已展示 8 条公开评论 · 内容来自 Waline」。</p><p>我的版本把眉题和徽章去掉（跟本站其它侧栏组件对齐，只留标题条 + 右侧链接），列表结构和参考基本一致。</p></section>
<section><h2>我的做法：只动两个文件<a href="#我的做法只动两个文件"><span>#</span></a></h2><p>要挂载的页面是上游文件，侧栏的 <code>SideBar.astro</code> 也是 —— 它的 <code>componentMap</code> 写死了支持的组件类型，想加一种新 widget 就得改它。所以还是走本站用惯了的页脚注入：</p>

<table><thead><tr><th>文件</th><th>状态</th><th>干什么</th></tr></thead><tbody><tr><td><code>src/config/FooterConfig.html</code></td><td>改（追加）</td><td>组件外壳 DOM + 全部样式 + PAGE_SCRIPTS 全站加载条目</td></tr><tr><td><code>public/assets/js/recent-comments.js</code></td><td>新增</td><td>拉 Waline 接口、渲染列表、挂载/隐藏、监听 swup 事件</td></tr></tbody></table><p>页脚本身就在 <code>#swup-container</code> 之外，整页加载才跑一次，正好用来挂 <code>swup:page:view</code>。PAGE_SCRIPTS 是页脚里现成的机制：一个 pathname 正则数组，匹配上了才去 <code>document.head</code> 插对应脚本。其它条目都锚定具体页面（友链页、书签页），我这条要全站生效，正则直接放开：</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>// 「最近评论」：全站加载 —— 桌面挂右栏、移动端挂底部栏，挂载逻辑在 js 里</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>path</span><span>:</span><span> /</span><span>.</span><span><span>*</span><span>/</span></span><span>,</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>id</span><span>: </span><span>"ff-recent-comments-js"</span><span>,</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>src</span><span>: </span><span>"/assets/js/recent-comments.js?v=2"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>脚本第一个页面进来就装上，之后切页不重跑；每次 <code>swup:page:view</code> 只重新决策挂载点。</p><p><strong>挂载点按视口分两套</strong>，这也是主题自己的布局规则：≥768px 进右栏 top 组件容器，插在「最新动态」（<code>widget-layout[data-id="latest-dynamics"]</code>）之后；&lt;768px 主题会把左右侧栏整块隐藏、改用页面底部的 <code>#mobile-bottom-sidebar</code>，组件就挂进里面的 <code>#bottom-sidebar</code>。两类容器都在 swup 的替换范围外，搬一次就永久有效，切页不会丢；再挂一个 <code>matchMedia("(max-width: 767px)")</code> 的 change 监听，跨断点时把节点搬到另一侧，缩放窗口也不会丢。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>findMountPoint</span><span>() {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>if</span><span> (</span><span>isMobileNow</span><span>()) {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>var</span><span><span> </span><span>bar</span><span> </span></span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>"bottom-sidebar"</span><span>);</span></div></div><div><div><div>4</div></div><div><span>    </span><span>if</span><span><span> (</span><span>bar</span><span>) </span></span><span>return</span><span> { </span><span>parent</span><span>:</span><span><span> </span><span>bar</span><span>, </span></span><span>before</span><span>:</span><span> </span><span>null</span><span> };</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>6</div></div><div><span>  </span><span>var</span><span><span> </span><span>sidebar</span><span> </span></span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>"right-sidebar"</span><span>);</span></div></div><div><div><div>7</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>sidebar</span><span>) </span></span><span>return</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>8</div></div><div><span>  </span><span>var</span><span><span> </span><span>wrapper</span><span> </span></span><span>=</span><span><span> </span><span>sidebar</span><span>.</span></span><span>firstElementChild</span><span>; </span><span>// top 组件容器（flex flex-col gap-4）</span></div></div><div><div><div>9</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>wrapper</span><span>) </span></span><span>return</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>10</div></div><div><span>  </span><span>var</span><span><span> </span><span>latest</span><span> </span></span><span>=</span><span><span> </span><span>sidebar</span><span>.</span></span><span>querySelector</span><span>(</span><span>'widget-layout[data-id="latest-dynamics"]'</span><span>);</span></div></div><div><div><div>11</div></div><div><span>  </span><span>return</span><span> { </span><span>parent</span><span>:</span><span><span> </span><span>wrapper</span><span>, </span></span><span>before</span><span>:</span><span><span> </span><span>latest</span><span> </span></span><span>?</span><span><span> </span><span>latest</span><span>.</span></span><span>nextSibling</span><span> </span><span>:</span><span> </span><span>null</span><span> };</span></div></div><div><div><div>12</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>核心机制<a href="#核心机制"><span>#</span></a></h2><p><strong>数据</strong>：Waline 自带一个「最近评论」公开接口，前端直连即可：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>GET https://waline.9ll.uk/api/comment?type=recent&amp;count=8</span></div></div><div><div><div>2</div></div><div><span>→ { errno: 0, data: [{ nick, avatar, comment, orig, url, time, status, ... }] }</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>响应头带 <code>Access-Control-Allow-Origin: *</code>，CORS 是放开的，不需要代理。<code>url</code> 就是评论所在页面的路径（<code>/guestbook/</code>、<code>/friends/</code>、<code>/posts/...</code>），正好当「评论于」和跳转目标。拿到的数据按 <code>status === "approved"</code> 再过滤一遍，宁滥毋缺。</p><p><strong>渲染</strong>：头像用接口返回的 <code>avatar</code>；字段为空时垫一个「昵称首字 + 哈希色相」的文字头像，<code>img</code> 的 <code>error</code> 事件一到就移除图片露出文字。时间显示成相对时间（N 分钟前 / N 小时前 / N 天前）。摘要统一截到 120 字，CSS 再 <code>-webkit-line-clamp: 2</code> 收两行。</p><p><strong>缓存</strong>：<code>localStorage</code> 存 2 分钟。全站任何页面之间来回跳都不会重复请求，停留超过 2 分钟再打开才重新拉。</p><p><strong>样式</strong>：全部写在页脚的 <code>&lt;style&gt;</code> 里，只用主题 CSS 变量（<code>--primary</code>、<code>--content-meta</code>、<code>--btn-plain-bg-hover</code>）和 <code>card-base</code>，不碰 Tailwind 工具类 —— 注入 HTML 里的工具类一旦没被内容探测扫到就是静默失效，这个坑本站以前踩过。几何复刻 <code>WidgetLayout</code>：标题条 <code>ml-8/mt-4/mb-2</code> + 主题色竖条、内容区 <code>px-4/pt-4</code>、卡片 <code>pb-4</code>，视觉上和其它侧栏组件完全一致。</p></section>
<section><h2>必要的技术坑<a href="#必要的技术坑"><span>#</span></a></h2><p><strong>一、<code>orig</code> 字段不是纯文本。</strong> Waline 返回里有两个内容字段：<code>comment</code> 是渲染后的 HTML，<code>orig</code> 是渲染前的原始 HTML —— 名字看着像「原文」，实际含 <code>&lt;span class="wl-badge"&gt;AI&lt;/span&gt;</code> 这种标签。直接把 <code>orig</code> 当文本塞进去，摘要里就会露出一排 HTML 标签。正解是用渲染后的 <code>comment</code>，过一遍 DOM 取 <code>textContent</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>htmlToText</span><span><span>(</span><span>html</span><span>) {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>var</span><span><span> </span><span>box</span><span> </span></span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>createElement</span><span>(</span><span>"div"</span><span>);</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>box</span><span>.</span><span>innerHTML</span><span> </span><span>=</span><span> </span><span>String</span><span><span>(</span><span>html</span><span> </span></span><span>||</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>4</div></div><div><span>  </span><span>return</span><span><span> </span><span>box</span><span>.</span></span><span>textContent</span><span> </span><span>||</span><span> </span><span>""</span><span>;</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>只借 <code>innerHTML</code> 解析、马上读 <code>textContent</code>，不把用户内容插回页面，没有 XSS 面。</p><p><strong>二、类名撞车，白吃 32px 内边距。</strong> 条目里的文字容器图省事叫了 <code>ff-rc-body</code>，跟组件内容外包装同一个类 —— 外包装那条 <code>padding: 1rem</code> 规则把每条评论的正文区也吃了，meta 行从 194px 缩到 162px，昵称被挤得只剩一个字。改名 <code>ff-rc-main</code> 隔离。注入样式和 JS 生成的类名在同一个命名空间里，下手前先 grep 一遍。</p><p><strong>三、窄栏 flex：不设 grow，宽度就按 max-content 算。</strong> 摘要是两行 clamp 的段落，但它的 max-content 是一整行不换刀的宽（含长 URL，轻松 600px+）。文字容器没有 <code>flex-grow</code> 时，flex 基准取这个值，行内怎么分都不对。补 <code>flex: 1 1 auto; min-width: 0</code> 让容器稳定吃掉剩余宽度。另外meta 行里昵称设 <code>flex: 0 0 auto</code> 永不收缩 —— 窄栏放不下时截断「评论于 /路径」，身份比路径重要。</p><p><strong>四、内联脚本一个语法错误，整个增强静默失效。</strong> 调试中途 PAGE_SCRIPTS 整段没执行，脚本压根没被请求，查了半天才发现是编辑工具把数组某行的缩排弄坏了，<code>var PAGE_SCRIPTS = [...]</code> 语法错误 → 整个 IIFE 不跑 → 既没有报错也没有任何效果。改完 <code>FooterConfig.html</code> 先用 <code>new Function(scriptText)</code> 把所有内联脚本校验一遍再验证，这个文件里已经躺着四段脚本，坏任何一段都可能连带一片。</p></section>
<section><h2>上手步骤<a href="#上手步骤"><span>#</span></a></h2><ul>
<li><strong>换 Waline 地址或显示条数</strong>：改 <code>recent-comments.js</code> 顶部的 <code>API_URL</code>。</li>
<li><strong>改外观</strong>：样式全在 <code>FooterConfig.html</code> 末尾的 <code>.ff-rc-*</code> 段，标题文案和「去留言板」链接在外壳 HTML 里。</li>
<li><strong>改了 JS 记得把 <code>?v=</code> 往上加</strong>：<code>vercel.json</code> 给 <code>/assets/*</code> 配了 30 天强缓存，不加版本号老访客拿到的还是旧文件。</li>
<li><strong>验证</strong>：<code>astro dev</code> 起服务，用 puppeteer 量 <code>clientWidth</code>/<code>scrollWidth</code> 判断截断（肉眼会漏），再分别验亮/暗主题、1280px 窄栏、桌面/移动端两种挂载点、swup 往返，以及「每个页面类型都有」。</li>
</ul><p>最后是落地效果，亮色和暗色都跟着主题走：</p><p></p><figure><img alt="亮色主题下的「最近评论」" loading="lazy" width="560" height="1352" src="/_astro/result.dhvHiYv7_1DpjcL.webp" /><figcaption>亮色主题下的「最近评论」</figcaption></figure><p></p><p></p><figure><img alt="暗色主题下的「最近评论」" loading="lazy" width="560" height="1352" src="/_astro/dark.DBLgtFx4_JGgmx.webp" /><figcaption>暗色主题下的「最近评论」</figcaption></figure><p></p><p>移动端（375px）挂在页面底部的组件栏里，卡片拉满内容宽，meta 行空间充裕，「评论于」不再需要截断：</p><p></p><figure><img alt="移动端底部组件栏里的「最近评论」" loading="lazy" width="722" height="1184" src="/_astro/mobile.BH3RVefz_Z1NXO26.webp" /><figcaption>移动端底部组件栏里的「最近评论」</figcaption></figure><p></p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/ai/aiagentchrome/</id>
      <title type="text">让 AI Agent 接管你真实的 Chrome：三条路线和踩过的五个坑</title>
      <published>2026-10-07T00:00:00.000Z</published>
      <updated>2026-10-07T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/ai/aiagentchrome/"/>
      <summary type="text">无头浏览器里没有你的登录态，很多活干不了。这篇记录我怎么让 AI Agent 直接接管本机正在用的 Chrome：chrome-devtools-mcp 的完整配置、Playwright 扩展的隐藏机制、以及那个每次连接都要弹一次的授权框到底怎么才能少弹。</summary>
      <content type="html"><![CDATA[<p>之前让 AI 帮我干活，流程一直是这样的：我在浏览器里打开后台，截图，把关键信息复制出来贴给它，它给方案，我再切回浏览器操作。</p>
<p>来回几次就烦了。</p>
<p>无头浏览器我早就试过。Playwright 自带一个干净的 Chromium，跑起来很省事——问题是那个浏览器里什么都没有：没有我的登录态，没有我的 Cookie，没装我要用的扩展。你让它打开一个需要登录的后台，它只能停在登录页，然后告诉你”需要登录”。</p>
<p>我真正想要的是：<strong>让 AI 直接操作我正在用的这个 Chrome</strong>，带着我全部的登录态和已打开的标签页。</p>
<p>折腾了一晚上跑通了，顺便踩了五个坑。记一下。</p>
<section><h2>三条路线，先说结论<a href="#三条路线先说结论"><span>#</span></a></h2><p>能让 AI 碰到真实 Chrome 的路线我试出来三条：</p>

<table><thead><tr><th>路线</th><th>原理</th><th>弹授权框</th><th>能看到真实标签页</th></tr></thead><tbody><tr><td>Playwright CLI 扩展模式</td><td><code>chrome.debugger</code> API，不碰调试端口</td><td>❌ 不弹</td><td>⚠️ 有条件</td></tr><tr><td>chrome-devtools-mcp（调试端口）</td><td>CDP，走 <code>chrome://inspect</code> 开的口子</td><td>✅ <strong>每连接弹一次</strong></td><td>✅ 可以</td></tr><tr><td>无头 Chromium</td><td>Playwright 自带浏览器</td><td>—</td><td>❌ 完全隔离</td></tr></tbody></table><p><strong>结论：想要”能操作真实标签页”，目前只有第二条路走得通。</strong> 但它的代价是那个弹窗，下面细说。</p><p>第一条路的坑更隐蔽，我最后会讲。</p></section>
<section><h2>路线一：chrome-devtools-mcp 接上真实 Chrome<a href="#路线一chrome-devtools-mcp-接上真实-chrome"><span>#</span></a></h2><p>这是 Google 官方的 MCP 服务器，工具很全：点击、填表、导航、截图、看控制台报错、抓网络请求、跑性能 trace，一共 30 个工具。</p><section><h3>第一步：打开 Chrome 的远程调试口子<a href="#第一步打开-chrome-的远程调试口子"><span>#</span></a></h3><p>新版 Chrome 加了一个机制，不用重启浏览器、也不用加启动参数就能开远程调试（我这边是 154.0.8037.98）。地址栏输入：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>chrome://inspect/#remote-debugging</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>把那个开关打开。注意这个口子和老的 <code>--remote-debugging-port</code> 启动参数不是一回事——新版 Chrome 对那个端口做了限制，直接访问 <code>/json/version</code> 会返回 404，拿不到调试地址。所以<strong>必须走这个开关</strong>。</p></section><section><h3>第二步：装 MCP 服务器<a href="#第二步装-mcp-服务器"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>npm</span><span> </span><span>install</span><span> </span><span>-g</span><span> </span><span>chrome-devtools-mcp@latest</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>我装到的版本是 <code>1.10.1</code>。</p></section><section><h3>第三步：写配置文件<a href="#第三步写配置文件"><span>#</span></a></h3><p>我用的是 WorkBuddy，配置文件在 <code>~/.workbuddy-ai/mcp.json</code>。其他客户端路径不一样（Claude Code 是 <code>.mcp.json</code>，Cursor 在设置界面里填），但格式都是标准的 MCP 配置。</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>"mcpServers"</span><span>: {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>"chrome-devtools"</span><span>: {</span></div></div><div><div><div>4</div></div><div><span>      </span><span>"command"</span><span>: </span><span>"C:</span><span>\\</span><span>Users</span><span>\\</span><span>你的用户名</span><span>\\</span><span>.workbuddy-ai</span><span>\\</span><span>binaries</span><span>\\</span><span>node</span><span>\\</span><span>versions</span><span>\\</span><span>22.22.2-3</span><span>\\</span><span>node.exe"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>      </span><span>"args"</span><span>: [</span></div></div><div><div><div>6</div></div><div><span>        </span><span>"C:</span><span>\\</span><span>Users</span><span>\\</span><span>你的用户名</span><span>\\</span><span>.workbuddy-ai</span><span>\\</span><span>binaries</span><span>\\</span><span>node</span><span>\\</span><span>versions</span><span>\\</span><span>22.22.2-3</span><span>\\</span><span>node_modules</span><span>\\</span><span>chrome-devtools-mcp</span><span>\\</span><span>build</span><span>\\</span><span>src</span><span>\\</span><span>bin</span><span>\\</span><span>chrome-devtools-mcp.js"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>        </span><span>"--autoConnect"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>        </span><span>"--no-usage-statistics"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>        </span><span>"--no-performance-crux"</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></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></div></div></code></pre><div><div></div><div></div></div></figure></div><p>几个参数值得说：</p><ul>
<li><strong><code>--autoConnect</code></strong>：自动连本机正在跑的 Chrome，不用自己找调试端口</li>
<li><strong><code>--no-usage-statistics</code></strong>：关掉 Google 的用量上报</li>
<li><strong><code>--no-performance-crux</code></strong>：<strong>这个建议加上</strong>。默认它会把你访问的 URL 发到 Google 的 CrUX 接口，换取真实用户的性能数据。考虑到这是你的日常浏览器，不想让浏览记录外流就关掉</li>
</ul><p><code>command</code> 我直接写了 node 的绝对路径。用 <code>npx</code> 也能跑，但 MCP 服务器是常驻进程，绝对路径启动更稳定，也避免 npx 每次去解析包。</p></section><section><h3>第四步：信任它<a href="#第四步信任它"><span>#</span></a></h3><p>这一步最容易漏。<strong>改完配置文件不会自动生效</strong>，而且应用一般只在启动时读一次 <code>mcp.json</code>，所以：</p><ol>
<li><strong>重启客户端</strong></li>
<li>找到连接器管理页（WorkBuddy 里是左侧边栏「专家·技能·连接器」→「连接器」页签）</li>
<li>点右上角的「自定义连接器」，找到 <code>chrome-devtools</code>，点「信任」</li>
</ol><p>不点这个，AI 那边看到的工具列表里根本没有这些浏览器工具。</p></section><section><h3>第五步：验证<a href="#第五步验证"><span>#</span></a></h3><p>信任之后，让它列一下当前打开的页面。我第一次跑出来的结果是这样：</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>"content"</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>"type"</span><span>: </span><span>"text"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>      </span><span>"text"</span><span>: </span><span>"## Pages</span><span>\n</span><span>1: https://www.bilibili.com/video/BV1GHHn6NEnZ/ [selected]</span><span>\n</span><span>2: 首页-个性推荐-哔哩哔哩</span><span>\n</span><span>3: [74%][50%] SamplerCustomAdvanced (http://127.0.0.1:8188/)"</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>]</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>看到自己真实开着的标签页那一刻还是挺爽的。</p></section></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><section><h3>坑 1：那个弹窗，每次连接都弹<a href="#坑-1那个弹窗每次连接都弹"><span>#</span></a></h3><p>第一次连上，Chrome 会弹一个「要允许远程调试吗？」的框，只有「取消」和「允许」两个按钮。点允许，连上了。</p><p><strong>问题是它不是只弹一次。</strong></p><p>我一开始写了个脚本，每次执行一批操作就起一个新的 MCP 进程——结果连着弹了四五次，我在终端这边等，那边满屏幕找弹窗，非常难受。</p><p>查了一下，这是 Chrome 较新版本的<strong>设计行为：按连接弹窗，官方没有提供「永远允许」的选项</strong>。</p><p>所以：</p><ul>
<li><strong>弹窗次数 = 你建立连接的次数</strong></li>
<li>想让 AI 干活不被打断，就得让连接<strong>常驻</strong></li>
</ul><p><strong>正确做法是让客户端托管 MCP 进程</strong>（就是上面第四步的”信任”）。客户端会启动一次 MCP 服务器并保持长连接，Chrome 只在最开头弹一次，之后所有操作复用这条连接。</p><p>如果你自己写脚本调，那就<strong>把一批操作合并进同一次连接</strong>，别一次操作起一个进程。</p></section><section><h3>坑 2：Playwright 扩展看起来更优雅，但有个隐藏条件<a href="#坑-2playwright-扩展看起来更优雅但有个隐藏条件"><span>#</span></a></h3><p>Playwright 也有官方扩展路线，走的是 <code>chrome.debugger</code> API，<strong>完全不碰调试端口，所以 Chrome 永远不会弹那个框</strong>。听起来完美。</p><p>我装上试了，结果 <code>tab-list</code> 只返回一个页面——扩展自己的中继页 <code>connect.html</code>，看不到我任何真实标签页。</p><p>翻了扩展源码才搞明白。它 v0.4.0 的代码里写着：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>PLAYWRIGHT_GROUP_TITLE</span><span> </span><span>=</span><span> </span><span>"Playwright"</span></div></div><div><div><div>2</div></div><div><span>PLAYWRIGHT_GROUP_TITLE_PREFIX</span><span> </span><span>=</span><span> </span><span>`</span><span>${</span><span>PLAYWRIGHT_GROUP_TITLE</span><span>}</span><span> · `</span></div></div><div><div><div>3</div></div><div><span>// 只列出该组内的标签</span></div></div><div><div><div>4</div></div><div><span><span>tabs</span><span>.</span></span><span>query</span><span>({ </span><span>groupId</span><span>:</span><span><span> </span><span>g</span><span>.</span></span><span>id</span><span> })</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>它只共享处于名为 <code>Playwright</code> 的 Chrome 标签组里的标签。</strong> 组外的标签它一概看不见。</p><p>而且断开连接时，扩展会把这个组里的标签<strong>全部解组</strong>（源码里的状态消息是 <code>"All controlled tabs detached"</code>），下次连接得重新挑。</p></section><section><h3>坑 3：那个 token，用错了反而更糟<a href="#坑-3那个-token用错了反而更糟"><span>#</span></a></h3><p>扩展的弹窗里提示可以设一个环境变量绕过连接对话框：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>PLAYWRIGHT_MCP_EXTENSION_TOKEN</span><span><span>=</span><span>你的token</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>我设了，确实不弹了，4 秒就连接成功。<strong>但一个标签页都共享不到。</strong></p><p>原因很反直觉：<strong>那个”连接对话框”正是让你挑选要共享哪些标签的步骤</strong>。你把对话框绕过去了，就等于没挑标签，自然什么都看不到。</p><p>所以这个 token 的定位很尴尬——它省掉的恰好是你需要的那一步。<strong>想用扩展路线，就别用 token，老老实实走对话框挑标签。</strong></p></section><section><h3>坑 4：Windows 上开着代理，探测本地端口会得到假 404<a href="#坑-4windows-上开着代理探测本地端口会得到假-404"><span>#</span></a></h3><p>这个坑跟浏览器无关，但排查过程被它带偏了很久。</p><p>我本机设了 <code>http_proxy</code>，用 curl 去探测 Chrome 的调试端口时一直返回 404，我一度以为”端口没开”。实际上请求走了代理，返回的是代理给的 404。</p><p><strong>所有本地端口的探测都要加 <code>--noproxy '*'</code>：</strong></p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-s</span><span> </span><span>-D</span><span> </span><span>-</span><span> </span><span>-o</span><span> </span><span>/dev/null</span><span> </span><span>--noproxy</span><span> </span><span>'*'</span><span> </span><span>http://127.0.0.1:9222/json/version</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>同理，查 DNS 也一样——本机的 DNS 可能被透明代理劫持成 fake-ip（<code>198.18.x.x</code>），想看真实解析结果得走 DoH：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-s</span><span> </span><span>--noproxy</span><span> </span><span>'*'</span><span> </span><span>"https://dns.google/resolve?name=www.example.com&amp;type=CNAME"</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>坑 5：会话生命周期，脚本跑完连接就断<a href="#坑-5会话生命周期脚本跑完连接就断"><span>#</span></a></h3><p>如果你用命令行工具（比如 <code>playwright-cli</code>）而不是 MCP，要注意它的守护进程<strong>会随发起命令的 shell 一起退出</strong>。</p><p>也就是说你没法”先 attach，然后下一条命令再操作”——每次都得在同一次调用里把 attach 和后续命令串起来：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>playwright-cli</span><span> </span><span>attach</span><span> </span><span>--extension=chrome</span><span> &amp;&amp; </span><span>playwright-cli</span><span> </span><span>-s=chrome</span><span> </span><span>tab-list</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这个设计挺反直觉的，我一开始分成两条命令跑，第二条永远报 <code>Browser 'chrome' is not open</code>。</p></section></section>
<section><h2>安全边界，说清楚<a href="#安全边界说清楚"><span>#</span></a></h2><p>这条路等于把你整个浏览器交给 AI——包括所有登录态。</p><p>我自己的判断标准是：</p><ul>
<li><strong>日常浏览器和调试浏览器分开</strong>。我现在是拿日常这个 Chrome 在连，所以只在我盯着的时候开调试开关，用完就关</li>
<li><strong>别在有敏感后台登录态的时候开</strong>（网银、公司内部系统）</li>
<li>那个 <code>--no-performance-crux</code> 记得加上，不然你访问的 URL 会外流</li>
<li>自定义连接器在客户端里的定位就是”访问范围完全由你的配置决定”，<strong>没有预设边界</strong></li>
</ul></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><p>如果你只想快点跑通：</p><ol>
<li>Chrome 打开 <code>chrome://inspect/#remote-debugging</code>，开启开关</li>
<li><code>npm install -g chrome-devtools-mcp@latest</code></li>
<li>写 <code>mcp.json</code>，命令加 <code>--autoConnect --no-usage-statistics --no-performance-crux</code></li>
<li><strong>重启客户端</strong>，去连接器管理页点「信任」</li>
<li>让它 <code>list_pages</code>，能看到你的标签页就成功了</li>
</ol></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>三条路线试下来，<strong>chrome-devtools-mcp 是唯一能真正操作到真实标签页的</strong>，代价是那个躲不掉的授权弹窗——但只要你让客户端托管进程、保持长连接，它一辈子只弹一次。</p><p>Playwright 扩展那条路看起来更干净，但”只共享 Playwright 标签组内的标签”这个限制，加上那个会绕过挑标签步骤的 token，实际用起来比看起来麻烦。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/ai/comfyui-wrokflow/krea2-seedvr2-upscale/</id>
      <title type="text">Krea2 双采极清生图流：两段采样出图 + SeedVR2 int8 放大</title>
      <published>2026-10-07T00:00:00.000Z</published>
      <updated>2026-10-07T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/ai/comfyui-wrokflow/krea2-seedvr2-upscale/"/>
      <summary type="text">分享一套自用的 Krea2 出图工作流：先低清一采定构图，二采重采样补细节，再走高清重绘，最后用 SeedVR2 int8 放大到 2K 以上。附全部模型、插件下载地址，以及 SeedVR2 3B / 7B 的选型建议。</summary>
      <content type="html"><![CDATA[<section><h2>写在前面<a href="#写在前面"><span>#</span></a></h2><p>这套流是从 B 站 UP 主 <a href="https://space.bilibili.com/515231056" target="_blank">黑鹤001</a> 那边搬来的，原视频：<a href="https://www.bilibili.com/video/BV1t7YT6SEGz/" target="_blank">▶Krea2特调模型全盘点◆极清生图流◆SeedVR2-int8图像放大</a>，配着视频看更清楚。</p><p><a href="https://github.com/hy4962/Share/blob/main/Comfyui/Workflow/%E2%96%B6%E2%96%B7Krea2-%E5%8F%8C%E9%87%87%E6%9E%81%E6%B8%85%E7%94%9F%E5%9B%BE%E6%B5%81%2BSeedVR2-int8%E5%9B%BE%E5%83%8F%E6%94%BE%E5%A4%A7.json" target="_blank">Share/Comfyui/Workflow/▶▷Krea2-双采极清生图流+SeedVR2-int8图像放大.json at main · hy4962/Share</a></p><p>我这边只改了两处：主模型换成自己常用的 Moody Mix premium，文本编码器换成 bf16，其余没动。整套从生成到放大一条龙——<strong>Krea2 双采出图</strong> 打底，<strong>SeedVR2 int8</strong> 收尾，模型和插件我重新整理了一遍，免得每次都翻网盘。</p><p>之前写过一篇 <a href="/posts/ai/comfyui-wrokflow/krea2fourviewshare/">Krea2 四视图生成</a>，管的是角色设定；这篇管的是单张出图和高清放大，两套配合着用。之所以叫「双采极清」，是因为出图分了两段采样：一采先把构图定下来，二采在放大后的 latent 上重采样补细节，跑完再交给 SeedVR2 放大。</p><p>整个工作流用 <code>Fast Groups Bypasser</code> 分成了四段，想跑哪段开哪段，不用整条一起跑。</p></section>
<section><h2>这套流怎么走<a href="#这套流怎么走"><span>#</span></a></h2><p>四段，从出图到放大（组名就是 <code>Fast Groups Bypasser</code> 里显示的名字）：</p>

<table><thead><tr><th>组名</th><th>干什么</th></tr></thead><tbody><tr><td>Krea2-一采出图</td><td>定尺寸，先出一版构图</td></tr><tr><td>Krea2-二采出图</td><td>latent 放大 1.5 倍后重采样补细节</td></tr><tr><td>Krea2-高清重绘2</td><td>UltimateSDUpscale + OmniSR 再放大并轻微重绘</td></tr><tr><td>SeedVR2-7/3b-int8图像放大</td><td>最后一道，int8 量化放大到成品</td></tr></tbody></table><section><h3>一采：先定构图<a href="#一采先定构图"><span>#</span></a></h3><p><code>ResolutionSelector</code> 给的是 <strong>9&lt;16&gt;、1 MP、8 的倍数</strong>，宽高算好直接喂给 <code>EmptyLatentImage</code>。</p><p>主模型用 <code>UNETLoader</code> 挂 <strong>Krea2 的 int8 量化版</strong>（这套用的是社区调的 Moody Mix premium 写实人像版），后面串两个可选 LoRA——亚洲人像 LoRA 和 turbo 加速 LoRA。文本编码器走 <code>CLIPLoader</code>，类型记得选 <strong>krea2</strong>，模型是 <code>qwen3vl_4b</code>。</p><p>负向提示词这块，Krea2 系列的老规矩：<strong>直接 <code>ConditioningZeroOut</code> 置空</strong>，不用写。</p><p><code>KSampler</code> 一采给 <strong>8 步、denoise 1.0</strong>，euler + simple。跑完 <code>PreviewImage</code> 先看一眼构图对不对。</p></section><section><h3>二采：在 latent 上补细节<a href="#二采在-latent-上补细节"><span>#</span></a></h3><p>一采的 latent 不重新编码，直接 <code>LatentUpscaleBy</code> 放大 <strong>1.5 倍</strong>（bicubic），再走第二次 <code>KSampler</code>：<strong>5 步、denoise 0.5</strong>。</p><p>denoise 压到 0.5 是关键——这一步不是重画，是在放大后的 latent 上补细节。构图和动作在一采已经定死了，二采只往里加东西，不改结构。</p></section><section><h3>高清重绘：UltimateSDUpscale<a href="#高清重绘ultimatesdupscale"><span>#</span></a></h3><p>二采解码出图后，进 <code>UltimateSDUpscale</code>，配 <code>OmniSR_X4_DIV2K</code> 放大模型，按「放大倍数」再放一次（默认 <strong>1.5</strong>），denoise <strong>0.2</strong>，只做轻微重绘。</p><p>分块尺寸不用自己算：<code>SimpleMathDual+</code> 会读图像实际宽高，按「放大倍数 × 边分块数」自动算出 tile 宽高喂进去。边分块数默认 <strong>2</strong>，显存紧就往大了调。</p></section><section><h3>SeedVR2：最后一道放大<a href="#seedvr2最后一道放大"><span>#</span></a></h3><p>最后一段交给 <strong>SeedVR2</strong>（字节的放大模型），这套用的是 int8 量化版，<strong>1 步采样</strong>就能出。</p><p>流程是：先 <code>ResizeImageMaskNode</code> 按倍数缩一下输入（默认 1.5、lanczos），进 <code>SeedVR2Preprocess</code> → <code>VAEEncodeTiled</code> → <code>SeedVR2Conditioning</code>，采样解码后再走 <code>SeedVR2PostProcessing</code>（adain）对齐色彩，最后拆 alpha、过一道可选锐化、合回去保存。</p><p>旁边挂了个 <code>Image Comparer</code>，原图和处理结果叠在一起，拖滑块就能看 SeedVR2 到底补了多少。</p></section></section>
<section><h2>SeedVR2 用 3B 还是 7B<a href="#seedvr2-用-3b-还是-7b"><span>#</span></a></h2><p>这是这套流里最需要说清楚的一点。</p><p>工作流里 <code>UNETLoader</code> 默认挂的是 <strong>7B</strong>（<code>seedvr2_7b_int8_convrot.safetensors</code>）。但<strong>别无脑上 7B</strong>。</p><ul>
<li><strong>3B</strong>：适合 <strong>2K 以下</strong>的图。</li>
<li><strong>7B</strong>：适合 <strong>2K 以上</strong>的再放大。2K 以下的图硬上 7B，很容易出<strong>色块</strong>、画面变<strong>杂乱</strong>。</li>
</ul><p>我的用法很简单：先看要处理的图长边——<strong>不到 2K 就用 3B，到了 2K 以上再换 7B</strong>。</p><p>切换只动一个地方：<code>UNETLoader</code> 的模型下拉框换成 <code>seedvr2_3b_int8_convrot.safetensors</code> 就行，其他连线一根都不用动，VAE 还是共用的 <code>seedvr2_ema_vae_fp16.safetensors</code>。</p><div><div><div></div><div>一句话记法</div></div><div><p>3B 管 2K 以下，7B 管 2K 以上。别嫌麻烦，选错模型那一档，放大出来就是一脸色块。</p></div></div></section>
<section><h2>模型下载<a href="#模型下载"><span>#</span></a></h2><p>工作流一共用到下面这些模型：</p>

<table><thead><tr><th>用途</th><th>文件名</th><th>下载</th><th>存放目录</th></tr></thead><tbody><tr><td>Krea2 主模型</td><td><code>Krea2-Moody-Mix-premium_int8_convrot.safetensors</code></td><td><a href="https://pan.quark.cn/s/127bb3686ff8" target="_blank">黑鹤001 网盘（模型+工作流）</a> · <a href="https://huggingface.co/catlover1937/moody-krea-mix" target="_blank">Moody Mix 系列镜像</a> · <a href="https://huggingface.co/Comfy-Org/Krea-2/tree/main/diffusion_models" target="_blank">官方 Krea2（备选）</a></td><td><code>models/diffusion_models/Krea2/</code></td></tr><tr><td>文本编码器</td><td><code>qwen3vl_4b_bf16.safetensors</code></td><td><a href="https://huggingface.co/Comfy-Org/Krea-2/tree/main/text_encoders" target="_blank">Comfy-Org/Krea-2</a></td><td><code>models/text_encoders/</code></td></tr><tr><td>VAE</td><td><code>qwen_image_vae.safetensors</code></td><td><a href="https://huggingface.co/Comfy-Org/Krea-2/tree/main/vae" target="_blank">Comfy-Org/Krea-2</a></td><td><code>models/vae/</code></td></tr><tr><td>加速 LoRA</td><td><code>krea2_turbo_lora_rank_64_bf16.safetensors</code></td><td><a href="https://huggingface.co/Comfy-Org/Krea-2/blob/main/loras/krea2_turbo_lora_rank_64_bf16.safetensors" target="_blank">Comfy-Org/Krea-2</a></td><td><code>models/loras/Krea2-功能/</code></td></tr><tr><td>亚洲人像 LoRA</td><td><code>Krea2-亚洲hina_krea2Turbo_lora_tqd_v4.0.safetensors</code></td><td><a href="https://tensorhub.art/models/1029695748163239939" target="_blank">TensorHub</a> / <a href="https://www.runninghub.cn/model/public/2079437791267737602" target="_blank">RunningHub</a></td><td><code>models/loras/Krea2-功能/</code></td></tr><tr><td>放大模型</td><td><code>OmniSR_X4_DIV2K.safetensors</code></td><td><a href="https://huggingface.co/Acly/Omni-SR/blob/main/OmniSR_X4_DIV2K.safetensors" target="_blank">Acly/Omni-SR</a></td><td><code>models/upscale_models/</code></td></tr><tr><td>SeedVR2 主模型（7B）</td><td><code>seedvr2_7b_int8_convrot.safetensors</code></td><td><a href="https://huggingface.co/Comfy-Org/SeedVR2/tree/main/diffusion_models" target="_blank">Comfy-Org/SeedVR2</a></td><td><code>models/diffusion_models/Seedvr2/</code></td></tr><tr><td>SeedVR2 主模型（3B）</td><td><code>seedvr2_3b_int8_convrot.safetensors</code></td><td><a href="https://huggingface.co/Comfy-Org/SeedVR2/tree/main/diffusion_models" target="_blank">同上</a></td><td><code>models/diffusion_models/Seedvr2/</code></td></tr><tr><td>SeedVR2 VAE</td><td><code>seedvr2_ema_vae_fp16.safetensors</code></td><td><a href="https://huggingface.co/Comfy-Org/SeedVR2/tree/main/vae" target="_blank">Comfy-Org/SeedVR2</a></td><td><code>models/vae/</code></td></tr></tbody></table><div><div><div></div><div>子目录别忘了建</div></div><div><p>Krea2 主模型、两个 LoRA、SeedVR2 主模型在工作流里的路径都带前缀（<code>Krea2\</code>、<code>Krea2-功能\</code>、<code>Seedvr2\</code>），要先把对应子文件夹建好再往里放，不然加载器的下拉框里根本选不中。
文本编码器、VAE、放大模型都没有前缀，直接丢进各自的根目录就行。</p></div></div><p>关于主模型补一句：这套挂的是社区调的 <strong>Moody Mix premium</strong> 写实人像版，黑鹤001 的网盘里就有；如果只是想把流跑通，把 <code>UNETLoader</code> 换成官方 <strong>Krea2 turbo int8</strong>（同一张表里「官方 Krea2」那一栏）也完全可以，<code>CLIPLoader</code> 和 VAE 都不用动。</p></section>
<section><h2>插件依赖<a href="#插件依赖"><span>#</span></a></h2><p>自定义节点不算多，但缺一个就是一片红：</p>

<table><thead><tr><th>包名</th><th>仓库</th><th>用到的节点</th></tr></thead><tbody><tr><td><code>comfyui_essentials</code></td><td><a href="https://github.com/cubiq/ComfyUI_essentials" target="_blank">cubiq/ComfyUI_essentials</a></td><td><code>GetImageSize+</code>、<code>ImageSmartSharpen+</code>、<code>SimpleMathDual+</code></td></tr><tr><td><code>ComfyUI_UltimateSDUpscale</code></td><td><a href="https://github.com/ssitu/ComfyUI_UltimateSDUpscale" target="_blank">ssitu/ComfyUI_UltimateSDUpscale</a></td><td><code>UltimateSDUpscale</code></td></tr><tr><td><code>rgthree-comfy</code></td><td><a href="https://github.com/rgthree/rgthree-comfy" target="_blank">rgthree/rgthree-comfy</a></td><td><code>Seed</code>、<code>Image Comparer</code>、<code>Label</code>、<code>Fast Groups Bypasser</code></td></tr><tr><td>图像工具箱（ComfyUI-ImageToolkit-CN）</td><td><a href="https://pan.quark.cn/s/22f32d20ec4e" target="_blank">夸克网盘</a></td><td><code>BatchImageSaverCN</code>（批量保存 / 压缩）</td></tr></tbody></table><p>图像工具箱这个包 Manager 里搜不到，作者发在夸克网盘，<a href="https://www.bilibili.com/video/BV1pbem6tExM/" target="_blank">这期视频</a>里有说明，下载后解压丢进 <code>custom_nodes</code> 重启即可。它管的是最后那步批量保存和压缩，不需要的话绕过这一段就行。</p><p>剩下那批节点——<code>SeedVR2Preprocess</code>、<code>SeedVR2Conditioning</code>、<code>SeedVR2PostProcessing</code>、<code>ResolutionSelector</code>、<code>ResizeImageMaskNode</code>、<code>LatentUpscaleBy</code>——在工作流 JSON 里记的都是 <code>comfy-core</code>，属于 ComfyUI 内置，不用装插件。但这也意味着你的主程序得够新：<strong>ConvRot int8 是 ComfyUI v0.27 才原生支持的</strong>，老版本打开要么红要么加载不了量化权重。先把主程序更新到最新再导入工作流。</p></section>
<section><h2>参数怎么调<a href="#参数怎么调"><span>#</span></a></h2><p>要动的东西基本就三个数字：</p>

<table><thead><tr><th>位置</th><th>参数</th><th>默认</th><th>说明</th></tr></thead><tbody><tr><td>高清重绘</td><td><code>放大倍数</code></td><td>1.5</td><td>UltimateSDUpscale 的放大倍数，同时决定分块尺寸</td></tr><tr><td>高清重绘</td><td><code>边分块数</code></td><td>2</td><td>分块放大时的切块粒度，显存紧就往上调</td></tr><tr><td>SeedVR2</td><td>输入缩放</td><td>1.5</td><td><code>ResizeImageMaskNode</code> 的倍数，SeedVR2 前先缩一道</td></tr></tbody></table><p>一采尺寸在 <code>ResolutionSelector</code> 里改（比例 / MP / 倍数），二采的 latent 放大倍数在 <code>LatentUpscaleBy</code> 里改。</p></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><section><h3>锐化是可选的<a href="#锐化是可选的"><span>#</span></a></h3><p><code>ImageSmartSharpen+</code> 那一段挂着「可选锐化」标签，默认是绕过的。Krea2 直出的锐度一般够用，加了反而容易过；觉得肉再打开。</p></section><section><h3>红节点先看主程序版本<a href="#红节点先看主程序版本"><span>#</span></a></h3><p>这套流的 SeedVR2 节点和 ConvRot int8 都吃主程序版本。打开工作流先看红框——如果整段 SeedVR2 都是红的，八成不是插件没装，而是主程序太旧。右键节点能看到它属于哪个包，对着上面的表格一条条补。</p></section><section><h3>别拿原图直接喂 SeedVR2<a href="#别拿原图直接喂-seedvr2"><span>#</span></a></h3><p>SeedVR2 那一段前面挂了 <code>ResizeImageMaskNode</code>，是故意先缩一道再放大的。直接拿最终成品图硬喂，放大倍数叠上去尺寸会失控，也更容易出色块。</p></section></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>这套流把「出图」和「放大」拆得很干净：Krea2 两段采样负责把画面结构定稳，UltimateSDUpscale 和 SeedVR2 负责把尺寸和细节顶上去。每一段都能单独 bypass，想快就只跑前面两段，要成品就把后面两道打开。</p><p>SeedVR2 那句再强调一遍：<strong>3B 管 2K 以下，7B 管 2K 以上，别无脑上 7B</strong>。</p><p>有更好的参数组合或者踩到别的坑，评论区交流。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/ai/comfyui-wrokflow/minimax-h3-twostage/</id>
      <title type="text">MiniMax H3 一二采工作流：0.2 分辨率快出，二采补到 720p</title>
      <published>2026-10-06T00:00:00.000Z</published>
      <updated>2026-10-06T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/ai/comfyui-wrokflow/minimax-h3-twostage/"/>
      <summary type="text">分享一个 MiniMax H3 的 ComfyUI 一二采工作流：一采用 0.2 MP 快速出片，二采在 latent 空间放大重采样补回 720p。附模型、插件下载地址和我这边的实测耗时。</summary>
      <content type="html"><![CDATA[<section><h2>写在前面<a href="#写在前面"><span>#</span></a></h2><p>八月我发过一篇 <a href="/posts/ai/comfyui-wrokflow/minimax-h3-workflow/">MiniMax H3 工作流分享</a>，那套是直出方案：想要高清，就直接把分辨率拉上去跑，慢。</p><p>后来一直在试二阶采样的路子——先用极低分辨率快速出一版，确认构图和动作没问题，二采再把 latent 放大重采样，把细节补回来。</p><p>试到现在，<code>H3一二采.json</code> 这个工作流是我用过最好用的一个，性能极其优秀。</p><p><a href="https://github.com/hy4962/Share/blob/main/Comfyui/Workflow/H3%E4%B8%80%E4%BA%8C%E9%87%87.json" target="_blank">Share/Comfyui/Workflow/H3一二采.json at main · hy4962/Share</a></p><p>这不是我自己写的，是 UP 主分享的，配着<a href="https://www.bilibili.com/video/BV1EAHE66EJ1/" target="_blank">这个 B 站视频</a>一起看更清楚。</p></section>
<section><h2>我这边跑出来什么效果<a href="#我这边跑出来什么效果"><span>#</span></a></h2><p>先给数据。我这边是 32G 内存加 12G 显存，单图参考：</p>

<table><thead><tr><th>阶段</th><th>分辨率</th><th>视频时长</th><th>耗时</th></tr></thead><tbody><tr><td>一采</td><td>0.2 MP（608 × 352）</td><td>15 秒</td><td>约 200 秒</td></tr><tr><td>二采</td><td>放大到 720p</td><td>15 秒</td><td>约 500 秒</td></tr></tbody></table><p>两次加起来大概 700 秒，十一分多钟能拿到一条 15 秒的 720p 片子。</p><p>参考图我基本都是拿 Krea2 出的角色图来喂的，之前写过一篇 <a href="/posts/ai/comfyui-wrokflow/krea2fourviewshare/">Krea2 四视图生成</a>，四个角度和脸型一次交代清楚，现在还在用，单图参考正好吃这一套。</p><p>0.2 MP 换算过来就是 608 × 352，肉眼看着确实糊。但一采本来也不是拿去交付的，它的任务是快速定下构图、动作和运镜，真正出片的是二采。</p><p>0.2 对动漫基本够用。真人我玩得少，就不乱下结论了——动漫角色线条干净、背景相对简单，一采丢掉的信息二采大多能补回来；真人那些皮肤质感、发丝、细微光影，估计得把一采提到 0.4 甚至 0.6 才稳，这个留给常跑真人的朋友自己试。</p></section>
<section><h2>和上次那套的区别<a href="#和上次那套的区别"><span>#</span></a></h2><p>上次那套（H3-3.0）是单段的：采样一轮，直接出你想要的尺寸。</p><p>这套是两段：</p><ol>
<li><strong>一采</strong>：由 <code>ResolutionSelector</code> 定一个很低的尺寸。15 秒配 0.2 MP，只出 608 × 352。步数上工作流把整条 sigma 表拆成两段——<code>SplitSigmas</code> 在第 8 步切开，前 8 步给一采，后 4 步留给二采；总数 12 是用 <code>a + b</code> 自动算出来喂给 <code>BasicScheduler</code> 的。latent token 少，这一步很快。</li>
<li><strong>二采</strong>：先用 <code>LTXVSeparateAVLatent</code> 把一采出来的音视频 latent 拆开，交给 <code>MinimaxH3LatentUpscaler3D</code> 在 latent 空间里放大（工作流里给的是 2 倍，608 × 352 翻一倍，差不多就是 720p 这一档），再 <code>LTXVConcatAVLatent</code> 拼回去，最后交给 <code>MMH3UltimateUpscale</code> 按时间分块重采样。</li>
</ol><p>关键在第二步没走「解码成像素 → 放大图片 → 再编码回 latent」那条路。H3 的 VAE 有 50 亿参数，解码再编码非常贵，直接在 latent 上放大，省掉的就是这一段。</p><p>工作流里还挂了个 <code>VRGDG Video Compare Slider</code>，一采和二采的结果叠在一起，拖滑块就能对比。这个细节挺贴心，能直观看出二采到底补回来了什么。</p></section>
<section><h2>模型下载<a href="#模型下载"><span>#</span></a></h2><p>工作流里一共用到六个模型文件：</p>

<table><thead><tr><th>用途</th><th>文件名</th><th>下载</th><th>存放目录</th></tr></thead><tbody><tr><td>UNet</td><td><code>DasiwaMinimaxH3_dasiwaHybridV2_int8.safetensors</code></td><td><a href="https://civitai.com/models/2877206/dasiwa-minimax-h3" target="_blank">Civitai</a> / <a href="https://tensor.art/models/1035601270213216816" target="_blank">Tensor.Art 备份</a></td><td><code>models/diffusion_models/H3/</code></td></tr><tr><td>加速 LoRA</td><td><code>TaoMate-H3-3step.safetensors</code></td><td><a href="https://huggingface.co/Robert1212star/TaoMate-H3-3Step-ComfyUI" target="_blank">HuggingFace（ComfyUI 版）</a></td><td><code>models/loras/H3/</code></td></tr><tr><td>文本编码器</td><td><code>qwen3vl_32b_minimax_h3_int8_convrot.safetensors</code></td><td><a href="https://huggingface.co/Comfy-Org/MiniMax-H3/tree/main/text_encoders" target="_blank">Comfy-Org/MiniMax-H3</a></td><td><code>models/text_encoders/</code></td></tr><tr><td>视频 VAE</td><td><code>minimax_h3_video_vae_fp16.safetensors</code></td><td><a href="https://huggingface.co/Comfy-Org/MiniMax-H3/tree/main/vae" target="_blank">同上</a></td><td><code>models/vae/H3/</code></td></tr><tr><td>音频 VAE</td><td><code>minimax_h3_audio_vae_fp32.safetensors</code></td><td><a href="https://huggingface.co/Comfy-Org/MiniMax-H3/tree/main/vae" target="_blank">同上</a></td><td><code>models/vae/H3/</code></td></tr><tr><td>Latent 放大器</td><td><code>minimax_h3_latent_upscaler_3d_fp16.safetensors</code></td><td><a href="https://huggingface.co/LBH-123-AI/Minimax_h3_latent_Upscaler" target="_blank">LBH-123-AI/Minimax_h3_latent_Upscaler</a></td><td><code>models/latent_upscale_models/</code></td></tr><tr><td>预览用小 VAE</td><td><code>taeh3.safetensors</code>（约 22 MB）</td><td><a href="https://github.com/hy4962/Share/blob/main/Comfyui/Model/taeh3.safetensors" target="_blank">Share/Comfyui/Model</a></td><td><code>models/vae_approx/</code></td></tr></tbody></table><div><div><div></div><div>子目录别忘了建</div></div><div><p>UNet、LoRA、两个 VAE 在工作流里的路径都带 <code>H3\</code> 前缀，所以要在这几个模型根目录下先建一个叫 <code>H3</code> 的文件夹再往里放，不然加载器的下拉框里根本选不中。
Latent 放大器和 <code>taeh3.safetensors</code> 都没有这个前缀，直接丢进各自的根目录就行。</p></div></div><p></p><figure><img alt="模型加载组：UNet、提速 LoRA、CLIP 和两个 VAE" loading="lazy" width="1054" height="1103" src="/_astro/image-001.CGHjKRmZ_ZW7i4h.webp" /><figcaption>模型加载组：UNet、提速 LoRA、CLIP 和两个 VAE</figcaption></figure><p></p><p>模型组里还有个 <code>MiniMax H3 Sigma Shift</code>，视频偏移 12、音频偏移 3，工作流已经把值给好了，一般不用动。</p></section>
<section><h2>插件依赖<a href="#插件依赖"><span>#</span></a></h2><p>这套工作流的自定义节点比上次那套多，缺一个就是一片红。按 ComfyUI Manager 里显示的包名整理了一遍：</p>

<table><thead><tr><th>包名</th><th>仓库</th><th>用到的节点</th></tr></thead><tbody><tr><td><code>comfyui-kjnodes</code></td><td><a href="https://github.com/kijai/ComfyUI-KJNodes" target="_blank">kijai/ComfyUI-KJNodes</a></td><td><code>MiniMaxH3MemoryEfficientSageAttentionPatch</code>、<code>ModelPreviewOverrideKJ</code>、<code>INTConstant</code>、<code>FloatConstant</code></td></tr><tr><td><code>rgthree-comfy</code></td><td><a href="https://github.com/rgthree/rgthree-comfy" target="_blank">rgthree/rgthree-comfy</a></td><td><code>Seed (rgthree)</code>、<code>Fast Bypasser</code>、<code>Fast Groups Bypasser</code>、<code>Label</code></td></tr><tr><td><code>comfyui-videohelpersuite</code></td><td><a href="https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite" target="_blank">Kosinkadink/ComfyUI-VideoHelperSuite</a></td><td><code>VHS_LoadVideo</code>、<code>VHS_VideoCombine</code></td></tr><tr><td><code>comfyui-vrgamedevgirl</code></td><td><a href="https://github.com/vrgamegirl19/comfyui-vrgamedevgirl" target="_blank">vrgamegirl19/comfyui-vrgamedevgirl</a></td><td><code>VRGDG_VideoCompareSlider</code></td></tr><tr><td><code>plaguekind-nodes</code></td><td><a href="https://github.com/PlagueKind/ComfyUI-PlagueKind-Nodes" target="_blank">PlagueKind/ComfyUI-PlagueKind-Nodes</a></td><td><code>MMH3UltimateUpscale</code>、<code>MMH3TemporalSplitParams</code></td></tr><tr><td>—</td><td><a href="https://github.com/LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler" target="_blank">LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler</a></td><td><code>MinimaxH3LatentUpscaler3D</code></td></tr></tbody></table><p>Manager 里搜不到后两个，直接 clone：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>cd</span><span> </span><span>ComfyUI/custom_nodes</span></div></div><div><div><div>2</div></div><div><span>git</span><span> </span><span>clone</span><span> </span><span>https://github.com/PlagueKind/ComfyUI-PlagueKind-Nodes.git</span></div></div><div><div><div>3</div></div><div><span>git</span><span> </span><span>clone</span><span> </span><span>https://github.com/LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler.git</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>剩下那批节点——<code>MiniMaxH3ReferenceToVideo</code>、<code>MiniMaxH3SigmaShift</code>、<code>ModelAttentionBackend</code>、<code>ResolutionSelector</code>、<code>ComfyMathExpression</code>、<code>LTXVConcatAVLatent</code>——在工作流 JSON 里记的是 <code>comfy-core</code>，属于 ComfyUI 内置，不用装插件。但这也意味着你的 ComfyUI 得够新：老版本打开，这些节点一样是红的。先把主程序更新到最新再导入工作流。</p><div><div><div></div><div>加速节点</div></div><div><p><code>MiniMax H3 Mem Eff Sage Attention</code> 和注意力后端那一套要走 SageAttention / FlashAttention。缺了也能跑，只是慢一些。</p></div></div></section>
<section><h2>设置面板怎么调<a href="#设置面板怎么调"><span>#</span></a></h2><p>工作流把要调的东西全圈在「设置」那一组里，用 <code>▼</code> 标签标出来，照着改就行：</p>

<table><thead><tr><th>标签</th><th>工作流预置</th></tr></thead><tbody><tr><td>▼设置一采尺寸</td><td>16&lt;9&gt;，0.2 MP，32 的倍数</td></tr><tr><td>▼设置秒数</td><td>15</td></tr><tr><td>▼设置一采步数</td><td>8 步，推荐 6–8</td></tr><tr><td>▼设置二采倍数</td><td>2 倍</td></tr><tr><td>▼设置二采步数</td><td>4 步，推荐 2–4</td></tr><tr><td>▼ 二采时间分块设置</td><td>153 帧一段 / 重叠 17 帧 / 锚点 0.99</td></tr><tr><td>▼设置提示词 及 参考图片尺寸</td><td>提示词写在这儿</td></tr><tr><td>▼关了变文生</td><td>绕过这一段就直接变成纯文生视频</td></tr></tbody></table><section><h3>步数怎么定<a href="#步数怎么定"><span>#</span></a></h3><p>一采 6–8 步、二采 2–4 步是我跑下来比较舒服的区间，工作流默认刚好卡在两边上沿（一采 8、二采 4）。</p><p>一采步数管构图和动作，压到 6 以下容易动作都没定型，二采放大也救不回来；二采步数管细节，2 步就能看出明显差别，再往上加收益掉得很快。想省时间就直接一采 6、二采 2，画面崩没崩，采样预览一眼就能看出来。</p><p>改的时候动「设置」组里那两个整数常量就行——<code>SplitSigmas</code> 的切点是一采步数，另一个是二采步数，总数不用自己算，<code>a + b</code> 会喂给 <code>BasicScheduler</code>。</p></section><section><h3>一采尺寸对照表<a href="#一采尺寸对照表"><span>#</span></a></h3><p>工作流里塞了一张 <code>Note: Size Settings Reference</code>，我顺手抄下来，改一采尺寸的时候直接查：</p>

<table><thead><tr><th>megapixels</th><th>输出（16&lt;9&gt;，32 的倍数）</th></tr></thead><tbody><tr><td>0.2</td><td>608 × 352</td></tr><tr><td>0.3</td><td>736 × 416</td></tr><tr><td>0.4</td><td>864 × 480</td></tr><tr><td>0.5</td><td>960 × 544</td></tr><tr><td>0.6</td><td>1056 × 608</td></tr><tr><td>0.7</td><td>1152 × 640</td></tr><tr><td>0.8</td><td>1216 × 672</td></tr><tr><td>0.9</td><td>1280 × 736</td></tr><tr><td>0.98</td><td>1344 × 768</td></tr><tr><td>1.0</td><td>1376 × 768</td></tr><tr><td>1.2</td><td>1504 × 832</td></tr><tr><td>1.5</td><td>1664 × 928</td></tr><tr><td>1.8</td><td>1824 × 1024</td></tr><tr><td>2.0</td><td>1920 × 1088</td></tr></tbody></table></section></section>
<section><h2>边跑边看：采样预览<a href="#边跑边看采样预览"><span>#</span></a></h2><p>这套工作流还内置了一个可视化节点，KJNodes 的 <code>Model Preview Override</code>（节点上写着「采样预览」）。跑采样的时候它会在旁边实时渲染一版低清画面，能大致看出构图、动作和人脸有没有崩。</p><p>这个功能挺关键的。一采加二采跑一趟十几分钟，要是等全部跑完才发现手画成六根指头、或者角色整个人歪了，那十几分钟就白扔了。有预览就能在中途看出来，直接中断，改个种子重开，省下的是整轮的时间。</p><p>要让它出画面，得把 <code>taeh3.safetensors</code> 放进 <code>ComfyUI/models/vae_approx/</code>。这个目录 KJNodes 是写死的（源码里就是 <code>folder_paths.get_full_path("vae_approx", name)</code>），放错地方节点里的 <code>tiny_vae</code> 下拉框就选不到它，预览区只会一直显示 <code>waiting for sample…</code>。</p><p>节点上这几个值是工作流里给好的：</p>

<table><thead><tr><th>参数</th><th>值</th><th>说明</th></tr></thead><tbody><tr><td><code>max_resolution</code></td><td>480</td><td>预览画面的分辨率上限，调大更清楚但更吃性能</td></tr><tr><td><code>jpeg_quality</code></td><td>80</td><td>预览帧的压缩质量</td></tr><tr><td><code>preview_frames</code></td><td>999</td><td>最多预览多少帧，999 相当于全预览</td></tr><tr><td><code>preview_fps</code></td><td>8</td><td>预览回放的帧率</td></tr><tr><td><code>suppress_default_preview</code></td><td>true</td><td>压掉 ComfyUI 自带的那个预览，只留这一个</td></tr><tr><td><code>tiny_vae</code></td><td><code>taeh3.safetensors</code></td><td>就是上面那个小 VAE</td></tr></tbody></table><p></p><figure><img alt="采样预览节点：参数、预览区和每步耗时" loading="lazy" width="1054" height="1176" src="/_astro/image-002.DNVt_WIx_1yH0Bh.webp" /><figcaption>采样预览节点：参数、预览区和每步耗时</figcaption></figure><p></p><p>节点下半部分还有个曲线区，右边直接给每一步的耗时（<code>step time (ms)</code>）。想让整体时间降下来，盯着这个数调步数最直观。</p><div><div><div></div><div>别拿预览判断画质</div></div><div><p>预览走的是小 VAE 快速解一版，糊是正常的。它只用来看构图和动作有没有大问题，画质还得看二采出来的成品。</p></div></div></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><section><h3>帧数不是随便填的<a href="#帧数不是随便填的"><span>#</span></a></h3><p>H3 的帧数要落在 <code>5 + 17n</code> 这个网格上（5、22、39、56……），自己随便填一个数，轻则报错，重则出怪东西。</p><p>这个工作流不用你操心。<code>▼设置秒数</code> 后面挂了个 <code>ComfyMathExpression</code>，公式是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>a</code> 就是秒数，乘 24 换成帧，再自动贴到最近的 <code>5 + 17n</code>。填 15 秒算出来是 362 帧。</p></section><section><h3>红节点别急着问人<a href="#红节点别急着问人"><span>#</span></a></h3><p>打开工作流先数红框。红框就是插件没装，右键节点能看到它属于哪个包，对着上面的表格一条条补。</p><p>模型缺了更简单：把红框截图甩给 Gemini，让它把文件名和下载地址列全，能整理得明明白白。这一点上它比 GPT 好用。说到底 Google 搜索足够强，别一上来就在群里问「为什么我的是红的」。</p></section><section><h3>0.2 到底够不够<a href="#02-到底够不够"><span>#</span></a></h3><p>我自己的判断：动漫够用，真人不好说。</p><p>一采的 0.2 只负责定构图和动作，细节全靠二采补。但如果一采糊到动作都定不准——快速挥手、大幅位移这种——二采放大之后只会跟着糊、跟着抖。这时候把一采提到 0.4 起步，代价是时间跟着涨。</p></section></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>这套工作流我会一直留着用。它把「快」和「清楚」拆成两步：低分辨率那一步不心疼，高清那一步也不用担心一步到位把显存榨干。32G 内存加 12G 显存能跑到 15 秒 720p，对家用机器来说已经很够意思了。</p><p>有更好的参数组合或者踩到别的坑，评论区交流。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/autofriendlink/</id>
      <title type="text">友链申请做成自动的：填完 Issue 表单，机器人验完自己上线</title>
      <published>2026-10-05T00:00:00.000Z</published>
      <updated>2026-10-05T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/autofriendlink/"/>
      <summary type="text">在 blog.amamo.top 的友链页看到他做了「自动友链」：点进去是个 GitHub Issue 表单，提交后机器人自己去验对方的友链页，通过就把友链写进配置。我这边的约束照旧是不能动主题源文件，所以按这个思路重做了一遍——三个新增文件、零第三方依赖，顺带绕开了他脚本里会吃掉 rss / homepage 字段的坑。</summary>
      <content type="html"><![CDATA[<p>今天翻 <a href="https://blog.amamo.top/friends/" target="_blank">blog.amamo.top</a> 的友链页，看到页面上有一行小字：「自动友链 点击上方进入申请表，按提示填写站点信息并提交。」</p>
<p>点进去是个 GitHub Issue 表单。</p>
<p></p><figure><img alt="参考站友链页的「自动友链」入口与站点信息卡" loading="lazy" width="1960" height="1440" src="/_astro/image-001.D3aA9qCD_L07pf.webp" /><figcaption>参考站友链页的「自动友链」入口与站点信息卡</figcaption></figure><p></p>
<p>我这边友链页的申请方式还是老一套：复制模板，发评论区或者发我邮箱，等人手动加。</p>
<p>差别挺明显的。他那边提交完就等结果，我这边至少得等我打开邮箱。</p>
<section><h2>他那边是怎么做的<a href="#他那边是怎么做的"><span>#</span></a></h2><p>顺手把他的仓库扒了一遍。他的站也是 fork 的 Firefly，仓库公开，<a href="https://github.com/qwc-ch/Firefly" target="_blank">qwc-ch/Firefly</a>。</p><p>实现全在 <code>.github/</code> 下面，一共三个文件：</p>

<table><thead><tr><th>文件</th><th>干什么的</th></tr></thead><tbody><tr><td><code>.github/ISSUE_TEMPLATE/friend-request.yml</code></td><td>申请表本体</td></tr><tr><td><code>.github/scripts/process-friend-request.cjs</code></td><td>校验 + 改配置 + 提交</td></tr><tr><td><code>.github/workflows/friend-link-checker.yml</code></td><td>触发（文件名起得有点误导，内容其实是「自动更新友链」）</td></tr></tbody></table><p>流程是这样的：申请人在 Issue 表单里填站点名、主站链接、<strong>友链页地址</strong>；workflow 监听到 <code>issues: [opened]</code> 就启动，跑脚本去访问那个友链页，确认页面上确实有他的站，然后改 <code>friendsConfig.ts</code> 并提交。</p><p>还有个细节我觉得挺聪明：触发条件里还带了 <code>issue_comment: [created]</code>，而且<strong>只有 Issue 作者本人的回复</strong>才会重新跑一遍。申请人第一次没挂链接被拒了，改完自己回一句，机器人重新验一次，不用关掉重开。</p><p>入口也不是什么站内表单，就是一条链接：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://github.com/qwc-ch/Firefly/issues/new?template=friend-request.yml</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>一个反直觉的点：这事不需要服务端<a href="#一个反直觉的点这事不需要服务端"><span>#</span></a></h2><p>搜「自动友链」的时候翻到一篇写得很细的文章，讲的是把友链数据抽成 <code>links.json</code>、把站切成 SSR、再加一个 API 接口负责校验和写文件。看完我一度以为得动构建模式。</p><p>扒完他的仓库才发现，<strong>他压根没上 SSR</strong>。站还是纯静态的，部署在 GitHub Pages（<code>deploy.yml</code> 里就是 <code>on: push: branches: [master]</code>）。</p><p>关键在于：校验和写入<strong>不发生在访客访问的时候</strong>，而是发生在 GitHub Actions 的容器里。</p><ul>
<li>机器人跑在 GitHub 的机器上，改文件、提交、推送，都是它干的</li>
<li>推送触发平台重新构建，新友链就出现在页面上了</li>
</ul><p>所以静态站完全够用。那篇文章里的 SSR 是作者自己站的部署方式，不是这件事的必要条件。</p></section>
<section><h2>但他的脚本不能直接抄<a href="#但他的脚本不能直接抄"><span>#</span></a></h2><p>有三处我这边过不去。</p><section><h3>一、他会把整个数组重写一遍<a href="#一他会把整个数组重写一遍"><span>#</span></a></h3><p>他脚本里的写入逻辑是：先把现有的 <code>friendsConfig</code> 全部解析成对象，再全部重新渲染回去。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>friendBlocks</span><span> </span><span>=</span><span> [</span><span>...</span><span><span>listMatch</span><span>[</span></span><span>1</span><span>].</span><span>matchAll</span><span>(</span><span><span>/</span><span>\{</span><span>(</span></span><span><span>[</span><span>\s\S</span><span>]</span></span><span><span>*?</span><span>)</span></span><span>\n\t</span><span><span>\}</span><span>,</span><span>?</span><span>/</span></span><span>g</span><span>)].</span><span>map</span><span><span>((</span><span>item</span><span>) </span></span><span>=&gt;</span><span><span> </span><span>item</span><span>[</span></span><span>1</span><span>]);</span></div></div><div><div><div>2</div></div><div><span>return</span><span><span> </span><span>friendBlocks</span><span>.</span></span><span>map</span><span><span>((</span><span>block</span><span>) </span></span><span>=&gt;</span><span> ({</span></div></div><div><div><div>3</div></div><div><span>  </span><span>title</span><span>:</span><span> </span><span>extractString</span><span><span>(</span><span>block</span><span>, </span></span><span>'title'</span><span>),</span></div></div><div><div><div>4</div></div><div><span>  </span><span>imgurl</span><span>:</span><span> </span><span>extractString</span><span><span>(</span><span>block</span><span>, </span></span><span>'imgurl'</span><span>),</span></div></div><div><div><div>5</div></div><div><span>  </span><span>desc</span><span>:</span><span> </span><span>extractString</span><span><span>(</span><span>block</span><span>, </span></span><span>'desc'</span><span>),</span></div></div><div><div><div>6</div></div><div><span>  </span><span>siteurl</span><span>:</span><span> </span><span>extractString</span><span><span>(</span><span>block</span><span>, </span></span><span>'siteurl'</span><span>),</span></div></div><div><div><div>7</div></div><div><span>  </span><span>tags</span><span>:</span><span> </span><span>extractTags</span><span><span>(</span><span>block</span><span>),</span></span></div></div><div><div><div>8</div></div><div><span>  </span><span>weight</span><span>:</span><span> </span><span>extractNumber</span><span><span>(</span><span>block</span><span>, </span></span><span>'weight'</span><span>, </span><span>10</span><span>),</span></div></div><div><div><div>9</div></div><div><span>  </span><span>enabled</span><span>:</span><span> </span><span>extractBoolean</span><span><span>(</span><span>block</span><span>, </span></span><span>'enabled'</span><span>, </span><span>true</span><span>),</span></div></div><div><div><div>10</div></div><div><span>  </span><span>issue_id</span><span>:</span><span> </span><span>extractNumber</span><span><span>(</span><span>block</span><span>, </span></span><span>'issue_id'</span><span>, </span><span>0</span><span>),</span></div></div><div><div><div>11</div></div><div><span>}));</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>问题出在字段白名单上。他的 <code>FriendLink</code> 类型里没有 <code>rss</code> 和 <code>homepage</code>，而他这个站也确实没用这两个字段，所以他跑着没事。</p><p>我这边的 <code>friendsConfig.ts</code> 正在用：<code>rss</code> 有 2 条、<code>homepage</code> 有 1 条，另外还有一堆分组的注释。按他这个流程走一遍，那些字段和注释全会被吃掉——因为整个数组会被”重新渲染”成他认识的那 8 个字段。</p><p>所以我把写入改成<strong>只往数组头部插一条</strong>，其他位置一个字节都不碰：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>anchor</span><span> </span><span>=</span><span> </span><span>'export const friendsConfig: FriendLink[] = ['</span><span>;</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>at</span><span> </span><span>=</span><span><span> </span><span>raw</span><span>.</span></span><span>indexOf</span><span><span>(</span><span>anchor</span><span>);</span></span></div></div><div><div><div>3</div></div><div><span>const</span><span> </span><span>updated</span><span> </span><span>=</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>raw</span><span>.</span><span>slice</span><span>(</span><span>0</span><span><span>, </span><span>at</span><span> </span></span><span>+</span><span><span> </span><span>anchor</span><span>.</span></span><span>length</span><span>) </span><span>+</span><span><span> </span><span>eol</span><span> </span></span><span>+</span><span><span> </span><span>entry</span><span> </span></span><span>+</span><span><span> </span><span>raw</span><span>.</span></span><span>slice</span><span><span>(</span><span>at</span><span> </span></span><span>+</span><span><span> </span><span>anchor</span><span>.</span></span><span>length</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>顺带加了按域名去重，否则同一个人重复申请会插进去两条。</p></section><section><h3>二、他的方案有点重<a href="#二他的方案有点重"><span>#</span></a></h3><p>他的 workflow 里有这两步：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>- </span><span>run</span><span>: </span><span>pnpm install --frozen-lockfile</span></div></div><div><div><div>2</div></div><div><span>- </span><span>run</span><span>: </span><span>pnpm exec playwright install --with-deps chromium</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>因为他的校验是用 Playwright 真的开一个浏览器去渲染友链页，再读渲染后的 DOM。</p><p>对靠 JS 出内容的站来说这确实更准，代价是每跑一次都要装一遍依赖外加一个 Chromium，几分钟起步。</p><p>我这边的校验只需要判断「页面 HTML 里有没有那个域名」，Node 内置的 <code>fetch</code> 就够了，<strong>零第三方依赖</strong>，跑完二十来秒：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>- </span><span>name</span><span>: </span><span>Setup Node.js</span></div></div><div><div><div>2</div></div><div><span>  </span><span>uses</span><span>: </span><span>actions/setup-node@v4</span></div></div><div><div><div>3</div></div><div><span>  </span><span>with</span><span>:</span></div></div><div><div><div>4</div></div><div><span>    </span><span>node-version</span><span>: </span><span>"22"</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>- </span><span>name</span><span>: </span><span>Process friend request</span></div></div><div><div><div>7</div></div><div><span>  </span><span>uses</span><span>: </span><span>actions/github-script@v7</span></div></div><div><div><div>8</div></div><div><span>  </span><span>with</span><span>:</span></div></div><div><div><div>9</div></div><div><span>    </span><span>github-token</span><span>: </span><span>${{ secrets.GITHUB_TOKEN }}</span></div></div><div><div><div>10</div></div><div><span>    </span><span>script</span><span>: </span><span>|</span></div></div><div><div><div>11</div></div><div><span><span>      </span></span><span>const path = require('node:path');</span></div></div><div><div><div>12</div></div><div><span><span>      </span></span><span>const handler = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/auto-friend-link.cjs'));</span></div></div><div><div><div>13</div></div><div><span><span>      </span></span><span>await handler({ github, context, core });</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>代价也说清楚：<strong>纯前端渲染的友链页我抓不到</strong>，页面内容靠 JS 出来的站会被误判成「没挂链接」，这类只能走人工。</p></section><section><h3>三、校验比博客里写的松<a href="#三校验比博客里写的松"><span>#</span></a></h3><p>他博客里写的是「主站域名与友链页域名一致（避免广告跳转）」，但脚本里其实只做了两件事：打开友链页、看页面里有没有出现他的域名或者站名。</p><p>站名那条尤其松——拿「年华」两个字去页面里做 <code>includes</code>，随便哪里出现这两个字就过了。</p><p>我这边把三项都写实了，全过才写入。</p></section></section>
<section><h2>我的三件套<a href="#我的三件套"><span>#</span></a></h2><p>同样是三个新增文件，职责划得很清楚：</p>

<table><thead><tr><th>文件</th><th>说明</th></tr></thead><tbody><tr><td><code>.github/ISSUE_TEMPLATE/friend-request.yml</code></td><td>申请表，7 个字段（多了 RSS 和首页照片两个选填）</td></tr><tr><td><code>.github/scripts/auto-friend-link.cjs</code></td><td>校验 + 写入，零依赖</td></tr><tr><td><code>.github/workflows/auto-friend-link.yml</code></td><td>触发 + 建 label</td></tr></tbody></table><p>友链页那边的入口做成这样：</p><p></p><figure><img alt="本站友链页改造后的申请卡片，第二步里是橙色的「自动友链 · 进入申请表」按钮" loading="lazy" width="1386" height="760" src="/_astro/image-002.DrIhdoy5_ZUE9OG.webp" /><figcaption>本站友链页改造后的申请卡片，第二步里是橙色的「自动友链 · 进入申请表」按钮</figcaption></figure><p></p><p>原来的「发评论区 / 发邮件」那条路我没删，压缩成一行小字留在按钮下面。用 GitHub 的门槛确实有，得给人留个后路。</p></section>
<section><h2>校验的三项<a href="#校验的三项"><span>#</span></a></h2>

<table><thead><tr><th>检查</th><th>怎么做的</th><th>不过会怎样</th></tr></thead><tbody><tr><td>主站能打开</td><td>fetch 主站，跟随重定向后要求 200</td><td>拒绝，提示「主站无法访问」</td></tr><tr><td>友链页能打开</td><td>fetch 友链页，用重定向后的最终地址</td><td>拒绝，附上状态码和错误</td></tr><tr><td>两站同域</td><td>比 registrable domain，<code>blog.example.com</code> 和 <code>example.com</code> 算同域</td><td>拒绝，提示域名不一致</td></tr><tr><td>页面挂了本站链接</td><td>在 HTML 里找 <code>9ll.uk</code></td><td>拒绝，把需要挂的站点信息列出来</td></tr></tbody></table><p>另外做了两个防呆。</p><p>字段里的换行和引号会压平转义。有人手打 Issue 的时候如果在「网站名称」里塞一段 <code>"];</code> 加换行，不处理就会把 <code>friendsConfig.ts</code> 撕成语法错误。</p><p>还有 label 不存在时不能让整个 job 挂掉。GitHub API 对不存在的 label 直接返回 422，流程会当场炸掉，所以 <code>addLabels</code> 外面包了 try/catch，workflow 里也顺手 <code>gh label create ... || true</code> 先建一遍。</p></section>
<section><h2><code>### 网站名称</code> 读不出来<a href="#-网站名称-读不出来"><span>#</span></a></h2><p>Issue 表单提交之后，正文长这样：</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>
</div></div><div><div><div>3</div></div><div><span>测试小站</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>### 网站链接</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>https://example.com</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>我一开始按 <code>\n### </code> 切分，每块的第一行当字段名。写完跑单元测试，发现<strong>第一个字段永远解析不出来</strong>。</p><p>原因挺蠢的：正文<strong>开头</strong>就是 <code>### 网站名称</code>，它前面没有换行，所以没被切走，留在了第一块里，字段名变成带 <code>### </code> 前缀的字符串，怎么都匹配不上。</p><p>修法是在清洗字段名时补一句：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>String</span><span><span>(</span><span>label</span><span>).</span></span><span>replace</span><span>(</span><span>/</span><span>^</span><span><span>#</span><span>+</span></span><span>\s</span><span><span>*</span><span>/</span></span><span>, </span><span>''</span><span>)   </span><span>// split 后第一块的 label 会带 "### " 前缀</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>还有个更值得记的教训：测试得走生产路径。</p><p>我第一版测试是自己在测试里手动拼了个对象塞进去测的，跑得干干净净——因为它压根没碰真正解析 Issue 正文的那段代码。后来把字段整理抽成 <code>buildForm()</code>，测试和主流程共用同一个函数，上面那个 bug 才浮出来。</p></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><p>要复刻的话，需要动的就四处：</p><ol>
<li><code>.github/ISSUE_TEMPLATE/friend-request.yml</code> —— 申请表，字段按自己的站点信息改</li>
<li><code>.github/scripts/auto-friend-link.cjs</code> —— 校验和写入，改开头 <code>SITE_INFO</code> 里的域名</li>
<li><code>.github/workflows/auto-friend-link.yml</code> —— 触发</li>
<li>友链页加一个入口按钮</li>
</ol><p>然后两个仓库设置：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Settings → General → Features → 勾上 Issues</span></div></div><div><div><div>2</div></div><div><span>Settings → Actions → General → Workflow permissions → Read and write</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>第一条必须开——fork 出来的仓库 GitHub 默认把 Issues 关着，不开的话申请链接直接 404，模板文件在不在都一样。</p><p>第二条是给机器人 push 配置用的，默认就是 Read and write，但如果之前手动收紧过，这里不放开提交会失败。</p><p>前置条件就这些。零依赖、零服务器，整套东西三个文件。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/booknavcoverpreview/</id>
      <title type="text">给书签导航加悬停预览：复刻友链流程，多踩三个坑</title>
      <published>2026-10-05T00:00:00.000Z</published>
      <updated>2026-10-05T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/booknavcoverpreview/"/>
      <summary type="text">书签导航那 64 个书签也加上了悬停预览：鼠标放上去展开一张该站点的首页截图。做法是把友链页那套原样搬过来，但书签和友链不是一个东西，中途多踩了三个坑——同域名多条会互相覆盖、Cloudflare 的 403 页截出来是张「看着正常」的白底黑字图、headless 的默认 UA 直接被拒。最后 56 条拿到了能看的封面。</summary>
      <content type="html"><![CDATA[<p>友链页那个悬停预览做完之后，我自己挺喜欢：鼠标划过去卡片顶部长出一条对方首页的截图，不用点进去就知道对面长什么样。</p>
<p>在书签导航页翻的时候发现这边就没这待遇 —— 一排图标加标题，干巴巴的。</p>
<p>于是就有了这篇。想法很简单：书签页和友链页结构几乎一样，都是「一个链接一张卡片」，把友链那套搬过来不就行了。</p>
<p>结果搬是搬过来了，坑比预期多。</p>
<section><h2>先把友链那套抄一遍<a href="#先把友链那套抄一遍"><span>#</span></a></h2><p>友链那套实现上次已经写过一篇（<a href="/posts/blog/friendcoverpreview/">给友链页加了悬停预览</a>），这里只列一下文件对应关系：</p>

<table><thead><tr><th>友链</th><th>书签</th></tr></thead><tbody><tr><td><code>scripts/generate-friends-covers.ts</code></td><td><code>scripts/generate-booknav-covers.ts</code></td></tr><tr><td><code>public/assets/images/friends/covers/</code></td><td><code>public/assets/images/booknav/covers/</code></td></tr><tr><td><code>src/data/friends-covers.json</code></td><td><code>src/data/booknav-covers.json</code></td></tr><tr><td><code>src/pages/api/friends-covers.json.ts</code></td><td><code>src/pages/api/booknav-covers.json.ts</code></td></tr><tr><td><code>public/assets/js/friends-covers.js</code></td><td><code>public/assets/js/booknav-covers.js</code></td></tr><tr><td><code>public/assets/css/friends-covers.css</code></td><td><code>public/assets/css/booknav-covers.css</code></td></tr></tbody></table><p>数据源从 <code>friendsConfig</code> 换成 <code>booknavConfig</code>，匹配的字段从友链的 <code>siteurl</code> 换成书签的 <code>item.url</code>，其余逻辑一行没改。</p><p>截出来大概是这样，一张 640 宽的 webp：</p><p></p><figure><img alt="脚本截出来的站点首页封面，640 宽" loading="lazy" width="640" height="256" src="/_astro/image-001.DIJUNJWU_2uYfBS.webp" /><figcaption>脚本截出来的站点首页封面，640 宽</figcaption></figure><p></p><p>到这里为止都很顺。下面三个坑全是「书签和友链不一样」的地方。</p></section>
<section><h2>坑一：同一个域名挂了好几条书签<a href="#坑一同一个域名挂了好几条书签"><span>#</span></a></h2><p>友链基本一站一个域名，文件名直接用域名扫就行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>blog.cuteleaf.cn  →  blog-cuteleaf-cn.webp</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>书签不是。我这边 <code>github.com</code> 底下挂了 7 个仓库 —— Firefly 上游、我自己的 fork、CC Switch、BewlyCat 之类的，全都在同一个域名下。</p><p>按域名取文件名，这 7 条会<strong>互相覆盖</strong>，最后只剩一张，而且还不确定剩的是哪张。</p><p>改法是把路径也编进文件名：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>github.com                              →  github-com.webp</span></div></div><div><div><div>2</div></div><div><span>github.com/keleus/BewlyCat              →  github-com-keleus-bewlycat.webp</span></div></div><div><div><div>3</div></div><div><span>github.com/hy4962/Firefly               →  github-com-hy4962-firefly.webp</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>万一连路径都一样，再补 <code>-2</code>、<code>-3</code>。清单里的 key 还是原始 URL，所以文件名怎么变都不影响匹配。</p><p></p><figure><img alt="GitHub 仓库页截出来的封面，同一域名多条各自独立" loading="lazy" width="640" height="256" src="/_astro/image-002.hqYBzBUU_Z19l6aU.webp" /><figcaption>GitHub 仓库页截出来的封面，同一域名多条各自独立</figcaption></figure><p></p></section>
<section><h2>坑二：403 页伪装成一张正常的截图<a href="#坑二403-页伪装成一张正常的截图"><span>#</span></a></h2><p>这个是最阴的。</p><p>第一轮跑完我随手翻产物，发现 deviantart 那张是 <strong>Cloudflare 的 403 页</strong>。</p><p>截出来是一张白底黑字、排版很规整的页面：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>403 ERROR</span></div></div><div><div><div>2</div></div><div><span>The request could not be satisfied.</span></div></div><div><div><div>3</div></div><div><span>Request blocked.</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>问题在于 —— 它长得<strong>跟一张正常截图没有任何区别</strong>。文件大小正常（21 KB），宽高正常，打开是张像模像样的图。光看产物根本发现不了，得悬停展开才知道是个报错页。</p><p>所以必须在截图那一步就把它筛掉。</p><p>好在 chrome 的 <code>--dump-dom</code> 和 <code>--screenshot</code> <strong>可以在同一次调用里同时用</strong> —— 一个把渲染后的 HTML 打到 stdout，一个写图片文件，一次请求两样都拿到，不用多跑一遍：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>--screenshot</span><span>=</span><span>&lt;png</span><span> </span><span>路径&gt;</span><span> </span><span>--dump-dom</span><span> </span><span>&lt;url&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>拿到 HTML 之后，只看前 4000 个字符，命中这些特征就当失败：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>BLOCKED_MARKERS</span><span> </span><span>=</span><span> [</span></div></div><div><div><div>2</div></div><div><span>  </span><span>/the request could not be satisfied/</span><span>i</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>/</span><span>\b</span><span>403</span><span>\s</span><span><span>+</span><span>error</span></span><span>\b</span><span>/</span><span>i</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>/you have been blocked/</span><span>i</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>/attention required/</span><span>i</span><span>,</span></div></div><div><div><div>6</div></div><div><span>  </span><span><span>/just a moment</span><span>\.\.\.</span><span>/</span></span><span>i</span><span>,</span></div></div><div><div><div>7</div></div><div><span>  </span><span>/enable javascript and cookies to continue/</span><span>i</span><span>,</span></div></div><div><div><div>8</div></div><div><span>  </span><span>/checking your browser before accessing/</span><span>i</span><span>,</span></div></div><div><div><div>9</div></div><div><span>  </span><span>/verify you are human/</span><span>i</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>只看开头是有意的：正文里偶尔出现「access denied」不算，标题和首屏出现才算。</p><p>另外还补了一道，用 <code>sharp</code> 算一下三个通道的标准差，整页一个颜色的空白图直接丢：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>stats</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>sharp</span><span><span>(</span><span>pngPath</span><span>).</span></span><span>stats</span><span>();</span></div></div><div><div><div>2</div></div><div><span>if</span><span><span> (</span><span>stats</span><span>.</span></span><span>channels</span><span>.</span><span>every</span><span><span>((</span><span>channel</span><span>) </span></span><span>=&gt;</span><span><span> </span><span>channel</span><span>.</span></span><span>stdev</span><span> </span><span>&lt;</span><span> </span><span>3</span><span>)) {</span></div></div><div><div><div>3</div></div><div><span>  </span><span>throw</span><span> </span><span>new</span><span> </span><span>Error</span><span>(</span><span>"截图几乎是纯色空白页，已丢弃"</span><span>);</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这一道抓的是另一种情况：站点还在渲染，图就先截下来了，出来是张纯白的。后来查失败清单，動漫花園、TouchGal 那几个就是栽在这。</p></section>
<section><h2>坑三：headless 的 UA 会被直接拒<a href="#坑三headless-的-ua-会被直接拒"><span>#</span></a></h2><p>chrome-headless-shell 的默认 UA 里带 <code>HeadlessChrome</code> 字样，Cloudflare 那批站点认得出来，直接回验证页。</p><p>换成正常的 Chrome UA 之后通过率明显上来：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>--user-agent</span><span>=</span><span>Mozilla/5.0</span><span> </span><span>(Windows</span><span> </span><span>NT</span><span> </span><span>10.0</span><span>; </span><span>Win64</span><span>; </span><span>x64</span><span>) AppleWebKit/537.36 (</span><span>KHTML,</span><span> </span><span>like</span><span> </span><span>Gecko</span><span>) Chrome/141.0.0.0 Safari/537.36</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>卡片得改成网格才装得下封面<a href="#卡片得改成网格才装得下封面"><span>#</span></a></h2><p>前面都是截图的事，这一步是布局。</p><p>书签卡片原来是个横向 flex：左边 14×14 的图标，右边标题和描述。要让顶部能展开一条通栏封面，得把布局改成两列网格：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.booknav-grid</span><span> </span><span>.booknav-card</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>display: </span><span>grid</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>grid-template-columns: </span><span>auto</span><span> </span><span>minmax</span><span>(</span><span>0</span><span>, </span><span><span>1</span><span>fr</span></span><span>);</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>column-gap: </span><span><span>0.75</span><span>rem</span></span><span>;</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>row-gap: </span><span>0</span><span>;</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div><div><div><div>7</div></div><div><span>.booknav-grid</span><span> </span><span>.booknav-card</span><span> </span><span>&gt;</span><span> </span><span>.ff-booknav-cover</span><span> {</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>grid-column: </span><span>1</span><span> / </span><span>-1</span><span>; </span><span>/* 占满整行 */</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>三个细节和友链不一样：</p><p><strong><code>row-gap</code> 要显式归零。</strong> 卡片上原本有 Tailwind 的 <code>gap-3</code>，在 flex 下只影响横向间距，改成 grid 之后它会<strong>同时变成行间距</strong>，封面和下面那行之间会凭空多出 0.75rem。所以得单独写 <code>row-gap: 0</code>。</p><p><strong>负边距的数值跟着卡片 padding 走。</strong> 封面要顶到卡片边缘靠的是负边距，这个值必须等于卡片的 padding。友链那边是 <code>p-2.5</code>（0.625rem），书签这边是 <code>p-3</code>（0.75rem），照抄友链的会露一圈边。</p><p><strong>网格要 <code>align-items: start</code>。</strong> 默认是 <code>stretch</code>，一张卡片展开会把同一行的邻居一起拉高。</p><p>「<code>.booknav-card</code> 是 flex」这件事本身是写在 Tailwind 类里的，我的样式是运行时注入的 <code>&lt;link&gt;</code>，未分层，压得过 <code>@layer utilities</code>，所以直接覆写就行，不用加 <code>!important</code>。</p></section>
<section><h2>脚本怎么进到页面上<a href="#脚本怎么进到页面上"><span>#</span></a></h2><p>这部分和友链完全一样，顺手做了个小整理。</p><p>页脚（<code>src/config/FooterConfig.html</code>）里原本挂着一段友链专用的加载器，只在 <code>/friends/</code> 才去拉真正的脚本。这次要再加一个书签的，与其复制一段，不如改成数组：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>var</span><span> </span><span>PREVIEW_SCRIPTS</span><span> </span><span>=</span><span> [</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>{ </span><span>path</span><span>:</span><span> /</span><span>^</span><span><span>\/</span><span>friends</span><span>\/</span><span>?</span></span><span>$</span><span>/</span><span>, </span><span>id</span><span>:</span><span> </span><span>"ff-friend-covers-js"</span><span>, </span><span>src</span><span>:</span><span> </span><span>"/assets/js/friends-covers.js"</span><span> },</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>{ </span><span>path</span><span>:</span><span> /</span><span>^</span><span><span>\/</span><span>booknav</span><span>\/</span><span>?</span></span><span>$</span><span>/</span><span>, </span><span>id</span><span>:</span><span> </span><span>"ff-booknav-covers-js"</span><span>, </span><span>src</span><span>:</span><span> </span><span>"/assets/js/booknav-covers.js"</span><span> },</span></div></div><div><div><div>4</div></div><div><span>];</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>按 <code>pathname</code> 正则匹配，命中的才插 <code>&lt;script&gt;</code>。以后哪页还要加同款预览，往数组里补一条就行。</p><p>映射表走 <code>src/pages/api/booknav-covers.json.ts</code>，加了 <code>export const prerender = true</code>，构建时生成一份静态 JSON，访客拿到的是文件不是接口。</p><p>封面图首屏一张都不加载 —— 脚本给 <code>img</code> 挂的是 <code>data-src</code>，卡片真被悬停或聚焦过才写进 <code>src</code>。</p></section>
<section><h2>结果<a href="#结果"><span>#</span></a></h2><p>64 个书签，<strong>56 个拿到了能看的封面</strong>，加起来 812 KB。</p><p>失败的 8 个：</p>

<table><thead><tr><th>站点</th><th>原因</th></tr></thead><tbody><tr><td>DeviantArt</td><td>Cloudflare 403，被特征匹配拦下</td></tr><tr><td>末日動漫資源庫</td><td>同上</td></tr><tr><td>動漫花園</td><td>纯色空白页</td></tr><tr><td>GYING 动漫筛选 / 发布页</td><td>纯色空白页</td></tr><tr><td>TouchGal</td><td>纯色空白页</td></tr><tr><td>NekoGAL</td><td>超时</td></tr><tr><td>DeepSeek 控制台</td><td>超时（要登录）</td></tr></tbody></table><p>没有封面的书签卡片保持原样，悬停不展开，也不会留个破图。</p><p>全量跑一遍大概 9 分钟，主要是等每个站加载。已经截过的会跳过，所以加完新书签再跑一次，只补新的那几张，两三分钟的事。</p></section>
<section><h2>以后加书签<a href="#以后加书签"><span>#</span></a></h2><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>npx</span><span> </span><span>tsx</span><span> </span><span>scripts/generate-booknav-covers.ts</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>想单独补某一条：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>npx</span><span> </span><span>tsx</span><span> </span><span>scripts/generate-booknav-covers.ts</span><span> </span><span>--only=touchgal</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>全量重来加 <code>--force</code>，只想试水加 <code>--limit=5</code>。</p></section>
<section><h2>总结<a href="#总结"><span>#</span></a></h2><ul>
<li><strong>书签和友链最大的区别是域名会重复</strong>，文件名必须带路径段，不然多条书签会共用一张封面。</li>
<li><strong>截图脚本一定要校验产物</strong>。Cloudflare 那种 403 页截出来就是一张「看起来很正常」的白底黑字图，不主动筛就会混进线上。</li>
<li><code>--dump-dom</code> 和 <code>--screenshot</code> 能一次调用同时拿 DOM 和图片，校验几乎零成本。</li>
<li>卡片布局从 flex 改 grid 是让封面独占一整行的最省事做法，但记得把 <code>row-gap</code> 归零、负边距对齐卡片 padding。</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/contentseparation/</id>
      <title type="text">把博客文章搬进私有仓库：一次内容分离的踩坑记录</title>
      <published>2026-10-05T00:00:00.000Z</published>
      <updated>2026-10-05T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/contentseparation/"/>
      <summary type="text">我的博客是 fork 的 Firefly，文章和主题代码一直挤在同一个仓库，而上游更新很勤，每次合并都要在文章目录上手工解冲突。折腾了一轮把内容搬进独立的私有仓库，中间踩了三个坑：Vercel 根本不支持私有 submodule、内容目录放错位置会让所有封面图消失、环境变量里混进一个换行符就能把 token 截断。</summary>
      <content type="html"><![CDATA[<section><h2>为什么想搞这个<a href="#为什么想搞这个"><span>#</span></a></h2><p>我的博客是 fork 的 <a href="https://github.com/CuteLeaf/Firefly" target="_blank">CuteLeaf/Firefly</a>，一直有个隐隐的别扭：文章和主题代码挤在同一个仓库里。</p><p>平时没事，一合上游就难受。上游挺勤快的，示例文章、文档、demo 项目三天两头动一次；我这边 <code>src/content</code> 里躺着的是自己写的一堆文章和配图。两个东西住在一个目录下，<code>git merge upstream/master</code> 的时候基本就是解冲突体力活。</p><p>所以想做个内容分离：文章归文章，主题归主题，各放各的仓库。以后合上游只跟主题代码打交道，<code>src/content</code> 保持上游原样，再也不会打架。</p></section>
<section><h2>先看别人是怎么做的<a href="#先看别人是怎么做的"><span>#</span></a></h2><p>动手之前先去翻了翻别人怎么实现的。</p><p>有个博客主题叫 <a href="https://github.com/LyraVoid/Shirone" target="_blank">Shirone</a>，它的文档里专门有一章讲内容分离，讲得挺细。看完发现它的做法跟我一开始想的<strong>完全不一样</strong>——我本来以为要用 git submodule，结果它压根没用。</p><p>它的思路是「构建时同步」：</p><ul>
<li>主仓库（主题代码）里<strong>完全不含内容</strong></li>
<li>内容放在另一个仓库，靠一个配置文件声明位置：</li>
</ul><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>"source"</span><span>: {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>"type"</span><span>: </span><span>"git"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>    </span><span>"url"</span><span>: </span><span>"https://github.com/OWNER/shirone-content.git"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>    </span><span>"ref"</span><span>: </span><span>"main"</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>7</div></div><div><span>  </span><span>"mounts"</span><span>: {</span></div></div><div><div><div>8</div></div><div><span>    </span><span>"content"</span><span>: </span><span>"src/content"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>    </span><span>"data"</span><span>: </span><span>"src/data"</span><span>,</span></div></div><div><div><div>10</div></div><div><span>    </span><span>"assets"</span><span>: </span><span>"src/assets"</span><span>,</span></div></div><div><div><div>11</div></div><div><span>    </span><span>"public"</span><span>: </span><span>"public"</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></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li>构建前跑一次同步，把内容仓库浅克隆下来，挂到对应目录</li>
<li>私有仓库就用带 token 的 URL 访问，token 放在环境变量里</li>
</ul><p><strong>关键发现</strong>：它也没绕开 token。文档原文写得很直白——「需一次性配置访问令牌或部署钩子」。</p><p>也就是说，「内容私有 + 云端构建」这两个条件同时存在时，token 是躲不掉的。区别只在于用哪种机制把内容搬进来。</p></section>
<section><h2>第一版：submodule，白做了<a href="#第一版submodule白做了"><span>#</span></a></h2><p>我一开始还是走了 submodule 这条路，因为看着最”正统”：主仓库里挂个子模块，指向内容仓库，克隆的时候自动拉下来。</p><p>做起来也确实不难：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>cd</span><span> </span><span>content</span></div></div><div><div><div>2</div></div><div><span>git</span><span> </span><span>init</span><span> </span><span>-b</span><span> </span><span>main</span></div></div><div><div><div>3</div></div><div><span>git</span><span> </span><span>add</span><span> </span><span>-A</span><span> &amp;&amp; </span><span>git</span><span> </span><span>commit</span><span> </span><span>-m</span><span> </span><span>"初始化内容仓库"</span></div></div><div><div><div>4</div></div><div><span>git</span><span> </span><span>remote</span><span> </span><span>add</span><span> </span><span>origin</span><span> </span><span>https://github.com/hy4962/firefly-content.git</span></div></div><div><div><div>5</div></div><div><span>git</span><span> </span><span>push</span><span> </span><span>-u</span><span> </span><span>origin</span><span> </span><span>main</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span># 回主仓库挂上</span></div></div><div><div><div>8</div></div><div><span>git</span><span> </span><span>rm</span><span> </span><span>-r</span><span> </span><span>--cached</span><span> </span><span>content</span></div></div><div><div><div>9</div></div><div><span>git</span><span> </span><span>submodule</span><span> </span><span>add</span><span> </span><span>https://github.com/hy4962/firefly-content.git</span><span> </span><span>content</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>本地跑得好好的。推到 GitHub，Vercel 构建——<strong>失败</strong>。</p><p>去看 Vercel 文档，一句话写得明明白白：</p><blockquote><p>Git submodules that are private or requested over SSH will fail during the Build step.</p></blockquote><p><strong>私有 submodule 在 Vercel 上必挂。</strong> 给 Vercel 的 GitHub App 授权也没用，社区里一堆人试过了，结论一致：得自己用 token 拉。</p><p>也就是说，submodule 这条路的唯一优势（不用管凭证）根本不存在，反而多了一堆毛病：本地克隆要加 <code>--recurse-submodules</code>、主仓库里多了个 <code>.gitmodules</code>、写文章得进子仓库 commit 再回主仓库提交指针。</p><p><strong>那还不如照 Shirone 的做法来。</strong></p></section>
<section><h2>改成构建时同步<a href="#改成构建时同步"><span>#</span></a></h2><p>方案换成了：主仓库完全不含内容，构建前用一个脚本把内容仓库拉进 <code>src/site-content/</code>。</p><p>脚本很简单，读环境变量、浅克隆、完事：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>DEST</span><span> </span><span>=</span><span><span> </span><span>path</span><span>.</span></span><span>join</span><span>(</span><span>ROOT</span><span>, </span><span>"src"</span><span>, </span><span>"site-content"</span><span>);</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>URL</span><span> </span><span>=</span><span><span> (</span><span>process</span><span>.</span></span><span>env</span><span>.</span><span>CONTENT_REPO_URL</span><span> </span><span>??</span><span> </span><span>""</span><span>).</span><span>replace</span><span>(</span><span>/</span><span>\s</span><span><span>+</span><span>/</span></span><span>g</span><span>, </span><span>""</span><span>);</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>// 本地已经有一份就直接跳过，不用每次开发都重新拉</span></div></div><div><div><div>5</div></div><div><span>if</span><span><span> (</span><span>hasContent</span><span> </span></span><span>&amp;&amp;</span><span> </span><span>!</span><span>FORCE</span><span>) {</span></div></div><div><div><div>6</div></div><div><span>  </span><span>log</span><span>(</span><span>"src/site-content/ 已存在，跳过同步（本地开发模式）。"</span><span>);</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>process</span><span>.</span><span>exit</span><span>(</span><span>0</span><span>);</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>execFileSync</span><span>(</span><span>"git"</span><span>, [</span><span>"clone"</span><span>, </span><span>"--depth=1"</span><span>, </span><span>"--branch"</span><span>, </span><span>REF</span><span>, </span><span>URL</span><span>, </span><span>DEST</span><span>], {</span></div></div><div><div><div>11</div></div><div><span>  </span><span>stdio</span><span>:</span><span> </span><span>"inherit"</span><span>,</span></div></div><div><div><div>12</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>配套的改动是三处：</p><ul>
<li><code>src/content.config.ts</code> 里四个 collection 的 glob <code>base</code> 指向新目录</li>
<li><code>.gitignore</code> 把内容目录加进去（这样主仓库不再跟踪任何文章）</li>
<li><code>vercel.json</code> 的 <code>buildCommand</code> 前置这个脚本</li>
</ul><p>我在 Cloudflare 也有一个部署，那边没有配置文件这一说，构建命令得去面板里手填（设置 → 构建 → 构建命令），填成一样的内容。</p><p><strong>注意</strong>：这里的路径处理有个细节——脚本用 <code>replace(/\s+/g, "")</code> 把 URL 里所有空白字符清掉了。原因后面踩坑那节讲。</p></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><section><h3>坑一：内容目录放错位置，封面图全没了<a href="#坑一内容目录放错位置封面图全没了"><span>#</span></a></h3><p>改完跑起来，首页一看：所有文章卡片的封面都是空白。</p><p>去查产物，首页 929KB 的 HTML 里<strong>只有 8 个 <code>&lt;img&gt;</code></strong>，全是壁纸和 logo，文章卡片一个都没有。</p><p>翻主题源码才搞明白，问题出在图片解析的写法上。<code>ImageWrapper.astro</code> 里是这么找图片的：</p><div><figure><figcaption></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>import</span><span>.</span><span>meta</span><span>.</span><span>glob</span><span>&lt;</span><span>ImageMetadata</span><span>&gt;(</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"../../**/*.{png,jpg,jpeg,webp,avif}"</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>{ </span><span>import</span><span>:</span><span> </span><span>"default"</span><span> },</span></div></div><div><div><div>4</div></div><div><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这个文件在 <code>src/components/common/</code> 下，所以 <code>../../</code> 解析出来就是 <strong><code>src/</code></strong>。<strong>它只能看见 <code>src/</code> 目录里的图片。</strong></p><p>我一开始把内容放在了仓库根的 <code>content/</code>，图片全在 <code>src/</code> 外面，glob 一个都扫不到。同理 <code>schema-image.ts</code> 里写的是 <code>/src/**/*</code>，也是同样的问题。</p><p>改法很直接——内容目录搬回 <code>src/</code> 里面，叫 <code>src/site-content/</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>src/</span></div></div><div><div><div>2</div></div><div><span>├── site-content/     ← 内容在这里（.gitignore 忽略）</span></div></div><div><div><div>3</div></div><div><span>│   ├── posts/</span></div></div><div><div><div>4</div></div><div><span>│   ├── spec/</span></div></div><div><div><div>5</div></div><div><span>│   ├── dynamic/</span></div></div><div><div><div>6</div></div><div><span>│   └── projects/</span></div></div><div><div><div>7</div></div><div><span>├── content/          ← 上游的示例文件，原样保留</span></div></div><div><div><div>8</div></div><div><span>├── components/</span></div></div><div><div><div>9</div></div><div><span>└── ...</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>顺带验证了一下 Vite 会不会因为 <code>.gitignore</code> 跳过这个目录——翻了一遍 Vite 的实际代码，<strong>没有 gitignore 相关的处理</strong>，所以可以放心忽略。</p></section><section><h3>坑二：环境变量里混进一个换行<a href="#坑二环境变量里混进一个换行"><span>#</span></a></h3><p>这个坑最阴。</p><p>Vercel 构建日志：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>warning: url contains a newline in its path component</span></div></div><div><div><div>2</div></div><div><span>fatal: credential url cannot be parsed</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Cloudflare 那边报的是另一句话：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>remote: Invalid username or token.</span></div></div><div><div><div>2</div></div><div><span>fatal: Authentication failed</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>同一个病根</strong>：粘贴长 URL 的时候，末尾带上了一个换行符。Vercel 直接说”URL 里有换行”，Cloudflare 语言更含蓄——换行把 token 从中间截断了，GitHub 收到一个残缺的 token，于是报认证失败。</p><p>回想起来，Vercel 那个环境变量输入框右下角一直挂着一个<strong>橙色警告图标</strong>，我当时没当回事。那就是在提示这个。</p><p>除了重新粘贴，我在脚本里也加了兜底，把 URL 里的空白字符统一清掉，以后就算粘脏了也不会挂。</p></section><section><h3>坑三：本地那份内容目录不能删<a href="#坑三本地那份内容目录不能删"><span>#</span></a></h3><p>一开始我想着内容都搬到独立仓库了，项目里那份是不是可以删掉。</p><p>不行。<code>pnpm dev</code> 需要它——本地开发时脚本会跳过同步（因为目录已存在），直接读里面的文件。删掉的话本地预览直接空掉。</p><p>线上倒是不受影响，构建时脚本会自己拉一份进来。</p><p>所以最后是两份副本共存：</p>

<table><thead><tr><th>位置</th><th>作用</th></tr></thead><tbody><tr><td><code>D:\Users\XOS\Documents\GitHub\firefly-content</code></td><td>写作目录，独立仓库，写文章在这里 commit + push</td></tr><tr><td><code>Firefly\src\site-content\</code></td><td>项目内的副本，<code>pnpm dev</code> 的内容来源</td></tr></tbody></table><p><strong>注意别两边都改</strong>，不然会分叉——这个”分叉”不是假设，后面很快就真发生了一次。</p></section></section>
<section><h2>最后一块：内容仓库推送也得能触发部署<a href="#最后一块内容仓库推送也得能触发部署"><span>#</span></a></h2><p>到这一步，文章已经独立了，但它有个新问题：<strong>Vercel 和 Cloudflare 只监听主仓库</strong>，往内容仓库推文章，它们完全没反应。</p><p>解决办法是 Deploy Hook。两个平台都能生成一个 URL，往那个 URL 发个 POST 就会触发一次部署。</p><p>在 Vercel：Settings → Git → Deploy Hooks，<strong>分支要填对</strong>（我填的 <code>HY</code>），不然会部署到别的分支去。</p><p>在 Cloudflare：设置 → 构建 → 部署挂链。</p><p>然后给内容仓库加一个 workflow，push 的时候去戳这两个地址：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>on</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>push</span><span>:</span></div></div><div><div><div>3</div></div><div><span>    </span><span>branches</span><span>: [</span><span>main</span><span>]</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>jobs</span><span>:</span></div></div><div><div><div>6</div></div><div><span>  </span><span>trigger</span><span>:</span></div></div><div><div><div>7</div></div><div><span>    </span><span>runs-on</span><span>: </span><span>ubuntu-latest</span></div></div><div><div><div>8</div></div><div><span>    </span><span>steps</span><span>:</span></div></div><div><div><div>9</div></div><div><span><span>      </span></span><span>- </span><span>name</span><span>: </span><span>通知构建平台</span></div></div><div><div><div>10</div></div><div><span>        </span><span>env</span><span>:</span></div></div><div><div><div>11</div></div><div><span>          </span><span>VERCEL_HOOK</span><span>: </span><span>${{ secrets.VERCEL_DEPLOY_HOOK }}</span></div></div><div><div><div>12</div></div><div><span>          </span><span>CF_HOOK</span><span>: </span><span>${{ secrets.CF_DEPLOY_HOOK }}</span></div></div><div><div><div>13</div></div><div><span>        </span><span>run</span><span>: </span><span>|</span></div></div><div><div><div>14</div></div><div><span><span>          </span></span><span>if [ -n "${VERCEL_HOOK:-}" ]; then</span></div></div><div><div><div>15</div></div><div><span><span>            </span></span><span>curl -fsS -X POST "$VERCEL_HOOK" &gt; /dev/null &amp;&amp; echo "Vercel 已触发"</span></div></div><div><div><div>16</div></div><div><span><span>          </span></span><span>fi</span></div></div><div><div><div>17</div></div><div><span><span>          </span></span><span>if [ -n "${CF_HOOK:-}" ]; then</span></div></div><div><div><div>18</div></div><div><span><span>            </span></span><span>curl -fsS -X POST "$CF_HOOK" &gt; /dev/null &amp;&amp; echo "Cloudflare 已触发"</span></div></div><div><div><div>19</div></div><div><span><span>          </span></span><span>fi</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>两个 URL 存进仓库 secret，不写在文件里——<code>curl</code> 出去的地址是能反复触发部署的，明文放在仓库里终究不合适。</p></section>
<section><h2>顺手把本地流程也改了<a href="#顺手把本地流程也改了"><span>#</span></a></h2><p>两份副本共存的状态用着别扭，毛病也很快来了：两边都是完整的 git 仓库，我在这边提交、那边还停在旧版本，push 的时候直接被拒（<code>non-fast-forward</code>）。</p><p>回头又去翻 Shirone 的文档，发现它的本地开发是这么设计的：</p><ul>
<li>用 <code>CONTENT_DIR</code> 指向本地内容仓库，<code>content:sync</code> 一次性把内容同步进代码仓</li>
<li>想边写边看，再开一个终端跑 <code>content:watch</code>，增量同步、保存即热重载</li>
<li><strong>关键</strong>：它的 <code>src/content</code> 是同步脚本物化出来的副本，不是独立的 git 克隆——所以根本没有”两份副本分叉”这回事</li>
</ul><p>我照着改了一版：</p><ul>
<li><code>sync-content.mjs</code> 增加 <code>CONTENT_DIR</code> 支持（优先级高于 <code>CONTENT_REPO_URL</code>，本地直接复制、不走网络）</li>
<li>新增 <code>watch-content.mjs</code>：监视内容仓库，改动增量同步到 <code>src/site-content/</code></li>
<li>把这两个动作写进本地的 <code>dev.sh</code> / <code>dev.bat</code>（这俩在 <code>.gitignore</code> 里）：先同步 → 后台起监听 → 起 dev server</li>
</ul><p>现在开发一条命令：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>./dev.sh</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>在内容仓库里保存 Markdown，watch 立刻同步过去，浏览器热重载。项目里那份 <code>src/site-content/</code> 彻底变成生成物——删了也无所谓，下次同步就回来了，也不再有两边版本对不上的问题。</p></section>
<section><h2>现在的样子<a href="#现在的样子"><span>#</span></a></h2><p>写文章的流程：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>cd</span><span> </span><span>D:</span><span>\U</span><span>sers</span><span>\X</span><span>OS</span><span>\D</span><span>ocuments</span><span>\G</span><span>itHub</span><span>\f</span><span>irefly-content</span></div></div><div><div><div>2</div></div><div><span># 写文章</span></div></div><div><div><div>3</div></div><div><span>git</span><span> </span><span>add</span><span> </span><span>.</span><span> &amp;&amp; </span><span>git</span><span> </span><span>commit</span><span> </span><span>-m</span><span> </span><span>"新文章：xxx"</span><span> &amp;&amp; </span><span>git</span><span> </span><span>push</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>推完不用管，Vercel 和 Cloudflare 各自会重新拉一遍内容、构建、上线。</p><p>本地想预览效果：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>cd</span><span> </span><span>D:</span><span>\U</span><span>sers</span><span>\X</span><span>OS</span><span>\D</span><span>ocuments</span><span>\G</span><span>itHub</span><span>\F</span><span>irefly</span></div></div><div><div><div>2</div></div><div><span>./dev.sh</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>主仓库那边现在是什么状态：<code>git ls-files src/site-content</code> 返回 <strong>0</strong>，一个文章文件都不跟踪，只留主题代码和配置。以后 <code>git merge upstream/master</code> 只跟主题较劲，<code>src/content</code> 是上游的原样，不会再打架。</p></section>
<section><h2>几个结论<a href="#几个结论"><span>#</span></a></h2><p>如果只是给主题仓库做内容分离，有几点值得先知道：</p><ol>
<li>
<p><strong>私有 submodule 在 Vercel 上不能用</strong>，这是官方明确写的限制。别像我一样先做完再发现。</p>
</li>
<li>
<p><strong>token 躲不开</strong>。内容要私有、又要云端构建，凭证是必须的。区别只是用 submodule 还是构建时同步。</p>
</li>
<li>
<p><strong>内容目录必须落在 <code>src/</code> 里面</strong>。主题的图片解析是写死扫 <code>src/</code> 的（<code>import.meta.glob("../../**")</code>），放外面封面和配图会全部消失。</p>
</li>
<li>
<p><strong>粘贴环境变量后检查一眼</strong>。输入框角落的警告图标不是装饰，一个换行符就能让你排查半小时。</p>
</li>
<li>
<p><strong>早年提交过的内容还留在历史里</strong>。这套改动只管往后——主仓库从今以后不跟踪任何文章，但之前提交过的那些依然在历史记录中。要不要一并清理是另一个决策：重写历史意味着所有 commit hash 变化、必须 force push，跟上游的关联也会变复杂。我的情况没必要，就留着了。</p>
</li>
<li>
<p><strong>本地开发别用”两份克隆”</strong>。一开始我在项目里放了个独立克隆当预览源，结果两边各写各的、push 时撞车。改成 <code>CONTENT_DIR</code> 指向外部目录 + 一个 watch 脚本之后，项目内那份变成纯粹的生成物，问题就没了。</p>
</li>
</ol></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/guestbookchat/</id>
      <title type="text">留言板改成聊天窗口：只改了主题一个文件，还少下载 82 KB</title>
      <published>2026-10-05T00:00:00.000Z</published>
      <updated>2026-10-05T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/guestbookchat/"/>
      <summary type="text">在 blog.amamo.top 的留言板看到群聊风格的界面：气泡、成员列表、公告栏，像开在博客里的群。扒了仓库发现后端就是 Waline，我现成有一个。约束照旧是不能动主题源文件，于是把 11 个文件搬过来之后，把原本要改 6 类上游文件的移植压到只动 guestbook.astro 一个文件，顺带把这一页的评论脚本从 100 KB 减到 23 KB。</summary>
      <content type="html"><![CDATA[<p>前两天写友链自动申请那篇的时候，在 <a href="https://blog.amamo.top" target="_blank">blog.amamo.top</a> 上翻了半天。今天又回去把它留言板翻出来看——不是普通评论区，是一整个聊天窗口：气泡、成员列表、顶上一条公告栏，右下角输入框，像开在博客里的群。</p>
<p></p><figure><img alt="改完的样子，暗色模式下的气泡与成员列表" loading="lazy" width="1280" height="760" src="/_astro/image-001.C2tU8PgM_2idBd3.webp" /><figcaption>改完的样子，暗色模式下的气泡与成员列表</figcaption></figure><p></p>
<p>扒了仓库，比想象的好复现。</p>
<a href="https://github.com/qwc-ch/Firefly" target="_blank"><div><div><div><div></div><div>qwc-ch</div></div><div>/</div><div>Firefly</div></div><div></div></div><div>Description not set</div><div><div>—</div><div>—</div><div>MIT</div><span>Astro</span></div></a>
<section><h2>后端是现成的，只是把 UI 换了<a href="#后端是现成的只是把-ui-换了"><span>#</span></a></h2><p>参考站自己写过一篇《留言页的群聊风格改造》，说组件直接与 Twikoo 的 API 通信。那篇文章应该是早期版本，仓库现状早就不是了——现在 import 的是 <code>@waline/api</code>，拿评论、发评论、改评论、删评论、登录，全走 Waline 的接口。</p><p>这对我是好消息：我的评论后端本来就是 Waline（<code>waline.9ll.uk</code>），留言板这个页面在 Waline 里就是 <code>path=/guestbook/</code> 的一组评论，跟文章评论同库不同 path。等于后端一个字节不用动，纯换前端。</p><p>组件本体是一个 Svelte 文件，43 KB 源码，用的是 Svelte 5 的 runes（<code>$state</code> / <code>$derived.by</code>）。主题的 <code>astro.config.mjs</code> 里 <code>svelte()</code> 集成上游本来就有，<code>@astrojs/svelte</code> 的版本我和参考站还完全一致，搬过来连 API 差异都没有。</p></section>
<section><h2>搬过来 11 个文件，改上游的只有 1 个<a href="#搬过来-11-个文件改上游的只有-1-个"><span>#</span></a></h2>

<table><thead><tr><th>文件</th><th>干什么</th></tr></thead><tbody><tr><td><code>components/features/GuestbookChat.svelte</code></td><td>主组件，43 KB，聊天窗口本体</td></tr><tr><td><code>components/features/GuestbookChatComposer.svelte</code></td><td>输入框：表情、图片、资料卡</td></tr><tr><td><code>components/features/GuestbookChatMessage.svelte</code></td><td>单条气泡：头像、引用、工具按钮</td></tr><tr><td><code>components/features/GuestbookChatFallback.astro</code></td><td>水合前的骨架屏（SSR）</td></tr><tr><td><code>utils/guestbook-chat.ts</code>、<code>guestbook-chat-markup.ts</code></td><td>评论树拍平、正文渲染</td></tr><tr><td><code>utils/guestbook-lang.ts</code></td><td>中文文案表（我自己加的，下面说）</td></tr><tr><td><code>types/guestbook-chat.ts</code>、<code>types/guestbookConfig.ts</code>、<code>config/guestbookConfig.ts</code></td><td>类型与公告配置</td></tr><tr><td><code>styles/components/guestbook-chat.css</code></td><td>全部样式，50 KB</td></tr></tbody></table><p>参考站为了塞进主题，改了 6 类上游文件：页面骨架、<code>variables.styl</code>、<code>main.css</code>、<code>i18nKey.ts</code> 加 6 个语言文件、<code>package.json</code>、<code>types/config.ts</code>。照单全收的话，以后每次合并上游都要在 i18n 那 7 个文件里打一架。所以三处改道：</p><p><strong>一、137 个 i18n key 换成自建文案表。</strong> 组件里的文案全是 <code>i18n(I18nKey.gbXxx)</code>，数下来 137 个新 key——意味着要改 <code>i18nKey.ts</code> 和 6 个语言文件，那正是上游高频改动区。我全部替换成自建文件里的 <code>GB_LANG.gbXxx</code>（186 处调用，脚本批量换的）。站点是中文单语，为多语言维护 7 个上游文件纯属自找麻烦；真要恢复多语言，把常量表换回 <code>i18n()</code> 调用就行。</p><p><strong>二、<code>--guestbook-*</code> 变量就近写进组件自己的 CSS。</strong> 参考站把它们写在主题的 <code>variables.styl</code> 里，亮暗两套 45 行。这些变量只有这套组件用，写在组件自己的样式文件里效果一样，还不用碰上游。</p><p><strong>三、样式引入从 <code>main.css</code> 挪到页面里。</strong> 参考站往 <code>main.css</code> 加了一行 <code>@import</code>，后果是这 50 KB 样式被打进全站公共 CSS——我抓了它的 Layout.css 数了数，<code>guestbook-chat</code> 类名出现 200 次，访客在每篇文章都要白下载一遍。改成在 <code>guestbook.astro</code> 里 <code>import "@/styles/components/guestbook-chat.css"</code>，Astro 按页分块，只有 <code>/guestbook/</code> 加载。</p><p>改完之后 <code>git status</code> 里被修改的上游文件只剩 <code>src/pages/guestbook.astro</code> 一个——88 行的页面骨架，上游几乎不动它，就算动了手工合一下也就一分钟的事。</p></section>
<section><h2>30 秒更新是拉取，不是推送<a href="#30-秒更新是拉取不是推送"><span>#</span></a></h2><p>组件里就一个定时器：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>POLL_INTERVAL</span><span> </span><span>=</span><span> </span><span>30_000</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>function</span><span> </span><span>startPolling</span><span>() {</span></div></div><div><div><div>4</div></div><div><span>  </span><span>if</span><span><span> (</span><span>pollTimer</span><span>) </span><span>window</span><span>.</span></span><span>clearInterval</span><span><span>(</span><span>pollTimer</span><span>);</span></span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>pollTimer</span><span> </span><span>=</span><span><span> </span><span>window</span><span>.</span></span><span>setInterval</span><span>(() </span><span>=&gt;</span><span> {</span></div></div><div><div><div>6</div></div><div><span>    </span><span>if</span><span><span> (</span><span>document</span><span>.</span></span><span>visibilityState</span><span> </span><span>===</span><span> </span><span>"visible"</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>navigator</span><span>.</span></span><span>onLine</span><span>) {</span></div></div><div><div><div>7</div></div><div><span>      </span><span>void</span><span> </span><span>syncLatest</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></span><span>}, </span><span>POLL_INTERVAL</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>没有 WebSocket、没有 SSE，Waline 也不提供这些。所谓”实时”就是浏览器每 30 秒发一个普通 GET，<code>pageSize=30</code> 拉最新一页，按 id 去重合并进列表。服务器全程无状态，甚至不知道有谁开着页面。</p><p>我拿无头浏览器实测过：页面可见时每 29.8 秒一次请求、每次响应 1.6 KB；切到后台标签 70 秒 0 次请求；切回来 2.5 秒内立刻补一次再恢复定时。访客停留 5 分钟也就 10 次请求、16 KB，后台标签页是零。发消息也不用等 30 秒——发送成功后组件会立刻补一次同步。</p></section>
<section><h2>旧回复看不到引用，是两套体系打架<a href="#旧回复看不到引用是两套体系打架"><span>#</span></a></h2><p>上线后立刻发现一个问题：用旧评论框发的回复，在聊天界面里看不到”回复了谁”的引用块。</p><p>原因是这套组件的回复关系是<strong>自己编码</strong>的——发送时往正文开头塞一个 HTML 注释，渲染时再解析出来：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;!--guestbook-reply:305687:HY--&gt;</span></div></div><div><div><div>2</div></div><div><span>@HY 测试回复</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>而旧评论框发的回复走的是 Waline 自己的 <code>pid</code> / <code>reply_user</code> 字段，正文里没有标记，组件自然解析不出来。我留言板上那条 9 月 9 日的回复就是这么发的，引用块直接消失。</p><p>修法是解析时加一层回退：标记优先，没有就查 <code>pid</code>。有个细节——Waline 对一级评论返回的是 <code>pid: null</code>（<strong>键存在、值是 null</strong>），判据必须写 <code>typeof pid === "number"</code>，用 <code>"pid" in comment</code> 会把所有一级评论误判成子评论：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>childFields</span><span> </span><span>=</span><span><span> </span><span>comment</span><span> </span><span>as</span><span> </span></span><span>Partial</span><span>&lt;</span><span>WalineChildComment</span><span>&gt;;</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>nativePid</span><span> </span><span>=</span><span> </span><span>typeof</span><span><span> </span><span>childFields</span><span>.</span></span><span>pid</span><span> </span><span>===</span><span> </span><span>"number"</span><span> </span><span>?</span><span><span> </span><span>childFields</span><span>.</span></span><span>pid</span><span> </span><span>:</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>3</div></div><div><span>// replyToId: parsed.replyToId ?? (nativePid === null ? undefined : String(nativePid))</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这样历史回复、以及以后从 Waline 后台发的回复，都能正常显示引用。</p></section>
<section><h2>界面上又修了三处<a href="#界面上又修了三处"><span>#</span></a></h2><p><strong>标题压在壁纸上。</strong> 主题本来就会让主内容区上浮 3.5rem 叠在首屏壁纸底部——普通页面有白底卡片挡着，看不出来；留言板是透明背景，标题就直接印在图上了。给页面加 3.5rem 的 <code>margin-top</code> 让开，高度顺势放大到原来的 1.2 倍。</p><p><strong>公告栏和消息文字叠在一起。</strong> 公告栏原本是绝对定位的浮层，底色写的是 <code>color-mix(in srgb, var(--guestbook-danger) 9%, var(--guestbook-surface))</code>，而 <code>--guestbook-surface</code> 是 <code>transparent</code>——整条栏实际只有 9% 不透明度，全靠 <code>backdrop-filter</code> 兜底，这个属性在父级带 overflow 的环境里说失效就失效。我索性把浮层改成占位式布局：公告栏独立占一行，消息在它下面滚动，底色换成主题的 <code>--card-bg</code>，谁也不挡谁。</p><p><strong>「不再显示」的字号死活不生效。</strong> 给公告弹窗加了”不再显示”的小字（写入 localStorage，点过以后不再自动弹窗），调字号时发现 CSS 里的 <code>font-size</code> 完全不生效，而同一条规则里的 <code>color</code>、<code>margin</code> 都好好的。翻了所有样式表才找到元凶：同文件里有一条 <code>.guestbook-chat button { font-size: inherit }</code>，特异性比我的单类选择器高一级，把字号顶回去了。选择器加上父级前缀解决。</p><p></p><figure><img alt="公告弹窗，左下角是「不再显示」，右侧是「我知道了」" loading="lazy" width="1280" height="760" src="/_astro/image-002.C0c6mQDm_2bh68I.webp" /><figcaption>公告弹窗，左下角是「不再显示」，右侧是「我知道了」</figcaption></figure><p></p></section>
<section><h2>省了多少<a href="#省了多少"><span>#</span></a></h2>

<table><thead><tr><th>项目</th><th>原版留言板</th><th>现在</th></tr></thead><tbody><tr><td>评论前端</td><td><code>@waline/client</code> 完整客户端</td><td>自写组件</td></tr><tr><td>脚本来源</td><td>unpkg 第三方 CDN</td><td>同域自托管</td></tr><tr><td>脚本体积</td><td>100.9 KB（gzip）</td><td>23.4 KB（brotli）</td></tr><tr><td>样式</td><td>—</td><td>不再打进全站 CSS</td></tr></tbody></table><p>最大的收益其实不是这 80 KB，而是甩掉了 unpkg——原版从第三方 CDN 拉 <code>@waline/client</code>，国内能不能打开全看缘分；现在全部资源同域托管，和站点走同一条链路。</p></section>
<section><h2>要抄的话<a href="#要抄的话"><span>#</span></a></h2><ol>
<li>装依赖：<code>pnpm add @waline/api lucide-svelte</code></li>
<li>把上面表格里的文件从 <a href="https://github.com/qwc-ch/Firefly" target="_blank">qwc-ch/Firefly</a> 拿过来放进同样的路径，建议用 raw 地址加 commit SHA 逐个拉，锁住版本，避免他以后更新后行为对不上</li>
<li>前提检查：<code>commentConfig</code> 里 Waline 的 <code>serverURL</code> 得是能用的后端，<code>astro.config.mjs</code> 里得有 <code>svelte()</code> 集成——Firefly 主题两者都自带</li>
<li><code>guestbookConfig.ts</code> 里写你的公告，<code>announcements</code> 置空数组可以连公告栏一起关掉</li>
</ol><p>写在最后。这套东西本质上是”把 Waline 的评论树拍平成聊天记录”，后端一个字节没动，随时可以退回原版评论框——把 <code>guestbook.astro</code> 还原就行。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/homedynamicbar/</id>
      <title type="text">首页加了一条会滚的动态：参考楠枝小筑，一个主题文件都没改</title>
      <published>2026-10-05T00:00:00.000Z</published>
      <updated>2026-10-05T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/homedynamicbar/"/>
      <summary type="text">在楠枝小筑首页看到一条会一直横滚的「瞬间」条，想在自己站上也挂一个。他用的是 Ethereal 主题，代码没法照抄，所以只搬机制：Memos 数据在客户端拉，滚动靠 CSS 把轨道复制成两份，DOM 走页脚注入再加一点点搬运。最后改动落在两个文件里，主题源码一个没碰。</summary>
      <content type="html"><![CDATA[<p>翻到一个叫楠枝小筑的站（<a href="https://www.nanzhiy.cn/" target="_blank">nanzhiy.cn</a>），首页横幅下面挂着一条一直在横向滚的东西：左边一个「瞬间」胶囊，右边「更多」，中间是滚过去的动态文字，鼠标放上去还会停。</p>
<p>我这边也有「动态」页，走的是 Memos。但内容全藏在 <code>/dynamic/</code> 里，首页上一丁点都照应不到 —— 别人点进来，不点那个链接根本不知道有这东西。</p>
<p>所以把它搬过来。</p>
<p></p><figure><img alt="首页那条会横滚的动态条，点了下滑箭头之后的落点" loading="lazy" width="1440" height="720" src="/_astro/cover.DSwAPJHA_Z1Nl91v.webp" /><figcaption>首页那条会横滚的动态条，点了下滑箭头之后的落点</figcaption></figure><p></p>
<section><h2>他那条是怎么做的<a href="#他那条是怎么做的"><span>#</span></a></h2><p>扒了一遍他的页面。这个站用的不是 Firefly，是 <strong>Ethereal</strong>（资源路径是 <code>/themes/Ethereal/assets/*.css</code>，页面里还留着 Thymeleaf 的痕迹 —— <code>.home-moments-track</code> 那块 JS 里写着 <code>th:if</code>）。</p><p>代码抄不了，机制可以搬。拆开看只有四件事：</p>

<table><thead><tr><th>机制</th><th>具体做法</th></tr></thead><tbody><tr><td>无缝滚动</td><td>轨道内容复制成<strong>完全相同的两份</strong>，<code>translateX(0) → translateX(-50%)</code> 线性无限循环</td></tr><tr><td>内容不够一屏会静止</td><td>先按 <code>ceil(跑道宽 / 内容宽)</code> 重复 k 份把一屏补满，再整体复制</td></tr><tr><td>左右淡出</td><td><code>mask-image</code> 上一层左右两端透明的渐变</td></tr><tr><td>手感</td><td>速度约 48px/s、最短 20s；悬停暂停；<code>prefers-reduced-motion</code> 时完全不动</td></tr></tbody></table><p>无缝的原理就是那个「两份」：第二份的开头和第一份的内容完全一样，所以平移到 -50%（正好是一份的宽度）时，第二份的开头刚好落在起点，肉眼接不上接缝。</p><p>还有个很容易漏的点：<strong>内容不足一屏时必须先把内容重复到够宽</strong>。不然一份还没跑道长，0 到 -50% 这段里后面是空的，整条看起来就是在原地不动。</p></section>
<section><h2>我的落法：只动两个文件<a href="#我的落法只动两个文件"><span>#</span></a></h2><p>主题的 <code>MainGridLayout.astro</code> 是上游文件，改它以后合并上游必冲突。所以还是走老路子 —— 页脚 HTML 注入 + 客户端脚本。</p>

<table><thead><tr><th>文件</th><th>状态</th><th>干什么的</th></tr></thead><tbody><tr><td><code>src/config/FooterConfig.html</code></td><td>改（追加）</td><td>条子的 DOM + 样式 + 按需加载那十几行</td></tr><tr><td><code>public/assets/js/home-dynamic-bar.js</code></td><td>新增</td><td>拉 Memos、渲染轨道、控制滚动、把 DOM 搬到位</td></tr></tbody></table><p><code>FooterConfig.html</code> 是本站已经在用的注入点（页脚的运行时间和访问统计都在里面），<code>&lt;style&gt;</code> 和 <code>&lt;script&gt;</code> 都能正常生效。</p><p>脚本只在首页 <code>/</code> 才拉取，其他页面只多十几行判断。</p><p>数据没什么好说的：<code>/dynamic/</code> 已经在用 Memos，直接连同一个接口拿最近的 12 条就行。响应里的 <code>creator</code> 字段得在客户端再过滤一次 —— Memos 的 ListMemos 带不带 <code>parent</code> 返回结果都一样，这个坑主题自己的 <code>memos-adapter.ts</code> 里也标注了。</p></section>
<section><h2>条子该插在哪<a href="#条子该插在哪"><span>#</span></a></h2><p>这个是折腾最久的地方，前后改了两版。</p><p>第一版插在 <code>#main-grid</code> 之前，横跨全宽，看着没问题 —— 直到点了首页那个下滑箭头，滚动条整个不见了。</p><p>查了半天才反应过来。那个箭头的 onclick 长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>onclick="document.getElementById('main-grid')?.scrollIntoView(</span><span>{</span><span><span> </span><span>behavior</span><span>: </span></span><span>'smooth'</span><span> </span><span>}</span><span>)"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>它滚的是 <strong><code>#main-grid</code></strong>，不是页面往下翻一屏。而 <code>#main-grid</code> 自己还带着 <code>scroll-margin-top: 5.5rem</code>（怕被吸顶导航栏盖住），所以点完之后它正好停在视口往下 88px 的位置 —— 条子在它前面，自然被甩到视口上方去了。</p><div><div><div></div><div>第二轮我一度把它挪进了 <code>#content-column</code></div></div><div><p>那样确实不会被甩出去，但只有文章列的宽度（1440 视口下 701px），比左右侧栏窄一大截，看着像「嵌」进去的一小块，不是横跨的一整条。又改了回来。</p></div></div><p><strong>最终解法</strong>：让条子变成 <code>#main-grid</code> 的 <strong>grid item</strong>，靠 <code>grid-column: 1 / -1</code> 横跨全部列。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.ff-dyn</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>grid-column: </span><span>1</span><span> / </span><span>-1</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>display: </span><span>none</span><span>;   </span><span>/* 默认不显示，数据到位才放开 */</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>margin: </span><span>0</span><span>;       </span><span>/* 行间距交给 #main-grid 自己的 gap-4 */</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>一举两得：既在 <code>#main-grid</code> <strong>内部</strong>（箭头滚过来时它就在视野里），又拿到了横跨左右侧栏的全宽。</p><p>能这么改的前提是 <code>#main-grid</code> 用的是<strong>自动放置</strong>。它自己的样式注释写得很清楚：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* #main-grid 列几何全部来自 --cols-* / --*-display-*；</span></div></div><div><div><div>2</div></div><div><span><span>   </span></span><span>子元素不做定位：侧栏包裹层是 display:contents，真正的 grid item 是内层元素 */</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>没有写死列位置，所以插一个跨列 item 不会打乱左右侧栏的排布，其余 item 自动顺延到下一行。</p><p>另外 <code>margin</code> 必须清零 —— <code>#main-grid</code> 自带 <code>gap-4</code>，再叠一层 margin 就是双倍间距。</p></section>
<section><h2>两个不显眼但会咬人的坑<a href="#两个不显眼但会咬人的坑"><span>#</span></a></h2><p><strong>1. <code>hidden</code> 属性在这儿是失效的</strong></p><p>一开始想用 <code>hidden</code> 控制「数据没来就别显示」，结果发现条子一直在页脚位置上挂着。</p><p>原因是作者样式的 <code>.ff-dyn { display: flex }</code> 和浏览器默认的 <code>[hidden] { display: none }</code> <strong>特异性完全一样</strong>（都是 0,1,0），同分时后定义的赢 —— 作者样式胜出，<code>hidden</code> 直接被吃掉。</p><p>要么显式写一条：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.ff-dyn</span><span>[</span><span>hidden</span><span>]</span><span><span> { display: </span><span>none</span><span>; }</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>要么干脆别用 <code>hidden</code>。本站最后改成了加 <code>.ff-dyn--ready</code> 类来控制。</p><p><strong>2. Vercel 给 <code>/assets/*</code> 配了 30 天强缓存</strong></p><p><code>vercel.json</code> 里这条会让改过的 js 在老访客那里一直是旧的：</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>"source"</span><span>: </span><span>"/assets/(.*)"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"headers"</span><span>: [</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>5</div></div><div><span>      </span><span>"key"</span><span>: </span><span>"Cache-Control"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>      </span><span>"value"</span><span>: </span><span>"public, max-age=2592000, stale-while-revalidate=604800, must-revalidate"</span></div></div><div><div><div>7</div></div><div><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></div></div></code></pre><div><div></div><div></div></div></figure></div><p>所以加载脚本时挂了个版本号，以后改脚本记得把 <code>v=</code> 往上加：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>script</span><span>.</span></span><span>src</span><span> </span><span>=</span><span> </span><span>"/assets/js/home-dynamic-bar.js?v=1"</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>顺手测了下会不会拖慢首页<a href="#顺手测了下会不会拖慢首页"><span>#</span></a></h2><p>这个是上线前最担心的：条子要等 Memos 数据回来才显示，会不会变成首页的阻塞项？</p><p>用无头浏览器跑了一组对照 —— 把 <code>memos.49o.pw</code> 的请求全部 abort，模拟接口整段挂掉：</p>

<table><thead><tr><th></th><th>正常加载</th><th>接口全挂</th></tr></thead><tbody><tr><td>FCP（首次内容绘制）</td><td>1112ms</td><td><strong>1112ms</strong></td></tr><tr><td>DOMContentLoaded</td><td>1437ms</td><td>1428ms</td></tr><tr><td>首页文章卡数量</td><td>10</td><td><strong>10</strong></td></tr><tr><td>CLS（布局偏移）</td><td>0</td><td>0</td></tr><tr><td>滚动条</td><td>1599ms 出现</td><td>永不出现，也不留空壳</td></tr></tbody></table><p>FCP 两组一模一样，说明条子根本不在首屏的关键路径上。三个原因：</p><ol>
<li>DOM 默认 <code>display: none</code>，不占布局也不参与渲染；</li>
<li>那个 js 是<strong>动态创建插入的 async 脚本</strong>，不阻塞解析，也不阻塞 DOMContentLoaded；</li>
<li>数据是页面渲染完之后才发起的客户端请求，拉不到就永远不显示。</li>
</ol><p>它比首屏内容晚约 400ms 出现，而且是从无到有插进去的。因为首页横幅占满整屏、<code>#main-grid</code> 那一刻还在视口外，实测 CLS 是 0，没有可见的跳动。数据本身还带了 10 分钟的本地缓存，第二次访问基本是瞬时出现。</p><p>暗色模式直接跟着主题变量走，没额外写一套色：</p><p></p><figure><img alt="暗色模式下的动态滚动条" loading="lazy" width="1440" height="900" src="/_astro/image-001.CGBLUqKh_1mgWwO.webp" /><figcaption>暗色模式下的动态滚动条</figcaption></figure><p></p></section>
<section><h2>上手步骤<a href="#上手步骤"><span>#</span></a></h2><p>想照搬的话大概是这几步：</p><ol>
<li>在 <code>FooterConfig.html</code> 里追加条子的 DOM、样式和按需加载脚本（只在首页拉）</li>
<li>新建 <code>public/assets/js/home-dynamic-bar.js</code>，数据源改成你自己的 Memos 实例和用户名</li>
<li>脚本里 <code>mount()</code> 用 <code>host.insertBefore(bar, host.firstChild)</code> 把 DOM 插到 <code>#main-grid</code> 首位</li>
<li>样式里给 <code>.ff-dyn</code> 加 <code>grid-column: 1 / -1</code> 拿全宽，<code>margin</code> 清零</li>
<li>拉不到数据时不要加 <code>.ff-dyn--ready</code>，让它彻底不显示</li>
<li>本地 <code>astro build</code> 验一次，确认图片都走了 <code>_astro</code> 管线</li>
</ol><p>脚本里的几个参数集中在这几行，想调直接改：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>var</span><span> </span><span>MAX_ITEMS</span><span> </span><span>=</span><span> </span><span>12</span><span>;        </span><span>// 最多放几条</span></div></div><div><div><div>2</div></div><div><span>var</span><span> </span><span>SPEED</span><span> </span><span>=</span><span> </span><span>48</span><span>;            </span><span>// 滚动速度 px/s，越小越慢</span></div></div><div><div><div>3</div></div><div><span>var</span><span> </span><span>MIN_DURATION</span><span> </span><span>=</span><span> </span><span>20</span><span>;     </span><span>// 一轮最短几秒，内容太少时兜底</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>整个过程最贵的不是写代码，是想明白条子该插在哪。前后改了三版，最后一版其实是反过来利用了主题自己的 grid 布局 —— 要是 <code>#main-grid</code> 当初写死了列位置，这条路就走不通。</p><p>还有一件事得说清楚：现在首页横幅是全屏模式（<code>mode: "fullscreen"</code>），占满整个视口，所以条子在首屏之下，要点那个下滑箭头才看得到。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/steamlibrary/</id>
      <title type="text">给博客加了个 Steam 游戏库：没后端，蹭朋友圈的定时任务喂数据</title>
      <published>2026-10-05T00:00:00.000Z</published>
      <updated>2026-10-05T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/steamlibrary/"/>
      <summary type="text">参考站用 Halo 插件在服务端拉 Steam Web API，我这静态站没后端，改成跟朋友圈同一套定时快照，顺带补了没 Key 时的降级路径。</summary>
      <content type="html"><![CDATA[<p>在 <a href="https://nxxy335.top/steam" target="_blank">nxxy335.top/steam</a> 看到他做了个 Steam 游戏库页面：54 款游戏、637 小时总时长、最近在玩什么，全都列得清清楚楚。</p>
<p>我 Steam 上也攒了些游戏，这种”游戏履历”摆在博客上挺有意思的，就想着也搞一个。</p>
<p>不过第一眼看到的是他的页面，第二眼想的是另一个问题：<strong>这数据是浏览器直接调的，还是他后端拉的？</strong></p>
<section><h2>他的数据是怎么来的<a href="#他的数据是怎么来的"><span>#</span></a></h2><p>扒了一下页面源码，答案很清楚。</p><p>站点是 <strong>Halo 2.26.1</strong>（<code>&lt;meta name="generator"&gt;</code> 里写着），装了个第三方插件 <code>plugin-steam</code>。页面 HTML 里游戏名、时长、封面图 URL <strong>全是烤好的静态内容</strong>——也就是说数据是后端定时调 Steam 接口拿到、缓存下来、再服务端渲染出去的。前端那个 <code>steam-game-card.js</code> 只在点开某张卡片时补一次详情请求，走的是他自己的后端代理。</p><p>浏览器从头到尾没碰过 Steam。</p><p>这个区别很关键，因为它直接决定了我要怎么抄：<strong>他有常驻后端，我是纯静态站。</strong> 服务端定时同步这条路我走不了。</p></section>
<section><h2>静态站没有后端，但这事有解<a href="#静态站没有后端但这事有解"><span>#</span></a></h2><p>静态站拿外部数据，常规就三条路：</p>

<table><thead><tr><th>方案</th><th>数据新鲜度</th><th>代价</th></tr></thead><tbody><tr><td>构建期 fetch</td><td>停在部署那一刻</td><td>零配置，但 Steam 抽风会挂构建</td></tr><tr><td><strong>定时任务写快照</strong></td><td>每天一次</td><td>一条 CI 任务</td></tr><tr><td>Serverless 函数实时拉</td><td>每次打开都最新</td><td>每访客一次请求，首屏空白，还得处理限流</td></tr></tbody></table><p>我选了中间那条，原因特别简单：<strong>我博客里已经有一条干这事的流水线了。</strong></p><p>朋友圈（友链 RSS 聚合）有个 <code>refresh-friends-feed.yml</code>，每周定时跑脚本抓所有友链的 RSS，写进 <code>src/data/friends-feed-snapshot.json</code>，有变化就 commit——提交一推，Vercel 和 Cloudflare 各自检测到就重新构建，内容自然保持新鲜。</p><p>Steam 游戏库是<strong>完全同一类活</strong>：定时抓外部数据、落本地文件、构建时读。所以我没有新建第二条流水线，直接往这条里加了一个步骤。跑一次、提交一次、构建一次，朋友圈和游戏库一起刷新。</p></section>
<section><h2>数据本身：Steam Web API<a href="#数据本身steam-web-api"><span>#</span></a></h2><p>接口不复杂，主要是三个：</p><ul>
<li><code>ISteamUser/GetPlayerSummaries</code> —— 昵称、头像、在线状态</li>
<li><code>IPlayerService/GetBadges</code> —— 等级和徽章数</li>
<li><code>IPlayerService/GetOwnedGames</code> —— 拥有游戏、每款总时长、近两周时长、最后游玩时间</li>
</ul><p>要申请一个 <strong>Steam Web API Key</strong>（<a href="https://steamcommunity.com/dev/apikey" target="_blank">申请页</a>）。这里有个前提：账号必须<strong>非受限</strong>（消费满 5 美元），否则申请不了。所幸我这号没这个问题。</p><p>另外记得把 Steam 隐私设置里的**「游戏详情」设为公开**，不然 <code>GetOwnedGames</code> 返回空。</p><p>封面图不用额外调商店接口。直接拼就行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://cdn.cloudflare.steamstatic.com/steam/apps/&lt;appid&gt;/header.jpg</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>实测 200，约 34 KB，460×215。参考站用的是带 hash 的 <code>store_item_assets</code> 那种 URL，那是要先把商店详情拉下来才能拿到的——<strong>别走这条弯路</strong>。</p></section>
<section><h2>没配置 Key 也不至于空着<a href="#没配置-key-也不至于空着"><span>#</span></a></h2><p>Key 还没申请下来的那段时间，页面就一直是空的。</p><p>后来补了条降级路径：没有 Key 就退回去抓 <code>steamcommunity.com/profiles/&lt;id&gt;/?xml=1</code> 这个公开 XML。不用任何凭据，能拿到昵称、头像、在线状态和<strong>常玩的几款游戏</strong>（含时长）。</p><p>代价是数据不完整——我这号实测只有 <strong>2 款</strong>。真正的游戏库有 86 款。所以脚本里加了个 <code>partial</code> 标记，页面会显示一条提示说明现在是精简模式，统计卡也从「拥有游戏」改成「收录游戏」，不去误导读者。</p><p>有 Key 走完整模式，没 Key 走精简模式，代码自己判断，不用手动切换。</p></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><section><h3><code>steamids</code> 是复数<a href="#steamids-是复数"><span>#</span></a></h3><p>这个是代码里最坑的一处。我把三个接口共用一个参数拼装：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>keyParam</span><span> </span><span>=</span><span> </span><span>`key=</span><span>${</span><span>apiKey</span><span>}</span><span>&amp;steamid=</span><span>${</span><span>steamId</span><span>}</span><span>`</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>结果 <code>GetPlayerSummaries</code> 一直返回 <strong>HTTP 400</strong>，我第一反应是 Key 有问题——刚申请的新 Key 不该失效，又怀疑是不是还没生效，来回试了几轮。</p><p>后来把响应体打出来才看到：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;html&gt;&lt;body&gt;&lt;h1&gt;Bad Request&lt;/h1&gt;Required parameter 'steamids' is missing&lt;/body&gt;&lt;/html&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>这个接口的参数名是复数 <code>steamids</code></strong>，另外两个才是单数 <code>steamid</code>。修一下就好了。</p><div><div><div></div><div>排查接口报错时一定要把响应体打出来</div></div><div><p>只看状态码的话，400 太容易被当成”凭据无效”了。</p></div></div></section><section><h3>别拿 302 判断”游戏详情公开了吗”<a href="#别拿-302-判断游戏详情公开了吗"><span>#</span></a></h3><p>想确认隐私设置对不对，我一开始拿 <code>steamcommunity.com/profiles/&lt;id&gt;/games?tab=all&amp;xml=1</code> 试探——返回 302 跳登录，看起来像是游戏列表没公开。</p><p>其实那个端点<strong>现在对所有人都跳登录</strong>，不管你的游戏详情公开不公开。参考站的游戏数据明显是公开的，我去试了一下，同样 302。</p><p>所以这条路判断不了任何事，配好 Key 跑一次脚本，看输出里有没有收录数才靠谱。</p></section><section><h3><code>game_count</code> 不含免费游戏<a href="#game_count-不含免费游戏"><span>#</span></a></h3><p><code>GetOwnedGames</code> 返回的 <code>game_count</code> 只统计付费游戏。CS2、Apex 这类免费游戏会出现在 <code>games</code> 数组里，但不计入这个数。我这边 <code>game_count</code> 是 86，实际玩过的有 51 款（时长大于 0 的），两个数各有各的用处，别混着展示。</p><p>顺带一提，想让免费游戏也出现在列表里，接口要加 <code>include_played_free_games=1</code>。</p></section></section>
<section><h2>密钥会不会泄露<a href="#密钥会不会泄露"><span>#</span></a></h2><p>这是我自己最担心的一点——<strong>仓库是公开的</strong>。</p><p>实际盘下来，风险比想象中小得多。核心原因是存档和取数是分开的：</p>

<table><thead><tr><th>环节</th><th>有没有 Key</th></tr></thead><tbody><tr><td>GitHub Secrets</td><td>加密存储，设置后连自己都看不到明文</td></tr><tr><td>Actions 运行</td><td>环境变量注入，日志里自动显示为 <code>***</code></td></tr><tr><td>快照 JSON</td><td>只有游戏数据，不含 Key</td></tr><tr><td>Vercel / Cloudflare 构建</td><td><strong>根本不需要 Key</strong></td></tr><tr><td>前端 JS 和 HTML</td><td>没有</td></tr></tbody></table><p>最后两行是这个方案最舒服的地方。构建时页面干的事只有一句 <code>import snapshot from "@/data/steam-snapshot.json"</code>——读一个已经提交的普通 JSON 文件。所以 Vercel 和 Cloudflare 后台都不用配任何环境变量，<strong>只在 GitHub 加一个 Secret 就够了</strong>。</p><p>顺手做的验证：<code>git log --all -S "&lt;密钥片段&gt;"</code> 搜全部分支和全部历史，零命中。（注意 GitHub 网页的代码搜索只索引默认分支，我的改动在 <code>HY</code> 分支上，用网页搜其实说明不了问题。）</p><div><div><div></div><div>Key 换掉之后记得手动跑一次</div></div><div><p>脚本的容错策略是”抓失败就保留旧快照、不让 CI 变红”。好处是流水线永远不炸，坏处是<strong>Key 失效了也不会有人告诉你</strong>，页面会一直显示旧数据。所以换 Key 之后建议手动 Run 一次 workflow，确认日志里打出「完整模式」和正确的收录数。</p></div></div></section>
<section><h2>上手步骤<a href="#上手步骤"><span>#</span></a></h2><p>如果你想在自己博客上也搞一个，流程是这样：</p><ol>
<li>到 <a href="https://steamcommunity.com/dev/apikey" target="_blank">steamcommunity.com/dev/apikey</a> 申请 Key，域名随便填，最后要在手机 Steam App 上确认一次</li>
<li>把 Steam 隐私设置里的「游戏详情」设为<strong>公开</strong></li>
<li>仓库 Settings → Secrets and variables → Actions 添加 <code>STEAM_API_KEY</code></li>
<li>把 64 位 Steam ID 填进配置（可以在 Steam 客户端「账户明细」里找到，或者用 steamid.io 查）</li>
<li>手动跑一次那条定时 workflow，确认日志输出正常</li>
</ol><p>脚本零依赖，只用 Node 内置的 <code>fetch</code>，几秒就跑完。</p></section>
<section><h2>最后看看效果<a href="#最后看看效果"><span>#</span></a></h2><p></p><figure><img alt="Steam 游戏库页面暗色模式" loading="lazy" width="1440" height="900" src="/_astro/image-001.DmhRTQk9_16QeKr.webp" /><figcaption>Steam 游戏库页面暗色模式</figcaption></figure><p></p><p>现在的数据是 <strong>86 款游戏、总时长 3364 小时、15 级、12 个徽章</strong>，按时长排前几名是 CS2（2265 小时）、Wallpaper Engine（295 小时）、Call of Duty（217 小时）。「最近游玩」那块会显示近两周有时长的游戏，比总时长榜更有”最近在干嘛”的感觉。</p><p>整件事改动是 6 个文件、+1343 行：一条 workflow 加了一个 step，新增一个抓取脚本、一个配置文件、一份快照数据和一个页面。除了导航菜单要加个入口，<strong>组件、布局、样式、i18n 这些高频改动区一行都没碰</strong>，上游合并的时候不会打架。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/friendcoverpreview/</id>
      <title type="text">给友链页加了悬停预览：12 张截图离线截好，主题文件一行没动</title>
      <published>2026-10-04T00:00:00.000Z</published>
      <updated>2026-10-04T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/friendcoverpreview/"/>
      <summary type="text">在 blog.amamo.top 的友链页看到鼠标悬停弹首页截图的效果。我这边的约束是不能动主题源文件，所以换了个做法：截图用本机浏览器离线截、转 webp 存进仓库，卡片用一段独立脚本在浏览器里现场拼。12 张封面一共 190 KB，悬停之前一张都不加载。</summary>
      <content type="html"><![CDATA[<p>今天翻 <a href="https://blog.amamo.top/friends/" target="_blank">blog.amamo.top</a> 的友链页，鼠标往卡片上划过的时候，卡片顶部长出了一条对方首页的截图。</p>
<p>效果大概是这样，不悬停是普通卡片：</p>
<p></p><figure><img alt="未悬停时的友链卡片，头像加名字和描述" loading="lazy" width="1386" height="660" src="/_astro/image-001.wYseNGDZ_1hMor6.webp" /><figcaption>未悬停时的友链卡片，头像加名字和描述</figcaption></figure><p></p>
<p>划上去之后，卡片顶部展开一条首页截图：</p>
<p></p><figure><img alt="友链卡片悬停时顶部展开对方首页截图" loading="lazy" width="1386" height="660" src="/_astro/cover.B9q9SxcG_1cSSxe.webp" /><figcaption>友链卡片悬停时顶部展开对方首页截图</figcaption></figure><p></p>
<p>不用点进去，扫一眼就知道对方站点长什么样。我那页友链现在还是一排光头像加名字，差距挺明显的。</p>
<section><h2>他那边怎么做的<a href="#他那边怎么做的"><span>#</span></a></h2><p>顺手把他的实现扒出来看了。核心是两行 CSS：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.friend-card__cover</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>height: </span><span>0</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>opacity: </span><span>0</span><span>;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>transition: height </span><span><span>0.3</span><span>s</span></span><span><span> </span><span>ease</span><span>, opacity </span></span><span><span>0.35</span><span>s</span></span><span><span> </span><span>ease</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>.friend-card</span><span>:hover</span></span><span> </span><span>.friend-card__cover</span><span> {</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>height: </span><span><span>7</span><span>rem</span></span><span>;</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>opacity: </span><span>1</span><span>;</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>截图默认高度 0、完全透明，悬停时展开到 7rem 并淡入。卡片本身被重写成了竖版：封面在上，头像加站点名、域名、描述在下。仓库在 <a href="https://github.com/qwc-ch/Firefly" target="_blank">qwc-ch/Firefly</a>。</p><p>然后是图从哪来的。他的封面地址长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://tu.520781.xyz/file/youlian/blog.cuteleaf.cn.png</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>路径里带着域名、还统一放在 <code>youlian</code> 目录下，估计是他自己一张张截好、批量传上图床的。</p></section>
<section><h2>我这边的约束<a href="#我这边的约束"><span>#</span></a></h2><p>我的博客是 fork 自 <a href="https://github.com/CuteLeaf/Firefly" target="_blank">CuteLeaf/Firefly</a> 的，上游一直在更新，所以给自己定了条规矩：<code>src/pages</code>、<code>src/components</code>、<code>src/styles</code> 这些主题源文件一律不动，要加东西就往 <code>src/config</code>、<code>src/content</code>、<code>public</code> 里塞新文件。</p><p>好处是上游发新版我直接 merge，冲突基本为零。代价是很多”顺手改一行”的事得绕路。</p><p>友链卡片正好在 <code>src/pages/friends.astro</code> 里渲染，撞红线上了。所以这个功能只能照着他的效果另找做法。</p></section>
<section><h2>截图从哪来<a href="#截图从哪来"><span>#</span></a></h2><p>先把图的问题解决掉。摆在面前的有三个选项：</p>

<table><thead><tr><th>方案</th><th>首次显示</th><th>维护成本</th><th>国内速度</th></tr></thead><tbody><tr><td>第三方截图 API（thum.io、mshots 这类）</td><td>5～15 秒</td><td>零</td><td>绕一圈国外</td></tr><tr><td>手动截图传图床</td><td>快</td><td>每加一条友链重做一次</td><td>看图床脸色</td></tr><tr><td>本机离线截好存进仓库</td><td>快</td><td>跑一条命令</td><td>同源，最快</td></tr></tbody></table><p>第一个方案不用试：这类服务是”你请求它、它现去打开那个网站截图”，第一次必然等好几秒。加上国内访问那些服务本身就慢，卡片展览会变成幻灯片。</p><p>第三个方案的代价是仓库多 190 KB、多一个脚本要维护。我选了它。</p></section>
<section><h2>离线截图脚本<a href="#离线截图脚本"><span>#</span></a></h2><p>新增 <code>scripts/generate-friends-covers.ts</code>，跑一条命令把启用中的友链全截一遍：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>npx</span><span> </span><span>tsx</span><span> </span><span>scripts/generate-friends-covers.ts</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>它干这几件事：</p><ol>
<li>从 <code>friendsConfig</code> 里取启用中的友链；</li>
<li>调本机的 <code>chrome-headless-shell</code> 逐个打开首页截图；</li>
<li>用 <code>sharp</code> 转成 640 宽的 webp，存进 <code>public/assets/images/friends/covers/</code>；</li>
<li>生成 <code>src/data/friends-covers.json</code> 映射清单。</li>
</ol><p>已经截过的会跳过，所以以后加了新友链再跑一次，只补新的那一张。</p><p>截图用的参数：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>--headless</span><span> </span><span>--no-sandbox</span><span> </span><span>--disable-gpu</span><span> </span><span>--hide-scrollbars</span></div></div><div><div><div>2</div></div><div><span>--force-device-scale-factor</span><span>=</span><span>1</span></div></div><div><div><div>3</div></div><div><span>--window-size</span><span>=</span><span>1280,512</span></div></div><div><div><div>4</div></div><div><span>--virtual-time-budget</span><span>=</span><span>10000</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>--window-size</code> 里的高度特意给得矮。卡片上那块封面是 7rem 高、宽度跟着卡片走，比例大概 2.5&lt;1&gt;，用 1280×512 截出来的正好是首页顶部（导航加首屏），比截个完整的 1280×800 再让 CSS 居中裁掉上下更合适。</p><div><div><div></div><div>Note</div></div><div><p><code>--virtual-time-budget</code> 是让浏览器等网络空闲，它不推进页面里的 <code>setTimeout</code>。有站点靠定时器延迟渲染首屏的话，截出来可能是空白。我这 12 个站没遇到，但这个坑先记着。</p></div></div><p>12 个站跑完，一次成功，零失败。单张 7～45 KB，加起来 190 KB。</p></section>
<section><h2>卡片是浏览器现场拼的<a href="#卡片是浏览器现场拼的"><span>#</span></a></h2><p>图有了，剩下的问题是怎么塞进卡片。</p><p>不能改 <code>friends.astro</code>，那就换个思路：卡片结构留给主题，封面用一段独立脚本在浏览器里补上去。</p><p>原卡片是个横向 flex：左边 64×64 头像，右边名字、描述、标签。要让它顶部能展开一条通栏封面，把布局改成两列网格最省事：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.friends-grid</span><span> </span><span>.friend-card</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>display: </span><span>grid</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>grid-template-columns: </span><span>auto</span><span> </span><span>minmax</span><span>(</span><span>0</span><span>, </span><span><span>1</span><span>fr</span></span><span>);</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>column-gap: </span><span><span>0.75</span><span>rem</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>.friends-grid</span><span> </span><span>.friend-card</span><span> </span><span>&gt;</span><span> </span><span>.ff-friend-cover</span><span> {</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>grid-column: </span><span>1</span><span> / </span><span>-1</span><span>; </span><span>/* 占满整行 */</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这里占了个便宜：卡片里那两个装饰元素（悬停时的主题色底块、右上角的箭头图标）本来就是 <code>position: absolute</code>，在网格里不参与自动排布。所以真正进网格的只有封面、头像、内容三块，位置正好排成”封面一行、头像和内容一行”。</p><p>封面首屏是不加载的。脚本给 <code>img</code> 挂的是 <code>data-src</code>，只有卡片真的被悬停或聚焦过，才把地址写进 <code>src</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[</span><span>"mouseenter"</span><span>, </span><span>"focusin"</span><span>, </span><span>"pointerdown"</span><span>, </span><span>"touchstart"</span><span>].</span><span>forEach</span><span>(</span><span>function</span><span><span> (</span><span>name</span><span>) {</span></span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>card</span><span>.</span><span>addEventListener</span><span><span>(</span><span>name</span><span>, </span><span>reveal</span><span>, { </span></span><span>once</span><span>:</span><span> </span><span>true</span><span>, </span><span>passive</span><span>:</span><span> </span><span>true</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><code>/friends/</code> 一页 12 张图，全铺上去就是 190 KB。现在第一次进去是 0 张，划过哪张加载哪张。</p></section>
<section><h2>数据接口和注入点<a href="#数据接口和注入点"><span>#</span></a></h2><p>映射表走了一个构建期生成的静态 JSON：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>src/pages/api/friends-covers.json.ts</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>加了 <code>export const prerender = true</code>，构建时把 <code>friendsConfig</code> 和截图清单合成一份文件，访客拿到的就是 <code>dist/api/friends-covers.json</code>。没有服务端，也没有运行时开销。</p><p>脚本怎么进到 <code>/friends/</code> 页面，这一步卡了一会儿。</p><p>最自然的做法是写进 <code>friends.mdx</code>，但那个页面的内容区在 <code>#swup-container</code> 里 —— swup 无刷新切页时，新插进 DOM 的 <code>&lt;script&gt;</code> 标签浏览器不会执行。从首页点进友链页，脚本就等于没加载。</p><p>所以最后落在页脚注入点（<code>src/config/FooterConfig.html</code>）上。它在 swup 容器之外，整页加载时执行一次，然后挂住 swup 的切页事件：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>document</span><span>.</span></span><span>addEventListener</span><span>(</span><span>"astro:page-load"</span><span><span>, </span><span>loadFriendCovers</span><span>);</span></span></div></div><div><div><div>2</div></div><div><span><span>document</span><span>.</span></span><span>addEventListener</span><span>(</span><span>"swup:page:view"</span><span><span>, </span><span>loadFriendCovers</span><span>);</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>页脚里只放十几行判断，真正的逻辑在 <code>/assets/js/friends-covers.js</code>，而且这个脚本只有 <code>/friends/</code> 才会去拉，其他页面不背这个包。</p></section>
<section><h2>性能账<a href="#性能账"><span>#</span></a></h2>

<table><thead><tr><th>项目</th><th>成本</th></tr></thead><tbody><tr><td><code>/friends/</code> 首屏</td><td>0 张封面图请求</td></tr><tr><td>悬停一张卡片</td><td>1 张 webp，7～45 KB，之后走浏览器缓存</td></tr><tr><td>友链脚本本体</td><td>4 KB，只在 <code>/friends/</code> 加载</td></tr><tr><td>其他页面</td><td>页脚多十几行判断，约 0.5 KB</td></tr></tbody></table></section>
<section><h2>一个知道但暂时没解决的问题<a href="#一个知道但暂时没解决的问题"><span>#</span></a></h2><p>悬停展开时卡片会从 94px 长到 206px，网格行高跟着变，所以同一行下面的卡片会被往下顶一下。</p><p>参考站也是这个行为（他的 CSS 就是 <code>height: 0 → 7rem</code>），我按他的做法来。想要完全不跳，得把封面改成绝对定位的浮层，那就不是他这个效果了，先放着。</p></section>
<section><h2>以后加友链<a href="#以后加友链"><span>#</span></a></h2><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>npx</span><span> </span><span>tsx</span><span> </span><span>scripts/generate-friends-covers.ts</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>截图脚本会跳过已截过的，只补新增那条，然后重写一遍清单。跑完构建带上线就完事。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/recommend/kite/</id>
      <title type="text">Kite：自带后台的静态博客，主题和插件都能按名字装</title>
      <published>2026-10-04T00:00:00.000Z</published>
      <updated>2026-10-04T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/recommend/kite/"/>
      <summary type="text">刷到一个用 Go 写的静态博客 CMS，最有意思的是它把后台直接编译进单个二进制里，主题和插件从官方市场按名字装。我把博客的 52 篇文章迁过去试了一遍。</summary>
      <content type="html"><![CDATA[<p>前两天刷 GitHub 的时候看到一个新项目，叫 Kite。star 才 12 个，创建也就半个多月，看着像是个玩具。</p>
<p>结果点进去一看，作者是把它当正经产品在做的 —— Go 写的 CMS，Apache-2.0，主题契约、插件 ABI、构建产物版本锁定，该有的都有。</p>
<p>我最近正好想给博客找个新后端，就顺手把这 52 篇文章全迁过去试了一圈。</p>
<section><h2>为什么觉得它有意思<a href="#为什么觉得它有意思"><span>#</span></a></h2><p>我做这个博客的时候有个一直很别扭的地方：写文章是纯 Markdown，但要改个标题、加张图、调整标签，得开编辑器改文件，存盘，切窗口看效果，来回折腾。</p><p>别的静态博客方案要么还是这套，要么就得自己配一个 CMS 前端 —— 通常是个单独的 Node 项目，跑起来还要连数据库。</p><p>Kite 的做法不一样：<strong>整个 CMS 就是一个二进制文件</strong>。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>kite 0.1.9 (152206b, 2026-10-03T20:26:53Z) go1.26.8 windows/amd64</span></div></div><div><div><div>2</div></div><div><span>theme api kite/v1, plugin abi 1</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>就这一行输出。32.4 MB 的 exe（解压后 34013696 字节），后台、默认主题、SQLite 驱动全编译进去了。解压完丢进 PATH，在一个空目录里敲 <code>kite run</code>，浏览器直接弹出建站页面，填完站名和作者名就进后台了。</p><p>没有数据库要装，没有依赖要跑，没有 npm install 出一屏 WARN。我这已经不是第一次被”解压即用”糊弄了，但 Kite 这个是真的一个文件。</p></section>
<section><h2>后台长什么样<a href="#后台长什么样"><span>#</span></a></h2><p>后台在 <code>http://localhost:1717/admin/</code>，可视化编辑器直接读写磁盘上的 Markdown。</p><p></p><figure><img alt="Kite 默认主题的浅色与深色外观，首页是文章卡片流，右侧是浏览器窗口预览" loading="lazy" width="1600" height="660" src="/_astro/image-001.CJgDtiCe_1QsPTg.webp" /><figcaption>Kite 默认主题的浅色与深色外观，首页是文章卡片流，右侧是浏览器窗口预览</figcaption></figure><p></p><p></p><figure><img alt="Kite 后台的仪表盘：文章 59、独立页面 5、分类 8、标签 161，右侧是最近十二个月的发布趋势和最新文章" loading="lazy" width="1600" height="1000" src="/_astro/image-002.CXeI1i7f_Z1OvYwh.webp" /><figcaption>Kite 后台的仪表盘：文章 59、独立页面 5、分类 8、标签 161，右侧是最近十二个月的发布趋势和最新文章</figcaption></figure><p></p><p>关键在”直接读写磁盘”这个说法：内容就是磁盘上的 <code>.md</code> 文件，保存时只改写真正变化的部分，key 顺序和注释都原样保留。</p><p></p><figure><img alt="可视化编辑器：分类、标签、slug、置顶、附件都摆在标题下面，正文所见即所得，工具栏能插表格、代码块和图片" loading="lazy" width="1600" height="1000" src="/_astro/image-003.B_lezmv9_Z1ihj10.webp" /><figcaption>可视化编辑器：分类、标签、slug、置顶、附件都摆在标题下面，正文所见即所得，工具栏能插表格、代码块和图片</figcaption></figure><p></p><p>这意味着你在后台改个标题，<code>git diff</code> 只有一行。写错了还能直接在编辑器里切回源码模式手动救。写作这件事和 Git 仓库之间没有割裂感 —— 这一点比很多 CMS 强太多了。</p><p></p><figure><img alt="切到源码模式就是原始 Markdown，顶部会提示这篇用到了内嵌 HTML、可视化编辑器保不住它们" loading="lazy" width="1600" height="1000" src="/_astro/image-004.BhBy_7R3_ZdBheK.webp" /><figcaption>切到源码模式就是原始 Markdown，顶部会提示这篇用到了内嵌 HTML、可视化编辑器保不住它们</figcaption></figure><p></p><p>我迁移文章的时候全程就是靠这个特性：写脚本批量改 front matter 的键名（<code>published</code> 改成 <code>date</code>、<code>category</code> 改成 <code>categories</code>），Kite 认这套规则，一遍过。</p></section>
<section><h2>插件化做到了什么程度<a href="#插件化做到了什么程度"><span>#</span></a></h2><p>插件分两种，一种往页面里注入代码，一种在构建时于沙箱里跑 WebAssembly。</p><p>构建时的钩子有这几个：</p>

<table><thead><tr><th>钩子</th><th>什么时候跑</th><th>能拿到什么</th></tr></thead><tbody><tr><td><code>transform_markdown</code></td><td>页面 Markdown 渲染之前</td><td><code>markdown</code></td></tr><tr><td><code>transform_html</code></td><td>每个渲染好的页面</td><td><code>html</code></td></tr><tr><td><code>build_complete</code></td><td>整站构建完成后</td><td><code>pages</code>，每页带纯文本 <code>text</code></td></tr></tbody></table><p>关键是 WASM 这个选择。插件跑在 Extism 沙箱里，不是拿 Go 插件往你进程里塞东西 —— 意味着任何有 Extism PDK 的语言都能写，官方插件用的是 Go。</p><p>目前官方的 4 个插件：</p>

<table><thead><tr><th>插件</th><th>作用</th><th>压缩包</th></tr></thead><tbody><tr><td><code>comments</code></td><td>评论，支持 Giscus / Waline / Twikoo</td><td>11.4 KB</td></tr><tr><td><code>analytics</code></td><td>访问统计，百度 / Google Analytics / Umami / Plausible</td><td>8.4 KB</td></tr><tr><td><code>math</code></td><td>KaTeX 公式 + mermaid 画图</td><td>927 KB</td></tr><tr><td><code>search</code></td><td>站内搜索，纯浏览器端，不需要服务端</td><td>978 KB</td></tr></tbody></table><p>索引里每个版本都记着要加载哪些外部域名（<code>loads</code> 字段），装之前你就能看清它要连哪些 CDN。<code>search</code> 那个插件的 <code>loads</code> 是空的 —— 搜索索引直接打进包里，一个外部请求都不发。<code>comments</code> 则老老实实列了 giscus.app 和两个 CDN。这种把网络依赖摊开给你看、而不是装完再说的做法，我觉得比插件数量更重要。</p><p></p><figure><img alt="应用中心：主题 2 个、插件 4 个，都从索引里按名字装，装进来的文件放在 themes/ 和 plugins/ 里" loading="lazy" width="1600" height="1000" src="/_astro/image-005.Cb2HYOUf_1oAXMm.webp" /><figcaption>应用中心：主题 2 个、插件 4 个，都从索引里按名字装，装进来的文件放在 themes/ 和 plugins/ 里</figcaption></figure><p></p><p></p><figure><img alt="插件详情里的「会对网站做什么」直接列出它会从哪些外部域名加载内容，装之前就能看到" loading="lazy" width="1600" height="1000" src="/_astro/image-006.DiKL6uHF_Z15aNBG.webp" /><figcaption>插件详情里的「会对网站做什么」直接列出它会从哪些外部域名加载内容，装之前就能看到</figcaption></figure><p></p></section>
<section><h2>主题市场<a href="#主题市场"><span>#</span></a></h2><p>主题也是按名字装，官方市场目前两个：</p><ul>
<li><strong>Almanac</strong>（年鉴）v0.1.2 —— 杂志风，首页有个刊头，下面是卡片流，还有 projects、books、moments、links、resume 各种版式</li>
<li><strong>Vane</strong>（风标）v1.0.2 —— 已经迭代到 1.0 了</li>
</ul><p></p><figure><img alt="主题页：年鉴在使用中，版本、作者、许可、仓库地址都列在右边，下面是「自定义」入口" loading="lazy" width="1600" height="1000" src="/_astro/image-007.CkyDFi13_Z1Ofz2R.webp" /><figcaption>主题页：年鉴在使用中，版本、作者、许可、仓库地址都列在右边，下面是「自定义」入口</figcaption></figure><p></p><p>我自己装的是 Almanac。它的 <code>theme.yaml</code> 里声明 <code>contentTypes: [post, page]</code>，只需要 Kite 原生的两种内容类型，正好能直接用上我迁过去的 <code>posts/</code> 和 <code>pages/</code>。</p><p>装主题在后台点一下就行，或者命令行 <code>kite theme add vane</code>。更新同理。</p></section>
<section><h2>部署方式随便挑<a href="#部署方式随便挑"><span>#</span></a></h2><p>这块我反而觉得不是重点，但确实自由：</p><p><strong>导出静态页</strong> —— 后台「部署」导出 zip，或者 <code>kite build</code> 写到 <code>public/</code>，丢到任何静态托管都行。</p><p></p><figure><img alt="部署页：导出 zip 和推送 Git 两条路，最后一段写着每个 Kite 版本都由 kite.lock 固定" loading="lazy" width="1600" height="1000" src="/_astro/image-008.DS4c2M75_Z25ltGn.webp" /><figcaption>部署页：导出 zip 和推送 Git 两条路，最后一段写着每个 Kite 版本都由 kite.lock 固定</figcaption></figure><p></p><p><strong>跑在自己服务器</strong> —— Docker 一条命令：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>docker</span><span> </span><span>run</span><span> </span><span>-d</span><span> </span><span>--name</span><span> </span><span>kite</span><span> </span><span>--restart</span><span> </span><span>unless-stopped</span><span> </span><span>-p</span><span> </span><span>127.0.0.1:1717:1717</span><span> </span><span>-v</span><span> </span><span>kite-data:/data</span><span> </span><span>ghcr.io/kite-plus/kite:latest</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>走 Git 发布</strong> —— <code>kite publish --all --push</code> 提交推送内容，托管平台的工作流负责上线。</p><p>有个细节我觉得挺实在：<code>kite.lock</code> 会把站点构建用的 Kite 版本固定下来，CI 里由 <code>kitew</code> 运行。也就是说<strong>你部署时用的 Kite 和你本地预览时的是同一个版本</strong>，不会因为上游发了新版导致线上突然挂掉。</p></section>
<section><h2>它现在到哪一步了<a href="#它现在到哪一步了"><span>#</span></a></h2><p>作者在 README 里写得很清楚，一点不藏着。</p><p><strong>已完成</strong>（0.1 系列）：静态构建、实时预览服务、浏览器后台、Git 发布（M0–M4），WebAssembly 插件第一版（M8）；主题契约 <code>kite/v1</code> 在 0.1.4 冻结（M5）；应用中心在 0.1.5 发布，0.1.6 起索引带签名；版本锁定在 0.1.7 发布（M6 的一部分）。</p><p><strong>接下来</strong>：增量构建（M6 剩下部分），以及基于 SQLite 的动态模式（M7）。</p><p>翻译成人话：写文章、后台、插件、主题、部署全都能用了；还没做完的是构建速度优化，以及带数据库的动态功能（比如访客交互、动态流这类需要服务端存储的东西）。</p><p>项目自己也写着”仍在早期开发中，版本之间仍可能有变化”。0.1.9 的 API 契约主题是 <code>&gt;=0.1.0 &lt;2.0.0</code>，卡得很死，但毕竟 0.1 阶段，谁都说不准明天会不会改。</p></section>
<section><h2>迁移过程中踩的坑<a href="#迁移过程中踩的坑"><span>#</span></a></h2><p>我这次迁移不算顺，Windows 上撞了三个。</p><p><strong>中文名图片存不进去。</strong> 篇文章里有 41 张 <code>01-设置root密码.jpg</code> 这种文件，Kite 构建时直接 <code>Access is denied</code>。我一开始以为是缓存脏了，清了 <code>.kite/</code> 重建，照样报。定位到是 Windows 文件名编码的问题，只好写脚本把所有非 ASCII 文件名改成 <code>img-1.jpg</code> 这种，再同步改写正文引用。这不是 Kite 逻辑错，是它对非 ASCII 文件名没做处理。</p><p><strong><code>doctor --fix-ids</code> 会制造 slug 冲突。</strong> 这命令给每篇文章分配唯一 ID 挺好用的，但它会顺手写一句 <code>slug: index</code>。我三篇页面 slug 全变成 <code>index</code>，构建直接报 SQLite 唯一约束失败。更坑的是 Windows 大小写不敏感 —— 如果我把旧地址写进 <code>aliases</code> 保留外链，<code>/posts/Oreimo/</code> 和 <code>/posts/oreimo/</code> 会去写同一个文件，又是 <code>Access is denied</code>。得把跟自身 slug 相同的 alias 剔掉。</p><p><strong><code>.kite/</code> 里有 SQLite，运行时删不掉。</strong> 想清缓存重来的时候 <code>rm -rf</code> 直接被占用文件拦了，得先停服务。</p><p>前两个坑是 Kite 在 Windows 上的真实缺陷，第三个算不上。但对一个”解压即用”的项目来说，这些确实会影响第一次上手的心情。</p></section>
<section><h2>跟现在的方案差在哪<a href="#跟现在的方案差在哪"><span>#</span></a></h2><p>我这个博客现在是 Firefly，Hugo 生态的静态站，主题功能多得多 —— 壁纸轮播、贴纸、追番、时间线、相册，都是折腾出来的。</p><p>对比一下：</p>

<table><thead><tr><th></th><th>Firefly</th><th>Kite</th></tr></thead><tbody><tr><td>技术栈</td><td>Astro + Svelte</td><td>Go 单二进制</td></tr><tr><td>后台</td><td>无（纯改文件）</td><td>自带可视化后台</td></tr><tr><td>装环境</td><td>Node + pnpm，依赖一大片</td><td>一个 exe</td></tr><tr><td>插件</td><td>自己改主题代码</td><td>官方市场装，有 ABI</td></tr><tr><td>主题</td><td>主题自带全部功能</td><td>主题 + 插件组合</td></tr><tr><td>内容量</td><td>52 篇 + 动态 + 项目 + 相册</td><td>同上（这次全迁了）</td></tr><tr><td>成熟度</td><td>稳定在 1.x</td><td>0.1.9，早期</td></tr></tbody></table><p>Kite 赢在”不用配环境 + 自带后台 + 装东西有统一入口”，Firefly 赢在”主题功能多到离谱”。</p></section>
<section><h2>适合谁<a href="#适合谁"><span>#</span></a></h2><p>我觉得挺适合这几类人：</p><ul>
<li><strong>想自己写博客但不想折腾环境的人</strong>。有个 exe 就够了，不用管 Node 版本、pnpm、依赖冲突。</li>
<li><strong>静态博客写累了的人</strong>。后台直接改，不用来回切窗口。</li>
<li><strong>想要插件化但不想自己写主题的人</strong>。评论、统计、公式、搜索都是现成的，主题自己换。</li>
</ul><p>不太适合的：</p><ul>
<li><strong>要功能完整的</strong>。图库、追番这类 Firefly 里折腾出来的东西 Kite 没有，官方市场也没有现成插件。Almanac 主题倒是画了 moments 时间线的版式，但要在 <code>kite.yaml</code> 里自己声明 dynamic 内容类型才能用 —— 官方 4 个插件和 2 个主题里没有一个帮你做这事。</li>
<li><strong>不接受早期项目的</strong>。0.1.9，API 契约随时可能变，star 才 12 个，基本没社区。</li>
<li><strong>在 Windows 上放大量中文文件名图片的</strong>。这条是我自己撞出来的，你如果图片都是英文名就没事。</li>
</ul></section>
<section><h2>我的判断<a href="#我的判断"><span>#</span></a></h2><p>方向是对的。</p><p>把 CMS 后台编译进单个二进制，这个取舍很克制 —— 代价是没有 Node 生态那套现成的东西，收益是零配置。主题契约和插件 ABI 冻结得早，说明作者想清楚了要往哪走，不是先跑起来再说。</p><p>我自己现在是两边留着：主站继续用 Firefly（那些折腾出来的功能我舍不得），Kite 拿来当试验田，写新东西或者想试试不同主题的时候在这边试。</p><p>最让我意外的是后台这个决定。之前觉得”静态博客配后台”是个伪需求 —— 我自己用 Astro 这么久也一直没配。但真的用下来，在后台改 front matter、拖图片上传、设标签这个过程，比开编辑器找文件改要舒服得多。</p><p>当然这也可能因为我刚从一个折腾了半年的 Astro 项目逃出来，单纯想换个不用折腾的环境。换句话说，如果我现在的主流博客工具已经自带好用的后台，我大概不会觉得这个东西有多新鲜。</p><hr /><p>如果你也在找静态博客的新方案，可以关注一下这个项目。不过还是那句话，早期项目，先看着别急着把主站搬过去。</p><p>项目地址在 <a href="https://github.com/kite-plus/kite" target="_blank"><code>kite-plus/kite</code></a>，文档在 <a href="https://github.com/kite-plus/kite/blob/main/docs/reference.zh-CN.md" target="_blank"><code>docs/reference.zh-CN.md</code></a>，写得比多数项目的文档细，踩坑之前值得先翻一遍。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/analyticsdashboard/</id>
      <title type="text">给博客加了张站点统计看板：自研翻车之后直接抄了整份实现</title>
      <published>2026-09-29T00:00:00.000Z</published>
      <updated>2026-09-29T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/analyticsdashboard/"/>
      <summary type="text">看上 rainzt.cn 的 /analytics/ 之后，我先照着自己设计了一版——能跑，但完全不是那个味道。被一句「你不会整就去抄人家代码」点醒，去翻同源 fork 的仓库才发现：人家根本不是单个文件，页面将近四千行，外加两个组件，少一个的话页面其余模块全正常、只有四个数字永远停在「加载中」。搬完又改了四轮：整页蓝换成主题色、推送数从 1695 修正到 252、热力图改 GitHub 绿、角标不再压住插画。</summary>
      <content type="html"><![CDATA[<p>上一篇刚给<a href="/posts/blog/footerstats/">页脚加了两行数字</a>，转头又看上了 <a href="https://rainzt.cn" target="_blank">rainzt.cn</a> 的 <code>/analytics/</code>。</p>
<p>那和页脚那两行完全不是一个量级——整整一页数据看板：</p>
<p></p><figure><img alt="站点统计页的顶部：hero 卡片和四张总览卡" loading="lazy" width="1440" height="720" src="/_astro/cover.D_G-uM25_nYLFd.webp" /><figcaption>站点统计页的顶部：hero 卡片和四张总览卡</figcaption></figure><p></p>
<p>四张彩色总览卡、能切 7 / 30 / 90 天的访问脉冲、按 GitHub 推送时间排的热力图、24 小时活跃分布、设备与浏览器的环形图，还有一张中国地图和最近评论。</p>
<section><h2>第一版：照着自己设计<a href="#第一版照着自己设计"><span>#</span></a></h2><p>我当时的做法是——截图，看一眼，然后自己写。</p><p>结果做出来是这样：能跑，数据也全对，但一看就不是那个味道。它的卡片有自己的四色体系（蓝、薄荷、金、紫），总览卡上压着纹理，访问脉冲下面还挂了一组「本周数据 / 大家在听什么」的小卡，动效的节奏也不一样。我这份是「功能齐全的朴素版」，人家那份是设计过的。</p><p>被一句话点醒之后我才反应过来：<strong>这东西的源头就在仓库里，我为什么要在那儿瞎设计。</strong></p><p>它的来源是 <a href="https://github.com/Jarvis0227/Aemeath" target="_blank">Jarvis0227/Aemeath</a>，和我一样是 Firefly 的 fork——同一种子。</p></section>
<section><h2>人家是四个部分，不是一个文件<a href="#人家是四个部分不是一个文件"><span>#</span></a></h2><p>去翻仓库，结构比我想的散：</p>

<table><thead><tr><th>文件</th><th>行数</th><th>干什么的</th></tr></thead><tbody><tr><td><code>src/pages/analytics.astro</code></td><td>3959</td><td>页面本身，模板 / 脚本 / 样式三大段</td></tr><tr><td><code>src/components/widget/AnalyticsSnapshot.astro</code></td><td>665</td><td>侧栏那块分布图</td></tr><tr><td><code>src/components/analytics/UmamiMetrics.astro</code></td><td>582</td><td><strong>渲染四个总览数字的</strong></td></tr><tr><td><code>public/assets/images/analytics/*</code> 等</td><td>8 个文件</td><td>角色插画、hero 背景、中国地图</td></tr></tbody></table><p>注意第三行。</p></section>
<section><h2>最大的坑：四个数字永远「加载中」<a href="#最大的坑四个数字永远加载中"><span>#</span></a></h2><p>我先把页面搬过来，构建，打开——访问脉冲有数据，环形图有数据，地图有数据，评论区也正常。</p><p><strong>唯独最上面那四张总览卡，一直停在「加载中」。</strong></p><p>查了半天才明白：页面模板里只输出了 <code>data-umami-stat</code> 占位符，<strong>真正去请求数据、把数字写进去的逻辑，写在 <code>UmamiMetrics.astro</code> 里</strong>。那个组件原本是在主题布局层全局引入的（页脚统计也用它），页面上根本不显眼。</p><p>漏了它，页面其余模块全部正常工作，因为那些数据是页面自己的脚本拉的；只有那四个数字永远填不上。</p><p>补上组件之后立刻出数：累计访客 670、累计浏览 4,716、今日访客 1、今日浏览 9。</p></section>
<section><h2>适配只动了三处<a href="#适配只动了三处"><span>#</span></a></h2><p>搬过来之后要做点本地化，但尽量少改，方便以后跟着上游一起更新。</p><p><strong>一是署名。</strong> hero 卡片右下角有一行 <code>RZ / 2026</code>，改成自己的。顺带把 localStorage 的 key 前缀和全局变量名里的 <code>rainzt</code> 也换掉了，一共 19 处——都是内部标识，看不见，改完不会互相干扰。</p><p><strong>二是两个缺的 CSS 变量。</strong> 页面用了 <code>--text-color</code> 和 <code>--font-active-sans</code>，这两个我这份主题里没有——作者是直接改了 <code>src/styles/variables.styl</code> 加的。为了两个变量去动主题源文件不划算，我改成在页面自己的 <code>.analytics-page</code> 作用域里就地定义，暗色模式也单独给了一组值。</p><p><strong>三是配置字段。</strong> 页面从 <code>analyticsConfig.umamiAnalytics</code> 里读 <code>shareId</code>、<code>shareApiBase</code> 和 <code>historicalStats</code>（迁移前历史累计，我没有迁移过就填 0）。</p></section>
<section><h2>顺手把入口挪了<a href="#顺手把入口挪了"><span>#</span></a></h2><p>第一版我把导航入口挂在了「关于」的子菜单里，结果被吐槽「不然我怎么进去」。</p><p>有道理。藏在下拉里的入口等于没有，现在提到了顶级导航，和「我的」「关于」并列。</p></section>
<section><h2>成品<a href="#成品"><span>#</span></a></h2><p>搬到一半的样子，左边是主题自带的侧栏，中间是看板主体：</p><p></p><figure><img alt="看板中段：访问脉冲、推送节奏、设备分布、地域分布、最近评论" loading="lazy" width="1440" height="1060" src="/_astro/image-001.CdyK0EaY_Zm3RUU.webp" /><figcaption>看板中段：访问脉冲、推送节奏、设备分布、地域分布、最近评论</figcaption></figure><p></p><p>再往下是活跃时段热力图和评论：</p><p></p><figure><img alt="看板下段：访客活跃时间与最近评论" loading="lazy" width="1440" height="1060" src="/_astro/image-002.BmdKJDd-_256DFh.webp" /><figcaption>看板下段：访客活跃时间与最近评论</figcaption></figure><p></p><p>数据全部来自 Umami 的公开分享接口，浏览器直连，<strong>不需要任何后端</strong>——和上一篇页脚统计是同一套路子，只是这次铺得更开。GitHub 提交记录在构建时拉取，Waline 评论走它的公开接口。</p></section>
<section><h2>上线后又改了四轮<a href="#上线后又改了四轮"><span>#</span></a></h2><p>部署完之后先自己看了两眼，然后被连着挑了四次毛病。回头看，这四个问题都不是 bug，而是「整份搬过来之后没消化」——功能是别人的，细节也全是别人的。</p><section><h3>一、整页蓝色，跟站点主题色打架<a href="#一整页蓝色跟站点主题色打架"><span>#</span></a></h3><p>一开始没注意，直到被指出「UMAMI / PUBLIC TELEMETRY 这种标题适配我的主题色吧，不然有点割裂」。</p><p>参考实现把主色写死成 <code>#2d7cff</code>，而这个变量被页面里几乎所有彩色元素引用：hero 的 kicker、每个模块的小标题、折线图、7 / 30 / 90 天切换、进度条。所以整页是蓝调，而本站主题是橙黄（<code>hue: 65</code>）。</p><p>改起来只有一行——<code>--analytics-blue</code> 从 <code>#2d7cff</code> 换成 <code>var(--primary)</code>。<code>--primary</code> 本身就有明暗两套取值，暗色模式那边写的那行覆盖也跟着删掉了。</p><p>顺带把「访客活跃时间」热力图的 6 级色阶从写死的蓝色改成 <code>color-mix</code> 从主色派生，否则暗色模式还得另写一组。有个细节：GitHub 那块热力图的色阶<strong>本来就是 <code>color-mix</code> 派生的</strong>，只有这一块写死——参考实现自己就没统一，改之前得 grep 清楚。</p></section><section><h3>二、推送数不对：fork 把上游历史也算进来了<a href="#二推送数不对fork-把上游历史也算进来了"><span>#</span></a></h3><p>这条是真错。热力图底部写着「最近 1690 次」，被一句反问戳穿：<strong>「我的推送没有这么多吧？你这是记录 commit 的吗」</strong>。</p><p><code>git log HEAD</code> 和 GitHub 的 <code>/commits</code> 接口<strong>默认统计仓库里所有作者的提交</strong>。这个仓库 fork 自 <code>CuteLeaf/Firefly</code>，上游那一千多条历史全被算进来了：</p>

<table><thead><tr><th></th><th>条数</th></tr></thead><tbody><tr><td>仓库全部提交</td><td>1695</td></tr><tr><td>我自己的</td><td><strong>252</strong></td></tr></tbody></table><p>差了七倍。加上 <code>--author</code> 过滤才对。</p><p>（顺带一提：GitHub 主页显示「过去一年 125 次贡献」，和这里的 252 又不一样——GitHub 只统计<strong>默认分支</strong>，而我的提交都在 <code>HY</code> 分支上。两个数字口径不同，别想着对齐。）</p><p>排查时我一度怀疑 API 限流、一度怀疑 <code>author</code> 参数不生效，都是白折腾。最后是在接口里加了两行日志，构建日志直接打出「git log 0 条 / API 也 0 条」，一次定位。<strong>早该这么做。</strong></p></section><section><h3>三、热力图要绿色，状态角标别压住插画<a href="#三热力图要绿色状态角标别压住插画"><span>#</span></a></h3><p>「推送热力图改成绿色，符合 GitHub 原版」——有道理，这个模块本来就是在模仿 GitHub 贡献图，用主题色反而不对。换成 GitHub 原版绿阶（<code>#9be9a8</code> → <code>#216e39</code>），暗色另配一组；「访客活跃时间」那块继续跟主题色，两者正好区分开。</p><p>同类的还有两个状态角标：「本地预览 · 已更新」和「已记录」。它们原本是 header 的 flex 子项，而 header 是 <code>space-between</code> 布局，右侧又恰好放着角色插画——角标被顶到最右，正好压在插画脸上。把它们挪进标题文案块就解决了。</p><p>两处角标、同一个原因，说明这不是偶然，是这套布局的固有倾向。</p></section><section><h3>四、Umami 链接点进去是登录页<a href="#四umami-链接点进去是登录页"><span>#</span></a></h3><p>我把「Umami」做成了外链，但 href 用的是 <code>shareApiBase</code>——那是实例根目录，访客点进去只有登录框，等于死链。</p><p>改成公开分享页才对：地址由 <code>shareApiBase</code> 和 <code>shareId</code> 拼出，而不是写死。以后换实例或换 shareId 就自动跟着变。</p></section><section><h3>小结<a href="#小结"><span>#</span></a></h3><p>这四轮改下来最大的体会：<strong>「能跑」和「是自己的」是两回事。</strong> 整份搬代码解决的是前者，配色、口径、排版这些细节才是后者。搬完之后最好逐块过一遍，问自己一句「这地方的数据和颜色，真的是我的吗」——第二项那个七倍差距，就是没问这一句的代价。</p></section></section>
<section><h2>顺带发现的一个老问题<a href="#顺带发现的一个老问题"><span>#</span></a></h2><p>看板页顶部 banner 上那四个巨大的「暂无封面」，我一度以为是自己搞出来的。</p><p>对比了一下 about 页面——<strong>也有</strong>。查代码才明白是主题音乐播放器的封面占位：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>ui</span><span>.</span></span><span>cover</span><span>.</span><span>src</span><span> </span><span>=</span><span> </span><span>''</span><span>;</span></div></div><div><div><div>2</div></div><div><span><span>ui</span><span>.</span></span><span>cover</span><span>.</span><span>classList</span><span>.</span><span>add</span><span>(</span><span>'opacity-0'</span><span>);</span></div></div><div><div><div>3</div></div><div><span><span>ui</span><span>.</span></span><span>cover</span><span>.</span><span>alt</span><span> </span><span>=</span><span><span> </span><span>cfg</span><span>.</span></span><span>i18n</span><span>.</span><span>noCover</span><span>;   </span><span>// 图片没 src 时 alt 就被浏览器画出来了</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>它给图片挂了 <code>opacity-0</code>，但 <code>alt</code> 文本照样渲染。这是主题自带的小瑕疵，要修得动 <code>src/components/features/</code>，本次没碰。</p></section>
<section><h2>最后说个教训<a href="#最后说个教训"><span>#</span></a></h2><p>这一轮最大的浪费不在技术，在流程：<strong>看到别人有个好效果，第一反应应该是先翻他的仓库，而不是照着截图自己设计。</strong></p><p>功能能对，味道对不上。而且人家那份是反复调过的，我再自己摸一遍，只是在重复他的试错过程。</p><p>不过反过来说，这次翻车也不是全白费——至少搞清楚了 <code>x-umami-share-token</code> 和 <code>x-umami-share-context</code> 这两个头缺一不可，那是自研时试出来的。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/footerstats/</id>
      <title type="text">给博客页脚加了两行数字：静态站直连 Umami 的零后端玩法</title>
      <published>2026-09-28T00:00:00.000Z</published>
      <updated>2026-09-28T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/footerstats/"/>
      <summary type="text">又是在 rainzt.cn 看上的东西——页脚一行跳秒的运行时间，一行四个访问数字。博客是跑在 Vercel 上的纯静态站，本来以为读不到 Umami 数据，结果试了一下发现分享接口的 CORS 是全开的。中间踩了个 401 的坑，最后落在 Firefly 原生的页脚注入点上，一个主题源文件都没动。</summary>
      <content type="html"><![CDATA[<p>前两篇写的都是<a href="/posts/blog/homestickercard/">首页贴纸卡片</a>和<a href="/posts/blog/blogchangelog/">更新日志页</a>，来源都是刷 <a href="https://rainzt.cn" target="_blank">rainzt.cn</a> 刷出来的羡慕。这次又轮到它了——刷到页脚，发现最底下挂着两行东西：</p>
<p></p><figure><img alt="rainzt.cn 页脚那行跳秒的运行时间" loading="lazy" width="664" height="94" src="/_astro/image-001.BCZ-Vosx_ZYhlvD.webp" /><figcaption>rainzt.cn 页脚那行跳秒的运行时间</figcaption></figure><p></p>
<p>一行是「本站已运行 82 天 23 小时 18 分 53 秒」，秒数还在跳；再往下一行是四个数字：今日访客、今日访问、本站访客、本站访问。</p>
<p>运行时间那行好办，纯前端算就行。真正让我停住的是第二行。</p>
<section><h2>第一反应：静态站读不到吧<a href="#第一反应静态站读不到吧"><span>#</span></a></h2><p>我的博客是 Astro 构建出来的纯静态站，跑在 Vercel 上。Umami 统计是另外部署的一个实例，数据在它自己那边。</p><p>按常识，浏览器要从 <code>www.9ll.uk</code> 去读 <code>umami.9ll.uk</code> 的数据，跨域了，得对方点头才行。而我没法在 Vercel 上加任何服务端代码去代理这个请求——那就不叫静态站了。</p><p>所以本来是打算放弃的。后来想，反正 curl 一下也不费事，先看看接口长什么样：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-sS</span><span> </span><span>-i</span><span> </span><span>"https://umami.9ll.uk/api/share/你的分享ID"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>响应头里明晃晃一行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Access-Control-Allow-Origin: *</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>行吧，Umami 的分享接口本来就是设计给外部页面调用的，人家压根没打算拦。</p><p>顺带拿到了后面要用的两个东西：<code>websiteId</code>，还有一枚 JWT。</p></section>
<section><h2>第二步卡住了：401<a href="#第二步卡住了401"><span>#</span></a></h2><p>有分享 ID 和 token 了，接下来取数据：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-sS</span><span> </span><span>-H</span><span> </span><span>"x-umami-share-token: </span><span>$TOKEN</span><span>"</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"https://umami.9ll.uk/api/websites/</span><span>$WEBSITE_ID</span><span>/stats?startAt=0&amp;endAt=</span><span>$NOW</span><span>"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>返回：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span><span>"error"</span><span>:{</span><span>"message"</span><span>:</span><span>"Unauthorized"</span><span>,</span><span>"code"</span><span>:</span><span>"unauthorized"</span><span>,</span><span>"status"</span><span>:</span><span>401</span><span>}}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>token 是从接口现取的，长度 619，没问题。那就只能是我传的方式不对。试了塞进 query 参数 <code>?token=</code>，401；试了 <code>Authorization: Bearer</code>，还是 401。</p><p>去翻参考实现才看到，真正要传的是<strong>两个</strong>头：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>x-umami-share-token: &lt;JWT&gt;</span></div></div><div><div><div>2</div></div><div><span>x-umami-share-context: 1</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>少一个就是 401。补上第二个，数据立刻回来了：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span><span>"pageviews"</span><span>:</span><span>4741</span><span>,</span><span>"visitors"</span><span>:</span><span>680</span><span>,</span><span>"visits"</span><span>:</span><span>1025</span><span>,</span><span>"bounces"</span><span>:</span><span>532</span><span>,</span><span>"totaltime"</span><span>:</span><span>265264</span><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>startAt=0</code> 表示全站，把它换成今天零点的时间戳，拿到的就是今日数据。四个指标正好对应页脚要的四项：</p>

<table><thead><tr><th>页脚显示</th><th>来源</th></tr></thead><tbody><tr><td>今日访客</td><td>今日 <code>visitors</code></td></tr><tr><td>今日访问</td><td>今日 <code>pageviews</code></td></tr><tr><td>本站访客</td><td>全站 <code>visitors</code></td></tr><tr><td>本站访问</td><td>全站 <code>pageviews</code></td></tr></tbody></table></section>
<section><h2>代码放哪：还是原生注入点<a href="#代码放哪还是原生注入点"><span>#</span></a></h2><p>落点不用纠结，前两篇已经踩过路了——<code>src/config/FooterConfig.html</code>。</p><p>这是 Firefly 自带的页脚 HTML 注入点，<code>Footer.astro</code> 在构建时用 <code>fs</code> 把这个文件读出来，再 <code>set:html</code> 塞进页脚。这个文件在 <code>src/config/</code> 目录下，本来就是留给用户填自定义内容的，改它不算动主题源码，以后合并上游更新也不会冲突。</p><div><div><div></div><div>一个容易踩的细节</div></div><div><p><code>set:html</code> 注入的内容<strong>不经过 Astro 的处理</strong>，所以里面写 <code>&lt;style&gt;</code> 和 <code>&lt;script&gt;</code> 会原样输出、照常生效，不需要额外包装。反过来，<code>Footer.astro</code> 里有一句正则会把 HTML 注释 <code>&lt;!-- --&gt;</code> 整段删掉，所以别在这个文件里用 HTML 注释写正经内容。</p></div></div><p>另外一件事让我省了不少心：<code>&lt;Footer /&gt;</code> 是挂在 <code>#swup-container</code> <strong>外面</strong>的。这意味着开着 Swup 的页面切换不会重新渲染页脚，注入的脚本只在整页加载时跑一次，不用像参考实现那样写一堆防重入判断。</p></section>
<section><h2>实现里的几个决定<a href="#实现里的几个决定"><span>#</span></a></h2><p>整段逻辑就是一个 IIFE，塞在注入的 <code>&lt;script&gt;</code> 里，没有新增任何文件。</p><p><strong>缓存分三层</strong>。分享令牌 24 小时、今日数据 60 秒、全站数据 5 分钟，都落在 localStorage。令牌有效期本来就长，没必要每次刷新都去换；今日数据变得快，60 秒是个折中；全站那几个数字半天不一定动一下，5 分钟足够。</p><p><strong>请求挂在 <code>window.load</code> 之后的 <code>requestIdleCallback</code> 上</strong>。页脚统计再怎么说也是装饰，没道理让它跟首屏抢带宽。</p><p><strong>页面停留时每 90 秒静默刷新一次</strong>。本来第一版是拉一次就不管了，但想想访客在文章页读个十分钟，数字一直不动也挺奇怪的。加了轮询之后还有个副作用：<code>document.hidden</code> 为真时直接跳过，切到别的标签页一次请求都不发。</p><p><strong>拉取失败就整行藏起来</strong>。不显示 0，也不显示「加载中」——页脚里留半截残骸比什么都没有更难看。控制台留一条 warn 方便排查就够了。</p><p><strong>数字是滚上去的</strong>，从 0 滚到目标值，900ms 三次方缓动。系统开了「减少动态效果」的话直接落值，不折腾。</p></section>
<section><h2>跳秒那行：时区写死是有意为之<a href="#跳秒那行时区写死是有意为之"><span>#</span></a></h2><p>运行时间比统计简单得多，但有个细节值得说。</p><p>建站日期我直接取了 <code>siteConfig.siteStartDate</code> 里的值（2026-03-24）。真正要注意的是<strong>怎么解析它</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>new</span><span> </span><span>Date</span><span>(</span><span>"2026-03-24"</span><span>)                     </span><span>// ❌ 按 UTC 午夜解析，东八区变成当天早上 8 点</span></div></div><div><div><div>2</div></div><div><span>new</span><span> </span><span>Date</span><span>(</span><span>"2026-03-24T00:00:00+08:00"</span><span>)      </span><span>// ✅ 明确东八区</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>第一种写法在我的机器上看不出问题，但访客要是在别的时区，算出来的天数就会差一天。把时区写死，不管谁访问都是同一个数字。</p><p>至于「切后台就停表」——<code>setInterval</code> 每秒跑一次本身不重，但没道理让它在看不见的地方一直空转。切回去的时候立刻重算一次，也不会出现停表期间时间对不上的情况。</p></section>
<section><h2>成品<a href="#成品"><span>#</span></a></h2><p>跑起来之后长这样：</p><p></p><figure><img alt="页脚最终效果：运行时间在上，四个统计数字在下" loading="lazy" width="1280" height="400" src="/_astro/image-002.B0qXV1yw_Z8y08N.webp" /><figcaption>页脚最终效果：运行时间在上，四个统计数字在下</figcaption></figure><p></p><p>红色那条是主题色，跟着站点配色走，暗色模式下会自己换。数字用了 <code>tabular-nums</code>，不然滚动的秒数会让整行宽度一直在抖。</p><p>顺手把这次改动也记进了<a href="/blog-changelog/">更新日志</a>（V1.11）。</p></section>
<section><h2>说点代价<a href="#说点代价"><span>#</span></a></h2><p>这套东西确实不需要后端，但它换来的是<strong>分享链接是公开的</strong>。分享 ID 会明文出现在 HTML 里，任何人拿到都能读到你站点的访问量——这本来就是 Umami 分享功能的用途。介意的话去后台把分享删掉就行，页脚那行会自己隐藏。</p><p>另一个是它只读数据、不写数据，所以不必担心开发环境访问页面会污染统计。本地 <code>pnpm dev</code> 打开也能正常看到数字，调试反而更方便了。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/recommend/jietuba/</id>
      <title type="text">换了一圈截图工具，最后留在了截图吧</title>
      <published>2026-09-28T00:00:00.000Z</published>
      <updated>2026-09-28T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/recommend/jietuba/"/>
      <summary type="text">截图吧（jietuba）是 Windows 上免费开源的截图工具，长截图、标注、本地 OCR、钉图、GIF 录制和剪贴板历史全都有，界面 PySide6 + 底层 Rust，用下来不卡也不占资源。</summary>
      <content type="html"><![CDATA[<p>Windows 上的截图软件我换过不少，能留下来的没几个。劝退我的原因基本就三类：功能太少、UI 发卡、后台常驻太重。前两个还能忍，第三个是真受不了——就截个图而已，有必要常年占着我几百 MB 内存吗。</p>
<p>截图吧是这段时间换过来的，用了一阵子，目前没换回去的打算。</p>
<p><a href="https://github.com/1003129155/jietuba" target="_blank">https://github.com/1003129155/jietuba</a></p>
<section><h2>三类劝退，它一个都没踩<a href="#三类劝退它一个都没踩"><span>#</span></a></h2><p>先说清楚我挑截图工具的三条线，不然”好用”两个字没意义。</p>

<table><thead><tr><th>劝退类型</th><th>具体长什么样</th><th>截图吧</th></tr></thead><tbody><tr><td>功能少</td><td>截完只能画个框，长截图没有，OCR 让你自己开网页</td><td>长截图 / OCR / 钉图 / GIF / 扫码 / PDF 导出都在一个程序里</td></tr><tr><td>UI 卡</td><td>选区跟手慢半拍，标注重绘掉帧，多屏不同缩放直接错位</td><td>选区走独立合成层，多屏混合 DPI 无缝截图</td></tr><tr><td>占用高</td><td>为了一个偶尔才用的功能常驻几百 MB</td><td>作者 README 写的是常驻十几 MB 甚至几 MB 级别</td></tr></tbody></table><p>第三条我没拿任务管理器认真测过，只是体感上它属于”你不会注意到它开着”的那类。这个数字你信 README 就好，别信我的感觉。</p></section>
<section><h2>它凭什么让我留下<a href="#它凭什么让我留下"><span>#</span></a></h2><section><h3>功能密度高，但不是堆功能<a href="#功能密度高但不是堆功能"><span>#</span></a></h3><p>一句话概括：把截图这件事的前前后后都接住了。</p><ul>
<li><strong>截图</strong>：区域、窗口、智能窗口识别（含 UI Automation 控件级）、多屏混合 DPI、鼠标指针可以画进图里</li>
<li><strong>长截图</strong>：上下左右四个方向都能滚动拼接，算法走 Rust 写的 <code>longstitch</code></li>
<li><strong>标注</strong>：笔、矩形、椭圆、箭头（9 种杆身和箭头样式）、文字、自动递增编号、荧光笔、马赛克、聚光灯、橡皮擦</li>
<li><strong>OCR</strong>：PP-OCRv6 的 det/rec 小模型，跟着程序包走，<strong>本地跑</strong>，中英日</li>
<li><strong>钉图</strong>：截图钉在屏幕最前，还能继续标注、缩放、再 OCR、翻译</li>
<li><strong>GIF 录制</strong>：录屏 + 录制过程中直接画，导出 GIF 或视频</li>
<li><strong>扫码</strong>：框选一下，图里所有二维码 / 条码一次读完</li>
<li><strong>导出</strong>：多种编码图片 + 高质量 PDF</li>
</ul><p>长截图这块我没做极限测试，横向拼接这种场景我也基本用不上，所以作者说的”毫秒级精准拼接”我不背书。但我日常竖向滚的长图，没拼歪过。</p></section><section><h3>底层是 Rust 干的，不是纯 Python 糊的<a href="#底层是-rust-干的不是纯-python-糊的"><span>#</span></a></h3><p>这点是我决定认真用下去的原因之一。</p><p>界面是 PySide6，但图像处理、剪贴板操作、OCR 这几个容易拖后腿的部分是 Rust 实现，以 wheel 的形式发布：<code>j-gif</code>（GIF 编码）、<code>j-stitch</code>（长图拼接）、<code>j-clipboard</code>（剪贴板底层）、<code>j-ppocr</code>（PP-OCR 识别）。OCR 推理跑在原生线程，不阻塞 UI。</p><p>换句话说，它是 Python 的壳、Rust 的骨头。很多号称轻量的 Python 工具做不到这点。</p></section><section><h3>翻译是唯一要联网的功能<a href="#翻译是唯一要联网的功能"><span>#</span></a></h3><p>除了翻译，其余全部本地运行，无数据收集、无偷偷联网。翻译也是你主动用才调，服务商你自己选：DeepL / Google / Azure / Amazon / 百度 / DeepSeek / 自定义 OpenAI 兼容接口（Ollama、LM Studio 这类都能接）。</p></section></section>
<section><h2>一开始让我膈应的地方：它集成了剪贴板<a href="#一开始让我膈应的地方它集成了剪贴板"><span>#</span></a></h2><p>这个是必须单独说一节的，因为我一开始是真的不想用。</p><p>我偏好干净一点的工具，剪贴板历史这种东西听着就臃肿——我这人连 Ditto 都没装。打开截图吧发现它自带一整套剪贴板管理，第一反应是”又来了”。</p><p>用下来的结论是：它没因为塞了这个功能就变重。</p><p>而且它做的方式有点意思——不只是自己截的图，<strong>你复制的任何文本、图片、文件、HTML 都会自动进同一份剪贴板历史</strong>。可以分组、收藏、搜索，有独立的三栏管理窗口，文本条目支持 CSV 导入导出。</p><p>最关键的一条：<strong>历史记录里的任何一张图，都能一键”钉”回屏幕最前面</strong>，随时拿来对照、缩放、继续标注、重新 OCR。</p><p>这就是它 README 里说的”截图 ⇄ 剪贴板 ⇄ 钉图”三向互通。听起来像宣传语，实际用起来确实省事：截完的图不用另存，复制过的东西不会丢，需要对照的时候从历史里翻出来钉着就行。</p><p>至于嫌烦的人——历史窗口是快捷键调出来的，不是常驻糊在屏幕上，不主动用就看不见。</p></section>
<section><h2>装上就能用<a href="#装上就能用"><span>#</span></a></h2><p>不用装 Python，不用装 Rust，OCR 模型已经在包里。</p><ol>
<li>打开 <a href="https://github.com/1003129155/jietuba/releases/latest" target="_blank">Releases 页</a>，按机器下载 <code>-x64.zip</code> 或 <code>-arm64.zip</code> 结尾的包</li>
<li><strong>整个压缩包解压</strong>，保持 <code>jietuba_pp.exe</code> 和同级的 <code>models/</code> 目录在一起（模型缺了 OCR 就用不了）</li>
<li>双击 <code>jietuba_pp.exe</code></li>
</ol><div><div><div></div><div>未签名</div></div><div><p>程序没有做数字签名，浏览器下载后 Windows 大概率弹 SmartScreen。点”更多信息” → “仍要运行”即可。介意这点的自己权衡。</p></div></div><p>首次启动有个 6 页的引导向导，会把截图、剪贴板这几个主要热键带你设一遍，建议别跳过。</p></section>
<section><h2>几个注意点<a href="#几个注意点"><span>#</span></a></h2><ul>
<li><strong>只有 Windows</strong>（x64 和 ARM64），没有 macOS / Linux 版</li>
<li><strong>免安装 zip，没有安装程序</strong>，升级要自己下新包覆盖，也不会自动更新到最新版（它只做版本检查提示）</li>
<li><strong>翻译要联网</strong>，其余全本地</li>
<li>我写这篇时的最新版本是 2.0.10（2026-09-26），项目更新挺勤的，装之前看一眼 Releases</li>
</ul></section>
<section><h2>适合谁<a href="#适合谁"><span>#</span></a></h2><p>适合：</p><ul>
<li>Windows 用户，截图频率不低</li>
<li>需要长截图、本地 OCR、钉图对照这类”截图之外”的能力</li>
<li>受不了工具常驻吃资源</li>
<li>想要剪贴板历史，但不想再单独装一个 Ditto</li>
</ul><p>不适合：</p><ul>
<li>macOS / Linux 用户（没有）</li>
<li>只想按一下快捷键截个图、其他一概不要的人——它的功能面板对你来说会显得多</li>
</ul></section>
<section><h2>我的判断<a href="#我的判断"><span>#</span></a></h2><p>我不敢说它是”最强”的截图工具，功能多不等于强，很多全能型工具最后都死在臃肿上。</p><p>但截图吧难得的地方在于：功能给得很足，重量却没跟着涨。这跟我之前换掉的那一批正好反过来。</p><p>剪贴板那部分我到现在也说不上”喜欢”，只能说它没拖累主体体验，而钉图 + 剪贴板打通之后确实省了几步操作。这算是我对它的唯一保留意见。</p><p>除此之外，用了一阵子没找到换回去的理由——对我来说这已经是很高的评价了。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/recommend/snow-shot/</id>
      <title type="text">Snow Shot：常驻 13MB 的截图工具，把 PixPin 的会员功能全免费了</title>
      <published>2026-09-28T00:00:00.000Z</published>
      <updated>2026-09-28T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/recommend/snow-shot/"/>
      <summary type="text">Snow Shot 是免费开源的跨平台截图工具，表格识别、文本翻译、LaTeX 公式识别、多语种 OCR 和录屏记键鼠都不需要会员，常驻内存只有 13MB，还带了一个 MCP 接口，AI 能直接替你截图、识别、导出。</summary>
      <content type="html"><![CDATA[<p>我电脑里装着 PixPin，用着挺顺，但有几处一直膈应：表格识别要会员、文本翻译要会员、LaTeX 公式识别要会员、多语种 OCR 也要会员，连录屏记鼠标键盘都锁在会员里。</p>
<p>换成 Snow Shot 之后，这个膈应没了。常驻内存 13MB，PixPin 靠会员收钱的功能，它全免费、全开源、全本地跑。</p>
<p>GitHub 上 5127 个 star，Snow Shot 本体是 GPLv3，主体 C++/Qt，底下的图像和 OCR 部分走 Rust。9 月 28 号刚发 v1.1.6-beta，再往前数，从 22 号的 v1.1.0 到今天，七天推了七个 beta。</p>
<a href="https://github.com/mg-chao/snow-apps" target="_blank"><div><div><div><div></div><div>mg-chao</div></div><div>/</div><div>snow-apps</div></div><div></div></div><div>Snow Apps repository, providing source code for Snow Shot and Snow Image Viewer.</div><div><div>—</div><div>—</div><div>NOASSERTION</div><span>C++</span></div></a>
<section><h2>PixPin 会员区里躺着的功能，它一个不落全给了<a href="#pixpin-会员区里躺着的功能它一个不落全给了"><span>#</span></a></h2><p>先摆账。PixPin 官方文档里明晃晃标着 “会员用户” 的这几样，Snow Shot 这边打开就能用：</p>

<table><thead><tr><th>功能</th><th>PixPin</th><th>Snow Shot</th></tr></thead><tbody><tr><td>表格识别</td><td>会员，截图时按 <code>Shift + Q</code>，只能存 Excel</td><td>免费，识别完能改单元格、合并拆分，导出 Excel 或 Markdown</td></tr><tr><td>文本翻译</td><td>会员，<code>Ctrl + Q</code></td><td>免费，贴图和 OCR 结果上右键就翻，自动识别源语言</td></tr><tr><td>LaTeX 公式识别</td><td>会员，截图时按 <code>Shift + F</code></td><td>免费，截图里的公式直接变成 LaTeX 代码</td></tr><tr><td>多语种 OCR</td><td>会员，简体、繁体、英、日、韩、法、德、西、葡</td><td>免费，模型版本 V4 到 V6 自己挑，精度和速度随你权衡</td></tr><tr><td>录屏记鼠标键盘</td><td>会员，画中画和录后剪辑也一起锁着</td><td>免费，键鼠轨迹、摄像头画中画、录完还能剪</td></tr></tbody></table><p>到这儿你大概会想：不过是把会员功能抄了一遍，能有多好？</p><p>接着往下看，还有几个我觉得更值钱的：</p><ul>
<li><strong>条码 / 二维码识别</strong>：快递单、商品码、WiFi 码，一次扫一整张图</li>
<li><strong>屏幕录制</strong>：选区录屏，键鼠记录、摄像头画中画，录完直接剪</li>
</ul><p>这些全都不用登录、不用扫码、不用绑支付方式。</p></section>
<section><h2>13MB 是个什么概念<a href="#13mb-是个什么概念"><span>#</span></a></h2><p>我挂上 Snow Shot 一天，任务管理器里那个进程稳定在 13MB 上下。同时开着的 PixPin 常年 200MB 起步，时不时窜到 400MB。都是挂着全局热键等我召唤，差出一个数量级。</p><p>它轻，不是阉割出来的轻。OCR 推理由 Rust 写的 <code>rapid-ocr-rs</code> 跑，模型是 PaddleOCR 那套，推理走 ONNX Runtime；截图捕获吃 Windows 的 DXGI / GDI，录屏编码甩给 GPU。作者连 “识别进程要不要常驻” 都做成了开关：不开，用的时候才拉起子进程；开了，模型预热好，OCR 零延迟。</p><p>那 13MB 是不是靠省掉功能换来的？我一开始也这么怀疑。直到我把表格识别、公式识别、录屏挨个跑了一遍——全在，而且都在本地跑。</p><p>PixPin 的核心是闭源的，你看不到它在跑什么。</p></section>
<section><h2>翻译到底要不要 API<a href="#翻译到底要不要-api"><span>#</span></a></h2><p>这个说法网上传得乱，我翻了源码，说准确点。</p><p>翻译走的是 OpenAI 兼容接口，base URL 你可以指向本机。填 <code>http://localhost:11434/v1</code>、模型名写本地跑的那个，整条翻译链路就出不了你的电脑，不用买任何云端 key。</p><p>你要是嫌折腾，接智谱、DeepSeek、通义的兼容 API 也行，作者把选择权交给你了。</p><p>OCR 更彻底：本地 PP-OCR 模型，断网能用，GPU 加速开不开随你。</p><p>补一句判断标准：隐私敏感就选本地翻译 + 本地 OCR，图省事就接云端 API。两条路它都给你留好了。</p></section>
<section><h2>它比 PixPin 多走了一步<a href="#它比-pixpin-多走了一步"><span>#</span></a></h2><p>Snow Shot 自带一个 MCP server，叫 <code>snow-shot-mcp</code>。在 Claude Code、Codex 或任何支持 MCP 的客户端里接上它（先在 <code>设置 → 系统</code> 里打开 MCP），AI 就能直接替你截图、标注、识别、导出。</p><p>意思就是你对着 AI 说 “把这块屏幕截下来，表格提出来”，它就真去干了。截图工具里这是我第一次见。</p></section>
<section><h2>说句公道话<a href="#说句公道话"><span>#</span></a></h2><p>PixPin 的产品打磨是顶级的，UI 细腻，会员制也合理——作者要吃饭。</p><p>但我这种普通用户，截图就是为了识个别字、翻一段英文、偶尔录十秒视频，这些动作 PixPin 全归进会员。我只是偶尔用一次表格识别，为什么得先开会员？</p><p>Snow Shot 的作者 mg-chao 把这些功能用本地模型加开源算法重做了一遍，GPLv3 丢在 GitHub 上，不放心你可以自己编译。</p></section>
<section><h2>它和上个月那个 “截图吧” 不是一回事<a href="#它和上个月那个-截图吧-不是一回事"><span>#</span></a></h2><p>有读者会问：前阵子不是刚写过一个开源截图工具？两个是不同的项目，别搞混。</p><p>那到底该装哪个？看你的日常：天天回头翻旧截图，选<a href="/posts/recommend/jietuba/">截图吧</a>；想录段带键鼠操作的视频给同事，选 Snow Shot。</p><p>截图吧是 PySide6 加 Rust 的路线，219 个 star，强项是剪贴板历史管理；Snow Shot 是 C++/Qt 加 Rust，5127 个 star，强项在录屏记键鼠、表格编辑器和 MCP 接口。</p></section>
<section><h2>怎么才能用上<a href="#怎么才能用上"><span>#</span></a></h2><p>去 <a href="https://github.com/mg-chao/snow-apps/releases/latest" target="_blank">Releases</a> 下最新版，Windows 有三个包：</p><ol>
<li><code>offline.exe</code>（62 MB）——所有 OCR 模型内置，离线环境最稳</li>
<li><code>online.exe</code>（24 MB）——想省空间选它，功能用到再下</li>
<li><code>portable.zip</code>——想塞 U 盘选它</li>
</ol><p>macOS ARM64 用户下 <code>.dmg</code>。</p><p>懒得下也行，命令行一行：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>winget install </span><span>--</span><span>exact </span><span>--</span><span>id mg</span><span>-</span><span>chao.snow</span><span>-</span><span>shot </span><span>--</span><span>source winget</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>官网 snowshot.top 的直连下载暂时在维护，作者让大家走 GitHub Releases 或飞盘。</p><p>PixPin 我已经卸了。你要是也吃过 “这功能怎么又要会员” 的闷亏，记住这一句：Snow Shot 把 PixPin 的会员功能全免费了，常驻只有 13MB，还带 MCP 接口。截图识字、翻英文、提表格、录视频这几件事，它一个软件全包了。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/anime/shakugan-no-shana/</id>
      <title type="text">05 年的《灼眼的夏娜》：三季看完，比现在大多数战斗番还能打</title>
      <published>2026-09-26T00:00:00.000Z</published>
      <updated>2026-09-28T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/anime/shakugan-no-shana/"/>
      <summary type="text">05 年的《灼眼的夏娜》三季看完的观后感。前两季经典到能猜到走向，第三季剧本意外，画风和战斗作画放到今天比大多数战斗番还能打。</summary>
      <content type="html"><![CDATA[<section><h1>05 年的《灼眼的夏娜》：三季看完，比现在大多数战斗番还能打<a href="#05-年的灼眼的夏娜三季看完比现在大多数战斗番还能打"><span>#</span></a></h1><p>先说结论：前两季很经典，第三季挺意外，整体确实写得好。</p><p>看的时候倒是一直很期待夏娜跟悠二能早点把关系挑明。</p><p>结局的处理方式跟我想的不太一样，不过冷静下来想想，这个收尾是合理的，也确实写得不错。</p><section><h2>前两季：经典，但基本能猜到下一步<a href="#前两季经典但基本能猜到下一步"><span>#</span></a></h2><p>第一季和第二季都非常经典。</p><p>经典到什么程度——看到一半大概就能猜到下一步要写什么、大概会发生什么事。</p><p>我个人不太把这个当成缺点。老番的节奏就是这样，一集一个事件，铺垫、冲突、收尾一气呵成。你大概知道接下来会有个红世使徒冒出来，也知道夏娜会打赢，但看的时候不觉得无聊。</p><p>第一季主要在建立世界观。悠二从普通高中生变成”火炬”，零时迷子，火雾战士，红世使徒，这些设定一层层铺开。给夏娜取名、带她去上学这些日常段落，是整部作品最舒服的部分。</p><p>第二季把校园日常写到了极致，然后再让所有人站到路口。转学生、化装舞会的影子、悠二开始不满足于被保护——表面还是日常，底下的暗流已经很急了。</p><p>两季看下来，人物都立住了。夏娜从一台战斗机器慢慢学会情绪和日常，这条线写得比很多同类型作品扎实。</p></section><section><h2>第三季：剧本挺意外，和《巨人》有点不约而同<a href="#第三季剧本挺意外和巨人有点不约而同"><span>#</span></a></h2><p>第三季是真的让我没想到。</p><p>前两季基本在预期内，第三季直接把棋盘掀了。悠二不再是等着被救的那个少年，而是站到了对面，成了化装舞会的盟主，主导一个”创造新世界”的计划。</p><p>这个转向挺狠的，也把整部作品从”打怪”变成了”阵营战争 + 世界观收束”。</p><p>还有就是，这剧本很多地方的母题和《进击的巨人》后期有种不约而同的感觉。</p><p>具体哪些地方像，各人有各人的看法，我不多展开。但”为了族群的存续去找一条活路，代价是把整个世界重写一遍”这种结构，看的时候确实很熟悉。</p><p>顺带一提，第三季收尾的方式，也不是简单的谁打赢谁。</p></section><section><h2>画风和战斗：05 年的番，比近几年不少战斗番还能打<a href="#画风和战斗05-年的番比近几年不少战斗番还能打"><span>#</span></a></h2><p>画风是我很满意的一点。</p><p>这种 05 年的画风能做到这么好看，真的是没得说。起码我个人非常喜欢。</p><p>更难得的是战斗。</p><p>PPT 敷衍这个毛病，其实现在的新番反而更严重。一到关键场面就几张静止帧推拉，配上光效和喊叫，就算打完了。</p><p>《灼眼的夏娜》完全没有这个问题。打起来是真的在动，招式、火焰、封绝的空间感都做得挺认真。</p><p>说实话，这么早的番，战斗表现能比近几年的大多数战斗番还要优秀得多。</p><p>这可能也是我到现在还愿意看老番的原因。</p></section><section><h2>结局：没烂尾，只是跟我想的不太一样<a href="#结局没烂尾只是跟我想的不太一样"><span>#</span></a></h2><p>回到开头那句话。</p><p>期待夏娜跟悠二挑明关系，期待了很久。真到了结局，走向跟我预期的不太一样。</p><p>但要说结局差，也不对。</p><p>它不是烂尾，也不是敷衍。该收的线都收了，人物的动机也站得住，该给的交代都给了。</p><p>可能只是我期待的方向，和作品想给的方向不太一样。</p></section><section><h2>老番的好处<a href="#老番的好处"><span>#</span></a></h2><p>这种老番还是比较有意思的。</p><p>都是带你进入一个个精彩的世界。</p><p>现在的番很多是把你留在现实里，老番是把你拉走。设定、世界、规则、代价，一整套都给你搭好，你进去待上一整季，出来的时候会有点舍不得。</p><p>反过来看现在的新番，路子基本就两条：要么画几个好看的角色，剩下的全靠你自己磕；要么干脆就是厕纸。愿意认真搭一个世界的，反而是少数。</p><p>《灼眼的夏娜》就是这类作品里比较典型的一个。</p></section><section><h2>男主扛着巨大责任，得放回那个年代看<a href="#男主扛着巨大责任得放回那个年代看"><span>#</span></a></h2><p>很多人对老番都有个印象：男主设定是不是有点俗。</p><p>悠二就是最常见的那个模板。普通高中生，体内藏着零时迷子，稀里糊涂被卷进两个世界的战争，最后自己一个人把整个世界扛了起来。</p><p>《反叛的鲁路修》的鲁鲁修，《死亡笔记》的夜神月，基本一个模子：十几岁的少年，手里握着能改变世界的东西，然后独自去做那个最重的决定。</p><p>用今天的眼光看，这种“少年扛起巨大责任”的框架确实容易让人皱眉。</p><p>但我从来不觉得老番俗。</p><p>我一直认为，不能拿今天的眼光去评价老番。社会环境不一样，甚至不同时期人的价值观都不一样。今天觉得老套的东西，很可能就是当年最潮、最有分量的东西。</p><p>那几年，让一个十几岁的少年去决定世界的走向，是能成立的，也是能打动人的。后来的作品把套路重复烂了，这个账不该算在老番头上。</p><p>《灼眼的夏娜》在这一点上也站得住。悠二不是喊两句口号就去拯救世界，他是先被保护、被瞒着、被当成一个需要看管的火炬，才慢慢走到“我自己来”这一步。到了第三季，代价摆得明明白白。</p><p>反过来讲，如果我今年看到一部新番还是这么写，又没写出这个重量压在哪、代价是什么，我确实也会嫌它俗。</p><p>但这是拿今年的标准去要求今年的作品，跟二十年前那批老番没关系。尺子是跟着时代走的，不能拿现在的尺子去量当年。</p><p>它第一季是 2005 年的，跟我同龄。</p><p>但 2005 年到现在也 20 多年了。说是同龄，它其实已经是很老的一批。</p><p>鲁鲁修、死亡笔记也都在那前后一两年。这一批大概就是那几年的经典了，后来我们在新番里看到的很多套路，都是从它们这儿来的，哈哈哈。</p></section><section><h2>集数够，配角才立得住<a href="#集数够配角才立得住"><span>#</span></a></h2><p>三季加起来七十多集，篇幅是它占的最大便宜之一。</p><p>集数够最直观的好处是：不只主角能讲清楚，各种配角的支线和故事也都有地方去铺。</p><p>吉田一美、佐藤、田中、玛琼琳、威尔艾米娜，这些人不只是站在主角旁边喊加油的工具人，各自都有自己的想法、自己的选择，也不全围着主角转。</p><p>人物一多、戏份一足，形象和性格自然就鲜明起来了。</p><p>想起之前看《AIR》的时候，一共才 12 集，中间还要塞进去一段主角前代的故事，看完就是一个感觉：太紧了。</p><p>不是说它做得不好，是那个篇幅真的撑不起那么多东西。对比之下，《灼眼的夏娜》集数给够了，看得就舒服得多。</p><hr /></section><section><h2>以下开始剧透<a href="#以下开始剧透"><span>#</span></a></h2><p>下面按季整理了三季的剧情脉络和关键设定，是我看的时候顺手记的笔记。</p><p>每季单独折叠，<strong>想看哪季点哪季</strong>，而且每一季都做了隔离——<strong>不会透露下一季的任何走向</strong>，按顺序点开也不会被剧透到后面。没看完的别乱点。</p>
第一季（2005，24 集）｜相遇、入局与世界观建立
<div><p><strong>一句话概括</strong>：普通高中生坂井悠二遇见火雾战士夏娜，得知自己早已死亡、只是"火炬"，并在红世使徒的袭击中逐步卷入两个世界的战争。</p><p><strong>这一季在做什么</strong>：把世界观和人物关系立起来——红世是什么、存在之力怎么被消耗、悠二为什么还算"活着"、夏娜为什么在战斗。看完这一季，后面所有人的动机都才对得上。</p><p><strong>主线脉络</strong></p><ul>
<li><strong>相遇与真相</strong>：悠二被卷入红世使徒吃人现场，被夏娜所救。夏娜展开"封绝"，告知悠二他早已被吞噬，现在的他只是替代品"火炬"。</li>
<li><strong>零时迷子</strong>：悠二体内寄宿着宝具"零时迷子"，因此不会像普通火炬一样熄灭，成为红世各方争夺的目标。</li>
<li><strong>法利亚格尼篇</strong>：红世之王"猎人"法利亚格尼为复活恋人玛丽安，利用宝具"幸福之钟"收集存在之力。夏娜与悠二首次直面强大使徒。</li>
<li><strong>同伴与日常</strong>：悠二给夏娜取名"夏娜"，带她体验学校生活。吉田一美、佐藤、田中等同学进入故事。玛琼琳·朵（悼文吟诵人）登场，与夏娜并肩作战。</li>
<li><strong>爱染他篇与最终战</strong>：红世之王"爱染他"蒂丽亚制造"摇篮花园"，企图吞噬城市。夏娜与悠二在决战中确认彼此的重要性。</li>
<li><strong>结局</strong>：夏娜在战斗前对悠二说出"我喜欢你"，但悠二没有听见。化装舞会开始关注悠二体内的零时迷子。</li>
</ul><p><strong>这一季要记住的设定</strong></p><ul>
<li><strong>红世</strong>：异世界，其居民"徒"需消耗存在之力。</li>
<li><strong>存在之力</strong>：维持世界与存在的能量。</li>
<li><strong>火炬 / 密斯提斯</strong>：被吞噬后留下的残渣；密斯提斯是体内藏有宝具的火炬。</li>
<li><strong>火雾战士</strong>：人类与红世之王契约后诞生的讨伐者。</li>
<li><strong>零时迷子</strong>：悠二体内的宝具，每天零点恢复存在之力。</li>
</ul><p><strong>留下的伏笔</strong></p><ul>
<li>夏娜从"战斗机器"逐渐学会情感与日常。</li>
<li>悠二决心不再只被保护。</li>
<li>零时迷子，以及化装舞会投过来的视线，在这一季末留了下来。</li>
</ul></div>

第二季（2007，24 集）｜日常下的暗流与抉择
<div><p><strong>一句话概括</strong>：在看似平静的校园日常下，零时迷子的秘密、"化装舞会"的阴影和"彩飘"菲蕾丝的出现，把悠二与夏娜推向必须做出选择的十字路口。</p><p><strong>这一季在做什么</strong>：把校园日常推到极致，再让每个人站到路口。这一季的张力几乎全来自"表面还是日常、底下全是暗流"——转学生到底是谁、菲蕾丝要拿零时迷子做什么、悠二想变成什么样。</p><p><strong>主线脉络</strong></p><ul>
<li><strong>校园日常与近卫史菜</strong>：转学生近卫史菜出现，她与化装舞会巫女黑卡蒂极为相似。她融入学校生活，与夏娜、吉田等人产生交集，成为全季最大谜团。</li>
<li><strong>菲蕾丝与零时迷子</strong>：红世之王"彩飘"菲蕾丝为夺回零时迷子而来。零时迷子原与"约定的两人"——约翰和菲蕾丝有关。约翰作为火炬逐渐消散，菲蕾丝想用零时迷子救他。</li>
<li><strong>吉田、佐藤、田中的选择</strong>：吉田一美对悠二感情加深，并从菲蕾丝处获得力量或宝具，主动选择参战。佐藤和田中接触玛琼琳，开始思考普通人类能否介入火雾战士的战斗。</li>
<li><strong>悠二的变化</strong>：悠二不再满足于被保护，开始锻炼自在法，希望变强并带夏娜离开城市。他一度释放出银色火焰，暗示体内藏着远超零时迷子的异常力量。</li>
<li><strong>史菜真相与结尾</strong>：近卫史菜最终被揭示为与黑卡蒂、化装舞会密切相关的存在，消失或回归。圣诞夜，悠二被化装舞会带走，夏娜无力阻止。</li>
</ul><p><strong>这一季要记住的设定</strong></p><ul>
<li><strong>零时迷子</strong>：与"约定的两人"有关，菲蕾丝想用它救约翰。</li>
<li><strong>约定的两人</strong>：约翰与菲蕾丝，零时迷子原主。</li>
<li><strong>化装舞会</strong>：红世使徒组织，巫女黑卡蒂，本季最大威胁的来源。</li>
<li><strong>银色火焰</strong>：悠二体内异常力量的暗示。</li>
</ul><p><strong>留下的伏笔</strong></p><ul>
<li>第二季把校园日常写到极致，再让所有角色站到选择路口。</li>
<li>悠二"想改变"的执念贯穿全季，他不想再只做一个被保护的人。</li>
<li>圣诞夜悠二被带走，故事停在这里。</li>
</ul></div>

第三季 FINAL（2011，24 集）｜阵营战争与世界观收束
<div><p><strong>一句话概括</strong>：悠二主动与创造神祭礼之蛇融合，成为化装舞会盟主，试图创造使徒不必吃人的新世界"无何有境"；夏娜与火雾战士为阻止并改写这个计划而战。</p><p><strong>这一季在做什么</strong>：从"打怪"彻底变成阵营战争，同时完成世界观的收束——前面两季攒下的伏笔，都在这里给出交代。</p><p><strong>主线脉络</strong></p><ul>
<li><strong>悠二回归与化装舞会</strong>：圣诞夜失踪后，悠二与红世创造神"祭礼之蛇"融合，成为其代行体，并成为化装舞会新盟主。他不再是等待救援的少年，而是敌方领袖。</li>
<li><strong>无何有境计划</strong>：祭礼之蛇的权能是"造化与确定"。悠二计划创造新世界"无何有境"，让红世之徒迁入，获得充足存在之力，从而不再需要啃食人类。他本意是间接终结战争，而非直接禁止吃人。</li>
<li><strong>火雾战士的抵抗</strong>：夏娜、亚拉斯特尔、玛琼琳、威尔艾米娜等火雾战士联合进攻化装舞会，试图阻止世界创造。但最终无法阻止"无何有境"诞生。</li>
<li><strong>夏娜打入法理</strong>：在世界之卵完成前，夏娜用"真红"击中世界之卵，强行写入一条法理——<strong>新世界中的徒无法啃食人类</strong>。祭礼之蛇本可消除这条规则，但听到使徒们接受"不吃人也没关系"，最终选择保留。</li>
<li><strong>结局与双赢</strong>：无何有境完成，使徒迁入，部分火雾战士进入新世界监督。夏娜与悠二决战并和解，悠二一意孤行的"赎罪式保护"被夏娜否定——她要求一起承担。拉米的帮助或礼物让悠二摆脱火炬命运，能够与夏娜一起走向新世界。菲蕾丝与约翰的孩子"两界嗣子"被威尔艾米娜带入新世界；引导神沙哈尔的眷属将这一新事物广播给所有徒。</li>
</ul><p><strong>这一季要记住的设定</strong></p><ul>
<li><strong>祭礼之蛇</strong>：红世创造神，权能"造化与确定"。</li>
<li><strong>无何有境</strong>：为使徒创造的新世界。</li>
<li><strong>世界之卵</strong>：创造新世界的核心术式。</li>
<li><strong>两界嗣子</strong>：菲蕾丝与约翰融合诞生的新个体，证明徒与人类可有非捕食关系。</li>
<li><strong>引导神沙哈尔</strong>：权能"唤起与传达"，通过神谕向所有徒广播新事物。</li>
</ul><p><strong>主题与争议点</strong></p><ul>
<li>悠二并非想直接创造"不吃人世界"，而是想用充足存在之力让使徒"不需要吃人"。</li>
<li>"徒无法啃食人类"是夏娜一方强行注入的法理，不是悠二原计划。</li>
<li>结局并非单纯胜负，而是火雾战士、使徒、新世界三方妥协后的双赢。</li>
<li>夏娜与悠二的核心矛盾：悠二想独自赎罪，夏娜拒绝被排除在外。</li>
</ul></div>
<hr /></section><section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>《灼眼的夏娜》算是我这轮补老番里比较有代表性的一部。</p><p>它不完美，第三季的收尾也不是那种让人拍桌叫好的类型，但它把该讲的故事讲完了。世界观、战斗作画、人物成长这几项都完成了，二十年后回头看依然不觉得过时。</p><p>之前写的<a href="/posts/anime/zero-no-tsukaima/zero-no-tsukaima/">《零之使魔》观后感</a>和<a href="/posts/anime/oreimo/oreimo/">《俺妹》观后感</a>也是同一批补的，都是那个年代的作品，感兴趣可以一起看。</p><p></p><figure><img alt="《灼眼的夏娜》三季观后感题图" loading="lazy" width="2048" height="1152" src="/_astro/image-002.CzNyyCXU_ILrnp.webp" /><figcaption>《灼眼的夏娜》三季观后感题图</figcaption></figure><p></p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/tailscale-magicdns/</id>
      <title type="text">不用域名不用反代，Tailscale 让 NAS 地址变成「设备名:端口」</title>
      <published>2026-09-24T00:00:00.000Z</published>
      <updated>2026-09-24T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/tailscale-magicdns/"/>
      <summary type="text">家里几台主机跑着一堆 Docker 服务，IP 和端口根本记不住。用 Tailscale 默认开启的 MagicDNS，浏览器直接输「设备名:端口」就能访问，零配置零成本，顺便对比了一下 ZeroTier。</summary>
      <content type="html"><![CDATA[<p>如果你家里有两三台小主机，每台上面跑着五六个 Docker 服务，你大概率遇到过这个场景：想打开某个服务的时候，得先回忆——这个服务跑在哪台机器上？那台机器的 IP 是什么来着？端口又是多少？</p>
<p>我之前用 <a href="/posts/network/ipv6-cloudflare-lucky/">lucky 做反代</a>，规则设得挺规整，但真到用的时候根本记不住，每次都得翻规则列表。直到我把 Tailscale 的一个默认功能用起来，才发现访问 NAS 地址可以简单到 <code>o:5000</code> 这种程度——就两个字符加端口号，浏览器直接打开群晖 DSM。</p>
<p>今天把这个「魔法」说清楚。</p>
<section><h2>MagicDNS：默认就开着的开关<a href="#magicdns默认就开着的开关"><span>#</span></a></h2><p>不卖关子，这个魔法叫 <strong>MagicDNS</strong>，Tailscale 默认开启，不需要你做任何额外设置。</p><p>只要你的设备都装了 Tailscale 并加入了同一个网络（官方叫 tailnet），浏览器里直接用「设备名&lt;端口&gt;」就能访问对应服务。我的几台机器是这么命名的：</p><ul>
<li>群晖叫 <code>o</code>，访问 DSM 就是 <code>o:5000</code></li>
<li>飞牛主机叫 <code>f</code>，进 fnOS 桌面是 <code>f:5666</code></li>
<li>拾光坞 N3 叫 <code>n3</code>，上面的 PanSou 搜索是 <code>n3:8822</code></li>
</ul><p></p><figure><img alt="浏览器地址栏输入 n3 直接打开拾光坞上的 PanSou 搜索" loading="lazy" width="1080" height="452" src="/_astro/image-001.jB4In5QG_jre4S.webp" /><figcaption>浏览器地址栏输入 n3 直接打开拾光坞上的 PanSou 搜索</figcaption></figure><p></p><p></p><figure><img alt="浏览器地址栏输入 o 直接打开群晖 DSM，系统状况一切正常" loading="lazy" width="1080" height="612" src="/_astro/image-002.CVg_rnlv_9NJXC.webp" /><figcaption>浏览器地址栏输入 o 直接打开群晖 DSM，系统状况一切正常</figcaption></figure><p></p><p>没有域名、没有证书、没有端口转发，甚至不用记 IP。设备名就是主机名，规律一句话就能记住：<strong>什么设备、什么端口，就访问什么</strong>。</p><p>Tailscale 免费额度给 100 台设备，对个人用户来说等于不设上限——官方明显没打算从普通消费者身上赚钱。</p><p>它的原理是什么？Tailscale 会给你的网络分配一个私有域名（形如 <code>xxx.ts.net</code>），每台设备是这个域名下的子域名。浏览器里输「设备名&lt;端口&gt;」时，Tailscale 客户端在本地把设备名解析成那台机器在虚拟内网里的 <code>100.x.x.x</code> 地址，请求直接走过去。整个过程不需要你碰 DNS 记录，也不用装任何插件。</p></section>
<section><h2>名字太长？自己改<a href="#名字太长自己改"><span>#</span></a></h2><p>默认设备名比较长（比如我的群晖叫 <code>oecnas</code>），想压缩成一个字母也很简单：</p><ol>
<li>打开 <a href="https://login.tailscale.com/" target="_blank">Tailscale 管理后台</a>的设备列表，找到那台机器</li>
<li>点右边三个点，选第一项 <strong>Edit machine name</strong></li>
<li>取消勾选「自动生成名称」，填入你想要的短名字，保存，立刻生效</li>
</ol><p></p><figure><img alt="Tailscale 管理后台设备菜单，第一项是 Edit machine name" loading="lazy" width="1080" height="244" src="/_astro/image-003.COV2P7OO_Z1Mew3N.webp" /><figcaption>Tailscale 管理后台设备菜单，第一项是 Edit machine name</figcaption></figure><p></p><p>我的命名规则是：飞牛 = <code>f</code>、OEC 群晖 = <code>o</code>、拾光坞 N3 = <code>n3</code>。改完之后，<code>f:5666</code> 打开飞牛桌面，<code>f:5244</code> 打开上面的 OpenList，一台机器上多个服务全靠端口区分，浏览器历史记录里一看就懂。</p><p></p><figure><img alt="浏览器地址栏输入 f 打开飞牛 fnOS 桌面" loading="lazy" width="1080" height="574" src="/_astro/image-004.HNVIhA4O_12aWMn.webp" /><figcaption>浏览器地址栏输入 f 打开飞牛 fnOS 桌面</figcaption></figure><p></p></section>
<section><h2>和 ZeroTier 比，各有什么讲究<a href="#和-zerotier-比各有什么讲究"><span>#</span></a></h2><p>我是先折腾的 ZeroTier，没走通才换的 Tailscale。最近 ZeroTier 改版了，界面干净大气，组网一次成功，免费额度限 10 台设备。</p><p>两家在易用性上其实不分伯仲，但思路不一样：</p>

<table><thead><tr><th></th><th>ZeroTier</th><th>Tailscale</th></tr></thead><tbody><tr><td>识别方式</td><td>一串网络 ID，设备申请加入，管理员审核放行</td><td>每台设备登录同一个 Google/微软账号（NAS 端可以用 auth key），身份即账户</td></tr><tr><td>免费额度</td><td>10 台设备</td><td>100 台设备</td></tr><tr><td>MagicDNS</td><td>没有直接对应的东西</td><td>默认开启，「设备名&lt;端口&gt;」直接访问</td></tr></tbody></table><p>如果你只有 NAS、电脑、手机这三四台设备，ZeroTier 的 10 台额度够用，界面也舒服；设备一多，Tailscale 的 100 台额度就没对手了。</p><p>不过先别急着冲——这类打洞组网方案最好用的前提，是你家宽带本身有 IPv6 公网。打洞成功时流量是点对点直连，速度拉满；打洞失败会走官方中继服务器，速度就全看中继质量了。有 IPv6 的环境下，速度、安全、便捷三项能同时占全。</p></section>
<section><h2>什么场景下它比域名反代更香<a href="#什么场景下它比域名反代更香"><span>#</span></a></h2><p>总结一句话：<strong>多台主机 × 多个 Docker 服务 × 自己日常管理</strong>，这个组合下 MagicDNS 是无敌的。</p><p>域名反代方案当然能做更精细的控制（比如给外人分享链接），但那是「发布服务」的场景，我写过 <a href="/posts/network/ipv6-cloudflare-lucky/">Cloudflare + Lucky</a> 和 <a href="/posts/network/lucky-esa-reverse-proxy/">Lucky + 阿里云 ESA</a> 两篇，都是干这个用的。自己日常管理、在家在外面随时打开某个服务看一眼，「设备名&lt;端口&gt;」的记忆成本几乎为零——你不用维护任何规则列表，加一台新设备、起一个新容器，地址自然就通了。</p><p>对我来说，lucky 那套反代规则还留着对外发布用，但日常访问早就全部切到这种方式了。</p></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><ol>
<li>所有设备装 Tailscale，登录同一账号加入 tailnet</li>
<li>浏览器直接输「设备名&lt;端口&gt;」——MagicDNS 默认就开着，这步其实不用做任何事</li>
<li>名字太长就去管理后台 Edit machine name 改短</li>
<li>家里宽带最好有 IPv6 公网，保证打洞成功走直连</li>
</ol><p>你要是也在用 Tailscale，现在就可以打开浏览器试试——这个开关默认就是开的，你可能一直都不知道。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/momentsmix/</id>
      <title type="text">给朋友圈加了层来源配额：本站 3 成、Blog 4 成、社区 3 成</title>
      <published>2026-09-12T00:00:00.000Z</published>
      <updated>2026-09-12T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/momentsmix/"/>
      <summary type="text">朋友圈那条时间线被博友圈、八零圈、BlogsClub 这几个聚合站霸榜了。我一开始以为是随机池的比例没配好，结果数完发现池子本身就是 6:4——真正的问题在排序。给时间线加了一层按标签的配额和交错排列，顺手把每个来源的取文上限从 6 篇提到 12 篇。</summary>
      <content type="html"><![CDATA[<p><a href="/posts/blog/friendsmoments/">朋友圈</a>上线才几天，我自己刷着刷着就发现不对劲：那条时间线翻下去，前排几乎全是博友圈、八零圈、BlogsClub 这几个聚合站，我自己和几个真正在写博客的朋友被挤到后面几页去了。</p>
<p>上一篇写朋友圈的时候我压根没管展示顺序，就是所有来源合流、按发布时间倒序一把排下来。当时觉得挺合理——朋友圈嘛，谁更新得近谁在前。</p>
<p>真跑起来才发现这套逻辑有个硬伤：<strong>聚合站的更新频率是个人博客的几十倍。</strong> 博友圈那种站点天天在爬别人的新文章，时间永远是新鲜的；而霞の葉間 上一次发文还是五月底。按时间排，聚合站天然霸榜。</p>
<section><h2>先怀疑随机池，结果不是它的锅<a href="#先怀疑随机池结果不是它的锅"><span>#</span></a></h2><p>我第一反应是「随机推荐的比例没配好」。毕竟页面顶部那张「随机一篇文章」的卡片就是随机抽的，所以我先去把随机池数了一遍：</p>

<table><thead><tr><th>标签</th><th>友链</th><th>篇数</th><th>占比</th></tr></thead><tbody><tr><td>Blog</td><td>折腾进行时（本站）、夏夜流萤、霞の葉間、UpXuu、朝朝听雨</td><td>30</td><td>62.5%</td></tr><tr><td>博客社区</td><td>博友圈、八零圈、BlogsClub</td><td>18</td><td>37.5%</td></tr></tbody></table><p>池子一共 48 篇，<strong>本身就是 6&lt;4&gt;</strong>，跟我想配的比例几乎一样。随机卡片那边根本没问题。</p><p>问题在排序。我把时间线按展示顺序拉出来一看，第一页 20 篇里 14 篇是社区，<strong>70%</strong>。这就对上了——我看到的「社区占比过大」，是排序排出来的观感，不是数据分布。</p><div><div><div></div><div>这轮最有价值的一条</div></div><div><p>看到「占比不对」，别急着调权重。先分清是<strong>池子的分布</strong>不对，还是<strong>展示的顺序</strong>不对。这两件事的修法完全不同。</p></div></div></section>
<section><h2>定配比：本站 3 成、Blog 4 成、社区 3 成<a href="#定配比本站-3-成blog-4-成社区-3-成"><span>#</span></a></h2><p>想清楚之后配比就好定了。我把自己也加进了友链（<code>siteurl</code> 指向本站 + <code>rss: "/rss.xml"</code>），所以时间线里其实有三类东西：我自己的、带 <code>Blog</code> 标签的友链、带 <code>博客社区</code> 标签的聚合站。</p><p>那就把本站单独拎出来算一组，剩下按标签分：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>mix</span><span>: [</span></span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>{ </span><span>key</span><span>:</span><span> </span><span>"own"</span><span>, </span><span>weight</span><span>:</span><span> </span><span>30</span><span> },</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>{ </span><span>key</span><span>:</span><span> </span><span>"Blog"</span><span>, </span><span>weight</span><span>:</span><span> </span><span>40</span><span> },</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>{ </span><span>key</span><span>:</span><span> </span><span>"博客社区"</span><span>, </span><span>weight</span><span>:</span><span> </span><span>30</span><span> },</span></div></div><div><div><div>5</div></div><div><span>],</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>own</code> 是保留字，按 <code>siteConfig.site_url</code> 匹配；其余的值直接按友链的 <code>tags</code> 匹配，数组顺序同时决定优先级。</p></section>
<section><h2>第一步：先把取文上限提上去<a href="#第一步先把取文上限提上去"><span>#</span></a></h2><p>第一个坑来得很快。</p><p>抓取逻辑里我写死了「每个友链最多取 6 篇」。这在原来的设计里没毛病，反正所有来源一视同仁。但配上比例就出事了：<strong>本站只有一个源，最多只能贡献 6 篇。</strong> 40 篇里要占 3 成是 12 篇，6 篇怎么凑都凑不出来。</p><p>我顺手数了一遍各家的供给量——本站 RSS 里有 44 篇可用，根本不是没货，是我自己把口子卡住了。</p><p>所以把上限提到 12，并且让它从配置里读，不再写死在代码里。</p></section>
<section><h2>第二步：排序不能是纯时间倒序<a href="#第二步排序不能是纯时间倒序"><span>#</span></a></h2><p>这是这轮唯一一次方案翻车。</p><p>第一版我想得很简单：按比例分配名额、各组取自己最新的几篇、最后<strong>整体按时间倒序</strong>排一下。听起来没毛病，总数也能对上，可第一页还是被聚合站霸占。</p><p>原因很直白：<strong>配额只约束了总量，约束不了顺序。</strong> 社区组总共就 12 篇，但博友圈那几篇时间最新，倒序一排全跑到最前面去了。总量对了，观感照旧。</p><p>第二版换成<strong>按配比交错</strong>：每一步都挑「当前实际落后目标比例最多」的那一组，取它的下一篇。组内仍然是新的在前，组与组之间是均匀摊开的。</p><p></p><figure><img alt="朋友圈时间线按来源配比交错排列的示意" loading="lazy" width="1600" height="540" src="/_astro/image-001.VnAiMg8W_ZKGfOK.webp" /><figcaption>朋友圈时间线按来源配比交错排列的示意</figcaption></figure><p></p><p>排出来基本是个三拍循环，中间偶尔连出两个 Blog 把 40% 凑够：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Blog 本站 社区 │ Blog 本站 社区 │ Blog 本站 社区 │ Blog Blog 本站 社区 │ Blog 本站 社区 │ Blog 本站 社区 │ Blog</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>第三步：顺手删掉一段旧逻辑<a href="#第三步顺手删掉一段旧逻辑"><span>#</span></a></h2><p>改到一半发现页面顶上那张随机卡片里，我自己埋过一段「20% 概率优先抽本站」的代码。</p><p>当时的想法是给自己一点曝光。但现在配比已经在池子里分好组了，这段逻辑等于在配置之上又叠了一层倾斜——本站实际命中率被压到 20%，跟配置里的 3 成对不上。</p><p>同一个地方叠两层倾斜，最后谁大谁小根本算不清。删掉，让随机卡片老老实实在配好的池子里等概率抽。</p></section>
<section><h2>最后看看效果<a href="#最后看看效果"><span>#</span></a></h2><p>配比全部收在新加的一个配置文件里（只新增文件，没碰主题源码）：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>momentsFeedConfig</span><span>:</span><span> </span><span>MomentsFeedConfig</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>// 时间线展示多少篇。页面每 20 篇一页，填 40 正好两页</span></div></div><div><div><div>3</div></div><div><span>  </span><span>displayLimit</span><span>:</span><span> </span><span>40</span><span>,</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>// 每个友链最多抓几篇。本站只有一个源，这个值太小就凑不出 3 成</span></div></div><div><div><div>6</div></div><div><span>  </span><span>maxItemsPerFriend</span><span>:</span><span> </span><span>12</span><span>,</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>  </span><span>mix</span><span>:</span><span> [</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>{ </span><span>key</span><span>:</span><span> </span><span>"own"</span><span>, </span><span>weight</span><span>:</span><span> </span><span>30</span><span> },</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>{ </span><span>key</span><span>:</span><span> </span><span>"Blog"</span><span>, </span><span>weight</span><span>:</span><span> </span><span>40</span><span> },</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>{ </span><span>key</span><span>:</span><span> </span><span>"博客社区"</span><span>, </span><span>weight</span><span>:</span><span> </span><span>30</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>
</div></div><div><div><div>14</div></div><div><span>  </span><span>layout</span><span>:</span><span> </span><span>"balanced"</span><span>,</span></div></div><div><div><div>15</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>

<table><thead><tr><th></th><th>本站</th><th>Blog</th><th>博客社区</th></tr></thead><tbody><tr><td>整体（40 篇）</td><td>12（30%）</td><td>16（40%）</td><td>12（30%）</td></tr><tr><td>第 1 页（20 篇）</td><td>6（30%）</td><td>8（40%）</td><td>6（30%）</td></tr><tr><td>第 2 页（20 篇）</td><td>6（30%）</td><td>8（40%）</td><td>6（30%）</td></tr></tbody></table><p>每一页都是 3:4&lt;3&gt;，翻页不会突然变成聚合站专场。</p><p><code>layout</code> 我留了三个值，想改回纯时间倒序就把 <code>balanced</code> 换成 <code>chronological</code>——配比还在，代价是第一页又会变回聚合站霸榜，<code>shuffle</code> 则是完全打乱。</p></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><p><strong>一、聚合站天然的「新鲜度优势」，是任何时间排序都绕不过去的。</strong>
只要有站点在频繁聚合别人的文章，按时间排它一定占前排。要么接受，要么改成配额制，没有第三条路。</p><p><strong>二、配额是上限，不是保底。</strong>
某一组文章不够，空出来的名额会补给其他还有余量的组，不会凭空造文章。所以哪天本站没更新，那天的实际比例就会偏，这是预期行为，不是 bug。</p><p><strong>三、删掉自己写的旧逻辑比想象中难。</strong>
见上面第三步。功能越叠越多的时候，最该检查的就是「我是不是在同一个地方倾斜了两次」。</p></section>
<section><h2>还没解决的<a href="#还没解决的"><span>#</span></a></h2><p>现在的组内策略是「取最新的 N 篇」，所以 Blog 组那 16 篇名额被更新勤快的站占满了。表现是：<strong>夏夜流萤、霞の葉間 因为几个月没更新，一篇都没进时间线</strong>，8 个来源实际只出现 6 个。</p><p>这算不算问题其实见仁见智——朋友圈本来就是看「最近谁写了什么」，几个月没动静的站被挤出去也说得过去。但如果你希望每个友链都能露脸，那就得把组内挑选从「按时间取最新」换成「按友链轮询」，每站轮流上。</p><p>先跑一阵子看看，不急。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/simpfunterraria/</id>
      <title type="text">用简幻欢开泰拉瑞亚模组服，顺便记几个终端不显示的坑</title>
      <published>2026-09-12T00:00:00.000Z</published>
      <updated>2026-09-12T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/simpfunterraria/"/>
      <summary type="text">在简幻欢上用 TModLoader 开一个带模组的泰拉瑞亚服务器：从 Steam 提取服务端、上传模组整合包到改启动脚本的完整流程，附创建世界时终端漏显示字段的对照表。</summary>
      <content type="html"><![CDATA[<p>一直想开个泰拉瑞亚模组服，又懒得去折腾公网和端口转发，最后选了简幻欢。面板上有现成的 Terraria 镜像，实例类别选 Terraria、服务端选 TModLoader 就能建出来，但建出来的服务端是纯净的——想玩模组，得自己把本地 Steam 上的 TModLoader 服务端文件搬上去，再改启动脚本把模组目录指过去。</p>
<p>流程不复杂，坑都在细节里：启动脚本要补一行运行时，模组和配置得放进指定目录，另外创建世界那一步终端会漏显示几个字段。这篇按我操作的顺序记一遍。</p>
<section><h2>准备工作<a href="#准备工作"><span>#</span></a></h2><ul>
<li>一个简幻欢账号（<a href="https://simpfun.cn/auth?type=register&amp;code=287905953" target="_blank">simpfun.cn</a> 注册）</li>
<li>本地装好 TModLoader（Steam 免费下载）</li>
<li>要玩的模组在本地先启用好</li>
</ul></section>
<section><h2>第一步：注册账号、新建实例<a href="#第一步注册账号新建实例"><span>#</span></a></h2><section><h3>注册账号<a href="#注册账号"><span>#</span></a></h3><p>前往简幻欢官网 <a href="https://simpfun.cn/auth?type=register&amp;code=287905953" target="_blank">simpfun.cn</a> 注册一个账号，跟着官网指引走完就行。</p></section><section><h3>新建实例<a href="#新建实例"><span>#</span></a></h3><p>进入官网点进控制台，然后新建实例：基础镜像，实例类别选 <strong>Terraria</strong>，服务端选 <strong>TModLoader</strong>，版本选最新版本，最后确认创建。</p></section></section>
<section><h2>第二步：提取 TModLoader 服务端文件<a href="#第二步提取-tmodloader-服务端文件"><span>#</span></a></h2><p>打开 Steam，下载游戏 <strong>TModLoader</strong>。在 Steam 左侧游戏列表右键 → 管理 → 浏览本地文件，运行一次 <code>start-tModLoaderServer.bat</code>。</p><p>第一个提示输入 <code>n</code>，走到选择世界的步骤时直接关闭程序。然后在目录里全选所有文件，右键压缩成一个压缩包，建议压成 7z。</p></section>
<section><h2>第三步：清掉简幻欢的原服务端，上传新的<a href="#第三步清掉简幻欢的原服务端上传新的"><span>#</span></a></h2><p>回到刚新建的实例，点「文件」，全选所有文件删除——简幻欢会保留一个启动脚本。然后把刚才压好的压缩包上传上去，勾选它，点右下角工具箱里的「解压」。</p></section>
<section><h2>第四步：改启动脚本，补上 dotnet<a href="#第四步改启动脚本补上-dotnet"><span>#</span></a></h2><p>找到文件列表里的 <code>start.sh</code>，点开编辑，在 <code>cd "$(dirname "$0")" || exit</code> 后面加一行：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>mise</span><span> </span><span>use</span><span> </span><span>-g</span><span> </span><span>dotnet@8.0</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>保存。这行是给容器装上 dotnet 8.0，TModLoader 本体就靠它启动。</p></section>
<section><h2>第五步：把本地模组打包成整合包<a href="#第五步把本地模组打包成整合包"><span>#</span></a></h2><p>在本地 TModLoader 里，把自己要玩的、也就是要传到服务器的那批模组全部启用。确认配置没问题后，在模组管理页面点「将已启用模组保存为模组整合包」，再点「导出整合包」。</p><p>然后打开这个整合包所在的文件夹，切到 <code>Mods</code> 和 <code>Modconfigs</code> 这两个文件夹的目录，把它们打包压缩，同样建议压成 7z。</p></section>
<section><h2>第六步：改启动参数，指定模组目录<a href="#第六步改启动参数指定模组目录"><span>#</span></a></h2><p>把上一步压好的包上传到 <code>start.sh</code> 所在的目录并解压，当前目录会多出 <code>Mods</code> 和 <code>Modconfigs</code> 两个文件夹。</p><p>再打开 <code>start.sh</code>：</p><ol>
<li>删掉所有以 <code>launch_args="-server"</code> 开头的代码</li>
<li>新增一行：</li>
</ol><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>launch_args</span><span>=</span><span>"-server -config serverconfig.txt -modpath /home/container/Mods -tmlsavedirectory /home/container"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这行把模组路径指到刚解压出来的 <code>Mods</code>，把存档目录指到 <code>/home/container</code>。</p><p>改完之后 <code>start.sh</code> 全文长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#!/usr/bin/env bash</span></div></div><div><div><div>2</div></div><div><span>cd</span><span> </span><span>"$(</span><span>dirname</span><span> "</span><span>$0</span><span>")"</span><span> || </span><span>exit</span></div></div><div><div><div>3</div></div><div><span>mise</span><span> </span><span>use</span><span> </span><span>-g</span><span> </span><span>dotnet@8.0</span></div></div><div><div><div>4</div></div><div><span>launch_args</span><span>=</span><span>"-server -config serverconfig.txt -modpath /home/container/Mods -tmlsavedirectory /home/container"</span></div></div><div><div><div>5</div></div><div><span>dotnet</span><span> </span><span>tModLoader.dll</span><span> </span><span>$launch_args</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>第七步：启动<a href="#第七步启动"><span>#</span></a></h2><p>回到简幻欢的终端页面，点右上角的「启动服务器」，服务器就带着你的模组跑起来了。</p></section>
<section><h2>踩坑：终端不显示的那几个字段<a href="#踩坑终端不显示的那几个字段"><span>#</span></a></h2><p>简幻欢的网页终端渲染 TModLoader 服务端的交互提示时会有 bug，提示文字显示不出来，只能看到一个光标在那儿闪。得按顺序对着填。</p><p><strong>创建世界的时候，下面这两个内容显示不出来：</strong></p>

<table><thead><tr><th>顺序</th><th>显示不出来的内容</th></tr></thead><tbody><tr><td>1</td><td>世界名称</td></tr><tr><td>2</td><td>种子</td></tr></tbody></table><p><strong>创建完世界后进入选择世界启动，这一步同样不显示，依次是：</strong></p>

<table><thead><tr><th>顺序</th><th>显示不出来的内容</th><th>怎么填</th></tr></thead><tbody><tr><td>1</td><td>最大玩家数</td><td>—</td></tr><tr><td>2</td><td>端口</td><td>填简幻欢对应实例的<strong>实际端口</strong>，别随手填</td></tr><tr><td>3</td><td>自动转发端口</td><td>直接输 <code>y</code> 过掉</td></tr><tr><td>4</td><td>密码</td><td>—</td></tr></tbody></table><p>这里就端口要注意一下：它要的是你这个实例自己的端口，不是随便一个能用的数，填错了外面连不进来。自动转发端口那项无所谓，无脑输 <code>y</code> 就行。</p><div><div><div></div><div>顺序别记错</div></div><div><p>提示看不到，等于是在盲填。建议把上面两张表放在旁边，一项一项对着来。</p></div></div></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><ol>
<li>简幻欢注册账号，控制台新建实例：Terraria + TModLoader + 最新版本</li>
<li>本地 Steam 跑一次 <code>start-tModLoaderServer.bat</code>，到选世界那步退出，全选文件压包</li>
<li>上传到简幻欢解压，<code>start.sh</code> 里加一行 <code>mise use -g dotnet@8.0</code></li>
<li>本地导出模组整合包，把 <code>Mods</code>、<code>Modconfigs</code> 压包上传解压</li>
<li>改 <code>launch_args</code> 指向模组目录，回终端点「启动服务器」</li>
</ol></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>整套流程走下来，真正花时间的不是点面板，是在本地把服务端文件和模组整理干净——本地跑过一遍、模组确认启用、路径对上，后面才顺。终端那个显示 bug 挺烦的，但知道顺序之后也就没什么影响了。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/recommend/operit-ai/</id>
      <title type="text">手机上的 AI Agent：用 Operit 帮我看住了挂机的虚拟机</title>
      <published>2026-09-10T00:00:00.000Z</published>
      <updated>2026-09-10T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/recommend/operit-ai/"/>
      <summary type="text">Operit 是一个安卓上的开源 AI Agent 平台，能直接操作手机。我拿它排查了虚拟机挂机几小时就卡死的问题，最后它给我写了个看门狗程序，检测到卡死就自动重启虚拟机和里面的两个实例。</summary>
      <content type="html"><![CDATA[<p>前几天在 GitHub 上翻到一个叫 Operit 的项目，7.7k star，说是安卓上的 AI Agent 平台。当时只是收藏了一下，没想到后来真拿它解决了一个折腾我很久的问题。</p>
<p><a href="https://github.com/AAswordman/Operit" target="_blank">https://github.com/AAswordman/Operit</a></p>
<section><h2>为什么觉得它有意思<a href="#为什么觉得它有意思"><span>#</span></a></h2><p>我手机上跑着虚拟机挂机，具体挂的什么就不细说了，反正需要 7×24 开着。问题是这玩意每隔几小时就会卡死一次，卡死之后只能手动打开虚拟机软件、把里面的两个实例重新启动。人不在手机边上的时候就断了，很烦。</p><p>这类”定时巡检 + 自动恢复”的活，以前要么自己写无障碍脚本，要么用 Tasker 拼，都挺麻烦的。看到 Operit 支持无障碍、Shizuku、Root 三种通道直接操作手机界面，还能跑工作流和定时任务，就想着：能不能让 AI 直接帮我搞定这事？</p></section>
<section><h2>排查过程<a href="#排查过程"><span>#</span></a></h2><p>我直接把问题丢给它：虚拟机每隔几小时卡死，帮我看看怎么回事。</p><p>它先是翻日志、看进程状态，分析了一圈可能的原因——内存回收、后台限制这些都查了一遍。说实话具体原因到最后也没 100% 定位，虚拟机这种东西卡死的因素太多了。但它换了个思路：既然不好根治，那就盯着它，卡死了自动拉起来。</p><p>然后它给我写了一个看门狗程序：持续检测虚拟机的运行状态，一旦发现卡死，自动打开虚拟机软件，然后把里面的两个实例依次启动起来。整个过程不用我管。</p><p>跑了几天，挂机再没断过。偶尔看记录，看门狗半夜自己触发过几次重启，早上起来虚拟机好端端的跑着。</p><p></p><figure><img alt="Agent 任务执行流程" loading="lazy" width="2048" height="853" src="/_astro/image-001.BXYTvkz0_Z1ofPWL.webp" /><figcaption>Agent 任务执行流程</figcaption></figure><p></p></section>
<section><h2>它能干什么<a href="#它能干什么"><span>#</span></a></h2><p>除了这次用到的自动化能力，看了下 README，功能面铺得挺广的：</p><ul>
<li><strong>AI 对话</strong>：兼容 OpenAI、Anthropic、Gemini 各种端点，也能跑本地模型（内置 MNN，支持 GGUF）</li>
<li><strong>设备自动化</strong>：无障碍 / Shizuku / Root 三种通道，能模拟点击、读屏、操作界面</li>
<li><strong>内置终端</strong>：自带 Ubuntu 24.04 ARM64 用户空间，SSH、tmux、vim 都有</li>
<li><strong>工作区</strong>：能直接在手机上开 Web、Python、Go 项目，代码编辑加实时预览</li>
<li><strong>工作流</strong>：可视化编排，支持定时、Tasker、Intent、语音触发</li>
<li><strong>其它</strong>：长期记忆、角色卡、多标签浏览器、语音对话、悬浮窗，一堆</li>
</ul><p>对我来说核心就一条：它是真的能动手的 Agent，不是只会聊天的那种。</p></section>
<section><h2>权限这块要说一下<a href="#权限这块要说一下"><span>#</span></a></h2><p>我自己的感受：<strong>有 Root 才是完全体</strong>。</p><p>UI 自动化这三档通道，能力差距挺明显的：</p><ul>
<li><strong>Root</strong>：什么都能干，还能用虚拟显示，后台跑任务不挑场景</li>
<li><strong>Shizuku</strong>：ADB 级权限，大部分操作没问题，不用解锁 BL，应该是多数人的选择</li>
<li><strong>无障碍</strong>：能用，但受系统限制多，碰到敏感界面就抓瞎</li>
</ul><p>我这次是看门狗检测到卡死后要去启动应用、点界面，有 Root 就完全没阻碍。没 Root 的话 Shizuku 应该也不错，至少比我之前试过的纯无障碍方案靠谱。</p><p></p><figure><img alt="Android 自动化演示" loading="lazy" width="2048" height="853" src="/_astro/image-002.BxHMgvRp_Z2a5lFi.webp" /><figcaption>Android 自动化演示</figcaption></figure><p></p></section>
<section><h2>几个细节<a href="#几个细节"><span>#</span></a></h2><ul>
<li><strong>系统要求</strong>：Android 8.0+，只要 ARM64 设备，APK 直接从 Releases 下</li>
<li><strong>协议</strong>：LGPL-3.0，开源。作者还在做跨平台的 Operit 2（Rust + Flutter）</li>
<li><strong>权限控制</strong>：工具调用分”自动允许、每次询问、禁止”三档，默认询问，不会背着你乱来</li>
<li><strong>语言</strong>：界面支持八种语言，中文没问题</li>
</ul></section>
<section><h2>我的判断<a href="#我的判断"><span>#</span></a></h2><p>这类”手机上的 AI Agent”项目最近不少，但大部分停留在”能调用几个 API”的水平。Operit 是真的把触手伸进系统里了——终端、文件、界面操作、定时任务都能串起来，这次看门狗的事就是个例子：从排查问题到写脚本到落地执行，全程在它里面完成。</p><p>适合什么人：手机上有 Root 或愿意装 Shizuku、喜欢折腾自动化、想让 AI 干实事而不只是聊天的人。</p><p>不适合什么人：只想找个开箱即用的 AI 聊天工具的。这东西功能多，设置项也多，得花点时间摸索。</p><p>反正我现在挂机是彻底放心了。以后手机上再有这种重复性的脏活，估计都会先丢给它试试。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/vercelcdncache/</id>
      <title type="text">给博客加上 Vercel 边缘缓存：顺便把那些英文术语整明白了</title>
      <published>2026-09-08T00:00:00.000Z</published>
      <updated>2026-09-10T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/vercelcdncache/"/>
      <summary type="text">给博客页面设置分层缓存头，让 CDN 直接吐页面而不是每次都回源，再配一个部署后自动预热的工作流，顺手把 max-age、s-maxage、HIT/MISS 这些缓存术语一个个讲明白</summary>
      <content type="html"><![CDATA[<p>博客一直挂在 Vercel 上，部署是省心，但有个小毛病：每次重新部署完，站点就像被人清空了记忆，第一个打开页面的人总要等上一小会。看 <a href="https://www.mysticstars.cn/archives/speed-vercel" target="_blank">mysticstars.cn</a> 和 <a href="https://upxuu.com/posts/vercel-youxuan-cache/" target="_blank">upxuu.com</a> 两篇文章，都是在讲怎么让 Vercel 站点在国内测速”全绿”，核心手段就是<strong>边缘缓存</strong>。看的时候一堆英文术语差点把我绕晕，折腾完顺手把这次的全过程记下来。</p>
<section><h2>先把那些英文术语整明白<a href="#先把那些英文术语整明白"><span>#</span></a></h2><p>搞缓存绕不开一屏幕英文，先把它们按”谁在看、谁在听”分好类，后面看配置就不晕了：</p>

<table><thead><tr><th>术语</th><th>一句话解释</th></tr></thead><tbody><tr><td><code>Cache-Control</code></td><td>响应头，告诉浏览器和 CDN”这个页面能缓存多久、要不要缓存”。本文所有配置都是在写它</td></tr><tr><td><code>max-age</code></td><td>缓存有效期，单位秒。写在 <code>Cache-Control</code> 里，<strong>浏览器</strong>认这个</td></tr><tr><td><code>s-maxage</code></td><td>和 <code>max-age</code> 一样是有效期，但只有**共享缓存（CDN）**认，浏览器直接忽略</td></tr><tr><td><code>immutable</code></td><td>“这文件永远不会变”，浏览器连重新验证都省了，连缓存都懒得检查。只能配给带哈希文件名的资源</td></tr><tr><td><code>must-revalidate</code></td><td>缓存过期后<strong>必须</strong>回源问一声”有没有新版”，不许直接用旧的</td></tr><tr><td><code>stale-while-revalidate</code></td><td>缓存过期了先照旧用，同时后台悄悄去更新，下次就有新版了</td></tr><tr><td><code>ETag</code></td><td>服务器给每个版本的内容算的一个标签，重新验证时比对一下就知道变没变</td></tr><tr><td><code>X-Vercel-Cache</code></td><td>Vercel 加的响应头，<code>HIT</code> 表示”这次是缓存直接吐给你的”，<code>MISS</code> 表示”这次回源现取的”</td></tr><tr><td><code>Age</code></td><td>这份缓存已经在边缘节点里躺了多少秒</td></tr><tr><td><code>哈希文件名</code></td><td>构建时把内容哈希写进文件名（<code>_..BulBwWNp.css</code> 这种），内容变了文件名就变，天然不怕缓存旧版</td></tr></tbody></table><p>几个关键关系再说细一点：</p><ul>
<li><code>max-age</code> 和 <code>s-maxage</code> 是<strong>分开的两个开关</strong>：<code>s-maxage</code> 拉得再长，浏览器也只认 <code>max-age</code>。所以可以做到”CDN 缓存一天，浏览器每次重新验证”。</li>
<li><code>HIT</code> 不丢人，<code>MISS</code> 也不丢人。第一次请求必然是 MISS（缓存是空的），第二次开始才是 HIT。判断缓存配好没，就是看同一个页面第二次请求是不是 HIT。</li>
<li>重新部署为什么能”自动清缓存”：Vercel 每次部署会主动把旧缓存失效掉。所以 HTML 的边缘缓存 TTL 设多长其实无所谓——反正部署时就换新的了。</li>
</ul></section>
<section><h2>分层配置 vercel.json<a href="#分层配置-verceljson"><span>#</span></a></h2><p>思路很简单：<strong>不同文件用不同缓存策略</strong>。哈希资源一年不用动，HTML 要保证发新文章立即可见，数据接口折中。我的 <code>vercel.json</code> 最终长这样（安全头那部分是之前就有的）：</p><div><div><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>"headers"</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>"source"</span><span>: </span><span>"/(.*)"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>      </span><span>"headers"</span><span>: [</span></div></div><div><div><div>6</div></div><div><span><span>        </span></span><span>{ </span><span>"key"</span><span>: </span><span>"X-Content-Type-Options"</span><span>, </span><span>"value"</span><span>: </span><span>"nosniff"</span><span> },</span></div></div><div><div><div>7</div></div><div><span><span>        </span></span><span>{ </span><span>"key"</span><span>: </span><span>"X-Frame-Options"</span><span>, </span><span>"value"</span><span>: </span><span>"SAMEORIGIN"</span><span> },</span></div></div><div><div><div>8</div></div><div><span><span>        </span></span><span>{ </span><span>"key"</span><span>: </span><span>"X-XSS-Protection"</span><span>, </span><span>"value"</span><span>: </span><span>"1; mode=block"</span><span> },</span></div></div><div><div><div>9</div></div><div><span><span>        </span></span><span>{ </span><span>"key"</span><span>: </span><span>"Referrer-Policy"</span><span>, </span><span>"value"</span><span>: </span><span>"strict-origin-when-cross-origin"</span><span> },</span></div></div><div><div><div>10</div></div><div><span><span>        </span></span><span>{ </span><span>"key"</span><span>: </span><span>"Cache-Control"</span><span>, </span><span>"value"</span><span>: </span><span>"public, s-maxage=86400, stale-while-revalidate=604800, max-age=0, must-revalidate"</span><span> }</span></div></div><div><div><div>11</div></div><div><span><span>      </span></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>{</span></div></div><div><div><div>14</div></div><div><span>      </span><span>"source"</span><span>: </span><span>"/_astro/(.*)"</span><span>,</span></div></div><div><div><div>15</div></div><div><span>      </span><span>"headers"</span><span>: [{ </span><span>"key"</span><span>: </span><span>"Cache-Control"</span><span>, </span><span>"value"</span><span>: </span><span>"public, max-age=31536000, immutable"</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></span><span>{</span></div></div><div><div><div>18</div></div><div><span>      </span><span>"source"</span><span>: </span><span>"/assets/(.*)"</span><span>,</span></div></div><div><div><div>19</div></div><div><span>      </span><span>"headers"</span><span>: [{ </span><span>"key"</span><span>: </span><span>"Cache-Control"</span><span>, </span><span>"value"</span><span>: </span><span>"public, max-age=2592000, stale-while-revalidate=604800, must-revalidate"</span><span> }]</span></div></div><div><div><div>20</div></div><div><span><span>    </span></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><span>"source"</span><span>: </span><span>"/(pagefind|pio)/(.*)"</span><span>,</span></div></div><div><div><div>23</div></div><div><span>      </span><span>"headers"</span><span>: [{ </span><span>"key"</span><span>: </span><span>"Cache-Control"</span><span>, </span><span>"value"</span><span>: </span><span>"public, max-age=2592000, stale-while-revalidate=604800, must-revalidate"</span><span> }]</span></div></div><div><div><div>24</div></div><div><span><span>    </span></span><span>},</span></div></div><div><div><div>25</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>26</div></div><div><span>      </span><span>"source"</span><span>: </span><span>"/api/(.*)"</span><span>,</span></div></div><div><div><div>27</div></div><div><span>      </span><span>"headers"</span><span>: [{ </span><span>"key"</span><span>: </span><span>"Cache-Control"</span><span>, </span><span>"value"</span><span>: </span><span>"public, s-maxage=3600, max-age=0, must-revalidate"</span><span> }]</span></div></div><div><div><div>28</div></div><div><span><span>    </span></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>每条规则的意图：</p>

<table><thead><tr><th>路径</th><th>策略</th><th>为什么</th></tr></thead><tbody><tr><td>其他所有（HTML 页面）</td><td>边缘缓存 1 天，过期后 7 天内先吐旧页面同时后台更新，浏览器每次重新验证</td><td>CDN 直接吐页面，但发新文章后老读者刷新立刻看到新版；缓存过期瞬间访客不用等回源，直接拿旧页面秒开</td></tr><tr><td><code>/_astro/</code></td><td>一年 + <code>immutable</code></td><td>全是带哈希的构建产物，文件名变了缓存自然换新</td></tr><tr><td><code>/assets/</code>、<code>/pagefind/</code>、<code>/pio/</code></td><td>30 天 + 过期后后台更新</td><td>手动放进去的静态文件，搜索索引、阅读器组件这类</td></tr><tr><td><code>/api/</code></td><td>边缘缓存 1 小时，浏览器不缓存</td><td>文章元数据、评论数据，旧一小时无伤大雅</td></tr></tbody></table><div><div><div></div><div>为什么要 1 天而不是十年</div></div><div><p>那两篇文章（尤其第二篇）建议把 HTML 边缘缓存直接拉到十年，理由是”重新部署会自动失效，设多长都行”。理是这个理，但我想的是：万一哪天 Vercel 某个区域的失效信号出岔子，TTL 一天最多让那个区域吐一天旧页面，十年就是吐到你手动清缓存为止。正常场景下两者效果完全一样，那我选风险小的。</p></div></div></section>
<section><h2>后续优化：给缓存加”过期续命”<a href="#后续优化给缓存加过期续命"><span>#</span></a></h2><p>上面这套配置跑了几天，有个小毛病一直没解决：<strong>每天凌晨缓存过期那一刻，第一个访客要干等回源</strong>，快则几百毫秒，慢则一两秒，全看 Vercel 源站当时的状态。</p><p>2026-09-10 给 HTML 规则加了 <code>stale-while-revalidate=604800</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>"public, s-maxage=86400, max-age=0, must-revalidate"</span></div></div><div><div><div>2</div></div><div><span>"public, s-maxage=86400, stale-while-revalidate=604800, max-age=0, must-revalidate"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>意思是：缓存过期后 7 天内，CDN 先把旧页面<strong>秒开</strong>给访客，同时在后台悄悄去源站拉新页面，拉完更新缓存，下一个访客看到的就是新的。这 7 天是兜底窗口，正常部署会主动清缓存，根本用不上；真出了异常也有旧页面顶着，不会白屏。</p><p>改完用 <code>curl -sI</code> 验证，响应头里能看到新指令：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-sI</span><span> </span><span>https://www.9ll.uk/</span><span> | </span><span>grep</span><span> </span><span>-i</span><span> </span><span>cache-control</span></div></div><div><div><div>2</div></div><div><span># Cache-Control: public, s-maxage=86400, stale-while-revalidate=604800, max-age=0, must-revalidate</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这个改动和原来的 <code>s-maxage=86400</code> 是互补关系：一天内正常缓存，一天到七天之间过期续命，七天之后彻底重新验证。浏览器端 <code>max-age=0</code> 不变，发新文章依旧立即可见。</p></section>
<section><h2>部署后自动预热<a href="#部署后自动预热"><span>#</span></a></h2><p>配置完之后还有一个空窗期：<strong>新部署的缓存是空的</strong>，每个页面都要等第一个访客来”暖场”，第二个访客才能吃到缓存。我的站一共六十多个页面，全等真人来暖，得等挺久。所以抄第二篇文章的思路，加了个 GitHub Actions 工作流。</p><p>逻辑是这样的：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>on</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>push</span><span>:</span></div></div><div><div><div>3</div></div><div><span>    </span><span>branches</span><span>: [</span><span>HY</span><span>]        </span><span># 我的部署分支</span></div></div><div><div><div>4</div></div><div><span>  </span><span>workflow_dispatch</span><span>:       </span><span># 也能手动跑</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>jobs</span><span>:</span></div></div><div><div><div>7</div></div><div><span>  </span><span>warm</span><span>:</span></div></div><div><div><div>8</div></div><div><span>    </span><span>steps</span><span>:</span></div></div><div><div><div>9</div></div><div><span><span>      </span></span><span>- </span><span>name</span><span>: </span><span>等待 Vercel 完成新部署</span></div></div><div><div><div>10</div></div><div><span>        </span><span>if</span><span>: </span><span>github.event_name == 'push'</span></div></div><div><div><div>11</div></div><div><span>        </span><span>run</span><span>: </span><span>sleep 120</span></div></div><div><div><div>12</div></div><div><span><span>      </span></span><span>- </span><span>name</span><span>: </span><span>预热并统计命中率</span></div></div><div><div><div>13</div></div><div><span>        </span><span>run</span><span>: </span><span>bash .github/scripts/cache-warm.sh</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>脚本干三件事：从 <code>sitemap-index.xml</code> 逐片抓全站 URL → 并发 curl 每个页面一遍 → 读响应里的 <code>X-Vercel-Cache</code> 统计 HIT 率，没达标就歇 20 秒再来一轮，最多四轮。原文的做法是写个 HIT.txt 提交回仓库，我嫌提交污染提交历史，改成直接把统计打到 Actions 的运行日志里，点开就能看。</p><p>有个物理限制得说清楚：<strong>GitHub Actions 的机器在美国，预热的是美区边缘节点</strong>。Vercel 边缘缓存按区域分片，所以这个工作流不能保证国内节点也全绿——国内访客第一次访问某页面时，就近节点可能还是 MISS，回源一次之后就好了。想连这块都解决就得动 DNS 优选，那是另一个话题。</p></section>
<section><h2>踩坑：header 顺序比你想象的阴险<a href="#踩坑header-顺序比你想象的阴险"><span>#</span></a></h2><p>第一版配置我是把 HTML 那条规则<strong>单独</strong>放在 <code>headers</code> 数组最后面，想着 Vercel 应该会”更细的规则覆盖更宽的”。结果上线一测，<code>/_astro/</code> 的 CSS 返回的头居然是 <code>s-maxage=86400, max-age=0</code>——<code>immutable</code> 没了。</p><p>查了文档才知道：Vercel 的 headers 匹配是<strong>先到先得</strong>，第一条 <code>/(.*)</code> 基础规则把后面所有细规则全挡了。修复也简单：把 HTML 的缓存头直接并进第一条基础规则，让每个请求在第一条就拿到该拿的头，后面的细粒度规则才有机会覆盖。</p><p>修完重新部署，再测：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-sI</span><span> </span><span>https://www.9ll.uk/</span><span> | </span><span>grep</span><span> </span><span>-i</span><span> </span><span>cache-control</span></div></div><div><div><div>2</div></div><div><span># Cache-Control: public, s-maxage=86400, max-age=0, must-revalidate</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>curl</span><span> </span><span>-sI</span><span> </span><span>https://www.9ll.uk/_astro/_..BulBwWNp.css</span><span> | </span><span>grep</span><span> </span><span>-i</span><span> </span><span>cache-control</span></div></div><div><div><div>5</div></div><div><span># Cache-Control: public, max-age=31536000, immutable</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这回对了。教训记下来：<strong>在 Vercel 配 headers，永远先声明通用头，再声明细粒度头，同 key 谁先声明谁赢</strong>。</p></section>
<section><h2>验证效果<a href="#验证效果"><span>#</span></a></h2><p>配置生效后，冷态实测（第一次就请求）：</p>

<table><thead><tr><th>页面</th><th>第一次</th><th>第二次</th></tr></thead><tbody><tr><td>首页</td><td>MISS</td><td>HIT，Age 增长</td></tr><tr><td>关于页</td><td>HIT（被预热工作流填过）</td><td>HIT</td></tr><tr><td>朋友圈</td><td>HIT</td><td>HIT</td></tr><tr><td>标签页</td><td>HIT</td><td>HIT</td></tr><tr><td>随机老文章</td><td>MISS（美区预热覆盖不到）</td><td>HIT</td></tr></tbody></table><p>预热脚本本地冒烟测试的数据也放一下：64 个页面，第一轮 HIT 28/64（43.8%），第二轮 64/64（100%）。这就是”暖场”的效果，第一遍全是 MISS，第二遍全 HIT。</p><p>发新文章会不会被缓存卡住？不会。浏览器端是 <code>max-age=0, must-revalidate</code>，每次都会带着 ETag 去问边缘节点有没有新版，部署一完成，刷新就是新内容。</p></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>整套东西在 Vercel 上就是<strong>一个 vercel.json + 一个 Actions 工作流</strong>，后台什么都不用点。对静态博客来说，<code>s-maxage</code> 边缘缓存加哈希资源 <code>immutable</code> 基本就是全部了，剩下的预热属于锦上添花。如果你也想抄，顺序记住：</p><ol>
<li><code>vercel.json</code> 里通用头写进第一条规则，细粒度规则放后面</li>
<li>HTML 用 <code>s-maxage</code> 控制边缘、<code>max-age=0</code> 控制浏览器</li>
<li>带哈希的资源大胆上 <code>immutable</code></li>
<li>加个部署后预热工作流，消掉”第一个访客最慢”的空窗</li>
</ol><p>配置完用 <code>curl -sI</code> 看响应头就行，同一个 URL 连请求两次，第二次 <code>X-Vercel-Cache: HIT</code> 就成了。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/blogchangelog/</id>
      <title type="text">给博客加了张更新日志页</title>
      <published>2026-09-07T00:00:00.000Z</published>
      <updated>2026-09-12T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/blogchangelog/"/>
      <summary type="text">羡慕 rainzt.cn 的博客日志页，把它的页面结构扒下来，用 Firefly 自己的组件体系重做了一遍。全程只新增三个文件，不碰主题源码，以后合并上游更新不受影响。顺便把折叠动画从 max-height 换成了 grid-template-rows。</summary>
      <content type="html"><![CDATA[<p>前一篇写了<a href="/posts/blog/homestickercard/">首页贴纸卡片的魔改过程</a>，这次折腾的是另一件事：<strong>博客日志页</strong>。</p>
<p>起因还是刷 rainzt.cn。他们首页有贴纸，里面还有个 <code>/blog-changelog/</code> 的更新日志，每次改动都记一条，版本号、变更明细、时间线摆得整整齐齐。我当时就想，这习惯挺好——博客又不是写完就扔，以后改了什么自己也该有个账本。</p>
<p>说干就干，最后做出来的成品长这样：</p>
<p></p><figure><img alt="做好的博客日志页，hero 统计卡加时间线" loading="lazy" width="1280" height="900" src="/_astro/changelog-page.WsvdOwkb_Z1ShBnW.webp" /><figcaption>做好的博客日志页，hero 统计卡加时间线</figcaption></figure><p></p>
<section><h2>第一版翻车：光看概述就动手<a href="#第一版翻车光看概述就动手"><span>#</span></a></h2><p>一开始我没去扒源码，让 AI 帮我”描述一下这个页面长什么样”，照着那个概述做了个版本：一个卡片加几条列表。</p><p>结果被自己打脸了。和源站的出入大到没法看——人家是 hero 统计卡加时间线加大卡片，我做出来的是个毛坯。最讽刺的是我还写了个”详细记录”，把写博客文章这种事也塞进了变更明细。</p><p>痛定思痛，还是老老实实扒源码。</p></section>
<section><h2>扒站：把 HTML 和 CSS 都拆出来<a href="#扒站把-html-和-css-都拆出来"><span>#</span></a></h2><p>第一步是 <code>curl</code> 把页面和它自己的样式文件都拉下来：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-sL</span><span> </span><span>https://rainzt.cn/blog-changelog/</span><span> </span><span>-o</span><span> </span><span>changelog.html</span></div></div><div><div><div>2</div></div><div><span># 从 HTML 里找到它引用的样式文件</span></div></div><div><div><div>3</div></div><div><span>grep</span><span> </span><span>-o</span><span> </span><span>'blog-changelog[^"]*\.css'</span><span> </span><span>changelog.html</span></div></div><div><div><div>4</div></div><div><span># 再把这个 CSS 也拉下来</span></div></div><div><div><div>5</div></div><div><span>curl</span><span> </span><span>-s</span><span> </span><span>https://rainzt.cn/_astro/blog-changelog.A9uhDpGh.css</span><span> </span><span>-o</span><span> </span><span>changelog.css</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>然后对着 HTML 的结构一层层拆，它的页面骨架长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>blog-changelog-page</span></div></div><div><div><div>2</div></div><div><span>├── hero（眉线 + 标题 + 描述 + 版本规则 + 2×2 统计卡）</span></div></div><div><div><div>3</div></div><div><span>├── nav（返回链接 + 按日倒序说明）</span></div></div><div><div><div>4</div></div><div><span>└── ol.list（时间线）</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>└── li.entry × N</span></div></div><div><div><div>6</div></div><div><span><span>        </span></span><span>├── date（日期 + 第几次记录）</span></div></div><div><div><div>7</div></div><div><span><span>        </span></span><span>├── rail（圆点 + 轴线）</span></div></div><div><div><div>8</div></div><div><span><span>        </span></span><span>└── article.card（版本徽章 + kind 短语 + h2 + 描述</span></div></div><div><div><div>9</div></div><div><span><span>            </span></span><span>+ details 折叠明细 + tags）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>关键细节在 CSS 里：时间线轴线是一条渐变的 1px 竖线，圆点是主题色带光环（<code>box-shadow: 0 0 0 .22rem</code>），折叠明细用原生 <code>&lt;details&gt;</code> 配展开动画，箭头旋转 180 度。这些光看概述是绝对看不出来的。</p><div><div><div></div><div>折叠动画后来换了实现</div></div><div><p>原来用的是 <code>max-height</code> 过渡，但这个写法有个先天问题：得给容器估一个固定高度，估小了正文就被裁掉。后来发现日志正文长到三五行的就显示不全了，改成了 <code>grid-template-rows: 1fr ↔ 0fr</code>——高度由内容自己决定，多长都能完整展开。详见 <a href="/blog-changelog/">V1.3 的日志条目</a>。</p></div></div></section>
<section><h2>重做：全部塞进三个新文件<a href="#重做全部塞进三个新文件"><span>#</span></a></h2><p>照着结构重做了一版。和首页贴纸一样，我给自己定的规矩是<strong>不碰主题源码</strong>，方便以后合并 CuteLeaf/Firefly 上游的更新。所以拆成三块：</p>

<table><thead><tr><th>文件</th><th>作用</th></tr></thead><tbody><tr><td><code>src/pages/blog-changelog.astro</code></td><td>页面入口，Astro 按文件名自动生成 <code>/blog-changelog/</code> 路由</td></tr><tr><td><code>src/components/features/ChangelogTimeline.astro</code></td><td>时间线组件，HTML + 全部 CSS 自包含</td></tr><tr><td><code>src/config/blogChangelogConfig.ts</code></td><td>日志数据，以后每次更新只改这一个文件</td></tr></tbody></table><p>这个方案最舒服的地方是<strong>记日志完全不用碰代码</strong>，往配置里加一个对象就行：</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></span><span>version</span><span>: </span><span>"V1.0"</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>title</span><span>: </span><span>"首页贴纸卡片"</span><span>,</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>date</span><span>: </span><span>"2026-09-06"</span><span>,</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>summary</span><span>: </span><span>"首页来了套新皮肤"</span><span>,</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>description</span><span>: </span><span>"给首页壁纸区加了一套可拖拽的贴纸装饰和个人卡片……"</span><span>,</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>items</span><span>: [</span></div></div><div><div><div>8</div></div><div><span><span>        </span></span><span>{ </span><span>category</span><span>:</span><span> </span><span>"首页"</span><span>, </span><span>text</span><span>:</span><span> </span><span>"新增壁纸装饰卡片……"</span><span> },</span></div></div><div><div><div>9</div></div><div><span><span>        </span></span><span>{ </span><span>category</span><span>:</span><span> </span><span>"视觉"</span><span>, </span><span>text</span><span>:</span><span> </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></span><span>tags</span><span>: [</span><span>"首页"</span><span>, </span><span>"贴纸"</span><span>, </span><span>"视觉"</span><span>, </span><span>"移动端"</span><span>, </span><span>"性能"</span><span>],</span></div></div><div><div><div>12</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>样式全部复用 Firefly 的主题变量（<code>--line-divider</code>、<code>--content-meta</code>、<code>--card-bg</code>、<code>--primary</code>），深浅色模式自动适配，一行硬编码颜色都不用写。</p></section>
<section><h2>怎么用：记一次日志只需要动一个文件<a href="#怎么用记一次日志只需要动一个文件"><span>#</span></a></h2><p>所有数据集中在 <code>src/config/blogChangelogConfig.ts</code>，加一条日志就是在数组<strong>最前面</strong>插一个对象：</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></span><span>version</span><span>: </span><span>"V1.1"</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>title</span><span>: </span><span>"站内搜索"</span><span>,        </span><span>// kind 徽章上的短语</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>date</span><span>: </span><span>"2026-09-20"</span><span>,</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>summary</span><span>: </span><span>"搜索页换了新引擎"</span><span>, </span><span>// 卡片大标题，要短</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>description</span><span>: </span><span>"把站内搜索换成了 Pagefind……"</span><span>, </span><span>// 标题下的一段说明</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>items</span><span>: [</span></div></div><div><div><div>8</div></div><div><span><span>        </span></span><span>{ </span><span>category</span><span>:</span><span> </span><span>"首页"</span><span>, </span><span>text</span><span>:</span><span> </span><span>"具体改了什么"</span><span> },</span></div></div><div><div><div>9</div></div><div><span><span>        </span></span><span>{ </span><span>category</span><span>:</span><span> </span><span>"性能"</span><span>, </span><span>text</span><span>:</span><span> </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></span><span>tags</span><span>: [</span><span>"搜索"</span><span>, </span><span>"性能"</span><span>],</span></div></div><div><div><div>12</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>字段逐个说一下：</p>

<table><thead><tr><th>字段</th><th>作用</th><th>注意点</th></tr></thead><tbody><tr><td><code>version</code></td><td>版本号，显示在卡片左上角</td><td>按 Major/Minor/Patch 规则自己定</td></tr><tr><td><code>title</code></td><td>kind 短语徽章</td><td>3-6 个字最合适，颜色按条目顺序自动循环</td></tr><tr><td><code>date</code></td><td>日期</td><td><code>YYYY-MM-DD</code>，同时决定时间线排序（数组顺序就是页面顺序）</td></tr><tr><td><code>summary</code></td><td>卡片大标题</td><td>短句，一句话</td></tr><tr><td><code>description</code></td><td>卡片描述</td><td>多写两句没问题，这里适合展开</td></tr><tr><td><code>items</code></td><td>变更明细</td><td><code>category</code> 是分类徽章（首页/视觉/修复/性能/内容/维护…随便起，没配色的分类自动用灰色），<code>text</code> 是说明</td></tr><tr><td><code>tags</code></td><td>底部标签</td><td>照抄明细里的分类就行</td></tr></tbody></table><p>加完保存，页面、统计卡（更新日数、记录条数、时间跨度）全部自动更新，不用改任何代码。</p><section><h3>明细的双列排布<a href="#明细的双列排布"><span>#</span></a></h3><p>明细会按奇数偶数自动分成左右两列（顺序 01 左、02 右、03 左……），手机端自动叠成单列。明细项本身可以点箭头折叠，默认展开。</p></section><section><h3>版本号怎么定<a href="#版本号怎么定"><span>#</span></a></h3><p>页面上写的那套规则：Major 结构级升级、Minor 新功能、Patch 修复和内容维护。像这次加贴纸卡片这种纯新增功能，按 Minor 算应该是 V1.0——版本号不用太纠结，能看出大小就行。</p><p>写完这篇之后我一直在往里记账，现在页面已经走到 <strong>V1.5</strong> 了，七条记录、四十多条明细。回头看看这个”账本”还挺有用的：哪些改动是性能相关、哪些是修 bug，翻一遍就清楚，比在 commit 历史里翻好找多了。</p></section></section>
<section><h2>细节：从配置自动算统计<a href="#细节从配置自动算统计"><span>#</span></a></h2><p>源站的统计卡是写死的，我这里全部从配置实时算出来：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>totalItems</span><span> </span><span>=</span><span><span> </span><span>entries</span><span>.</span></span><span>reduce</span><span><span>((</span><span>sum</span><span>, </span><span>e</span><span>) </span></span><span>=&gt;</span><span><span> </span><span>sum</span><span> </span></span><span>+</span><span><span> </span><span>e</span><span>.</span></span><span>items</span><span>.</span><span>length</span><span>, </span><span>0</span><span>);</span></div></div><div><div><div>2</div></div><div><span>// 时间跨度：最早一条到最新一条，格式 26.09—26.09</span></div></div><div><div><div>3</div></div><div><span>const</span><span> </span><span>dateSpan</span><span> </span><span>=</span><span> </span><span>`</span><span>${</span><span>最早</span><span>.</span><span>date</span><span>.</span><span>slice</span><span>(</span><span>2</span><span>,</span><span> </span><span>7</span><span>)</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>}</span><span>—</span><span>${</span><span>最新</span><span>.</span><span>date</span><span>.</span><span>slice</span><span>(</span><span>2</span><span>,</span><span> </span><span>7</span><span>)</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>}</span><span>`</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>当前版本取数组第一条、更新日数取数组长度、“第 N 次记录”倒着数。以后加日志，统计数字自己会涨，不用维护。</p></section>
<section><h2>踩坑：两次跑错方向<a href="#踩坑两次跑错方向"><span>#</span></a></h2><ol>
<li><strong>概述不可信</strong>。第一版全靠文字描述，做出来四不像。第二次把 HTML 和 CSS 全部拉下来 1&lt;1&gt; 复刻才像样。教训：做界面参考，永远以源码为准，别信二手转述。</li>
<li><strong>菜单入口差点忘了</strong>。页面做完才反应过来——导航栏里根本没入口，访客怎么进？后来在「关于」子菜单里加了一项「博客日志」，放在「关于我」上面，顺手在关于页面里也补了个链接。日志页自己的返回按钮指向「关于我」，形成闭环。</li>
</ol><p></p><figure><img alt="移动端单列排布，时间线和折叠明细正常" loading="lazy" width="390" height="844" src="/_astro/image-001.6ngtmKmc_s1VRe.webp" /><figcaption>移动端单列排布，时间线和折叠明细正常</figcaption></figure><p></p></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>这页做完，博客就算有了自己的”账本”。V1.0 已经把贴纸卡片那次的改动全记进去了，以后每折腾一次就记一条，看着版本号往上走，还挺有成就感的。</p><p>和贴纸一样，这次依然是三个新文件加两处内容文件的小改动，合并上游时零冲突。需要的人可以照这个思路给任何静态博客加日志页：路由靠文件约定，数据放配置，样式自包含。</p><p>页面地址：<a href="/blog-changelog/">/blog-changelog/</a>，导航栏「关于 → 博客日志」也能进。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/friendsmoments/</id>
      <title type="text">给博客装了个朋友圈：把友链的更新聚成一条时间线</title>
      <published>2026-09-07T00:00:00.000Z</published>
      <updated>2026-09-12T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/friendsmoments/"/>
      <summary type="text">羡慕 rainzt.cn 的朋友圈，把它搬进自己的 Firefly：构建时抓取所有友链的 RSS 合流成时间线，快照兜底，GitHub Actions 每天自动刷新。纯静态，不用服务器，只新增文件不碰主题源码。后来又给时间线加了层来源配额，不让聚合站霸榜。</summary>
      <content type="html"><![CDATA[<p>上一轮魔改<a href="/posts/blog/homestickercard/">首页贴纸卡片</a>和<a href="/posts/blog/blogchangelog/">更新日志页</a>的时候，我一直在 rainzt.cn 上转悠。她导航栏里有个”朋友圈”，点进去是把所有友链博客最近的文章聚成的一条时间线，谁更新了都看得到。我当时就惦记上了——这功能对独立博客来说太合适了，相当于一个小号的博客聚合社区，还不用装任何服务。</p>
<p>成品先放这里，就是这个 <code>/moments/</code>：</p>
<p></p><figure><img alt="做好的朋友圈页面，顶部是统计和随机文章卡，下面是友链文章时间线" loading="lazy" width="1200" height="598" src="/_astro/cover.Dfr480q6_1yM9kt.webp" /><figcaption>做好的朋友圈页面，顶部是统计和随机文章卡，下面是友链文章时间线</figcaption></figure><p></p>
<section><h2>一个前提：RSS 抓取发生在构建时<a href="#一个前提rss-抓取发生在构建时"><span>#</span></a></h2><p>先想清楚这事怎么做。老派的”朋友圈”是浏览器实时去抓别人的 RSS，要过一层 CORS 代理（rss2json 那套），又脆又慢，还依赖第三方服务活着。我的博客是纯静态，最顺的做法是反过来：<strong>抓取发生在 <code>astro build</code> 的时候</strong>，数据直接烤进 HTML，线上还是那个纯静态站点，服务器零参与。</p><p>代价也明确：内容的”新”取决于多久重新构建一次。所以我配了个 GitHub Actions 定时任务，每天自动抓一遍并提交快照，提交又触发 Pages 部署。每天更新一次朋友圈，对我来说完全够了。</p></section>
<section><h2>核心代码：一个文件干完所有事<a href="#核心代码一个文件干完所有事"><span>#</span></a></h2><p>抓取逻辑全在 <code>src/utils/friends-feed.ts</code>，页面入口 <code>src/pages/moments.astro</code> 里就一行关键代码：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>feed</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>loadFriendsFeed</span><span><span>(</span><span>friends</span><span>);</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Astro 的 frontmatter 里顶层 <code>await</code> 是在构建时执行的，这一行就是”把全部友链的 RSS 抓回来”。</p><p>具体流程：</p><ol>
<li>遍历 <code>friendsConfig.ts</code> 里 <code>enabled: true</code> 的友链；</li>
<li>每个站点按顺序猜 RSS 地址：先试你填的 <code>rss</code> 字段，没填就猜 <code>rss.xml</code>、<code>feed.xml</code>、<code>feed/</code>、<code>atom.xml</code>、<code>index.xml</code> 这些常见路径，还不行就抓首页解析 <code>&lt;link rel="alternate"&gt;</code> 标签；</li>
<li>用正则把每个 feed 的 <code>&lt;item&gt;</code>/<code>&lt;entry&gt;</code> 拆出标题、链接、摘要、时间，每个站最多取 12 篇；</li>
<li>所有来源去重后，按来源配额（本站 3 成 / Blog 4 成 / 博客社区 3 成）挑片、交错排列成一条时间线。</li>
</ol><p>防呆也做了：并发 5 个、单个 8 秒超时、失败重试 3 次。全部失败就用上一次的快照兜底，构建永远不会因为友链挂了而炸。</p></section>
<section><h2>和友链共用一个配置<a href="#和友链共用一个配置"><span>#</span></a></h2><p>朋友圈不是新数据源，它读的就是友链那一份配置。我给 <code>FriendLink</code> 类型加了一个可选字段：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// RSS/Atom 地址，可留空并由朋友圈页面自动探测</span></div></div><div><div><div>2</div></div><div><span>rss</span><span>?:</span><span><span> </span><span>string</span><span> </span></span><span>|</span><span><span> </span><span>string</span><span>[];</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>填了就优先用，不填靠探测。探测失败的站点会列在页面底部的提示里——“未订阅的站点：xxx”，方便你直接去跟对方要 RSS 地址。</p><p>顺带我还把自己也加进了友链：<code>siteurl</code> 指向本站、<code>rss: "/rss.xml"</code>，这样自己的文章也进时间线，页面上会打”本站主理人”的蓝色徽章（跟”推荐友链”的金色徽章区分开）。唯一的副作用是友链页也会出现自己这条，排在最前面当个自荐位，我觉得挺好。</p></section>
<section><h2>每天自动刷新：一个 workflow 完事<a href="#每天自动刷新一个-workflow-完事"><span>#</span></a></h2><p>保持新鲜的方案是 <code>src/data/friends-feed-snapshot.json</code> 这个快照文件 + 一个定时 workflow：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># .github/workflows/refresh-friends-feed.yml（节选）</span></div></div><div><div><div>2</div></div><div><span>on</span><span>:</span></div></div><div><div><div>3</div></div><div><span>  </span><span>schedule</span><span>:</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>- </span><span>cron</span><span>: </span><span>"30 1 * * *"</span><span>  </span><span># UTC 01:30 = 北京时间 09:30</span></div></div><div><div><div>5</div></div><div><span>  </span><span>workflow_dispatch</span><span>:</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>permissions</span><span>:</span></div></div><div><div><div>8</div></div><div><span>  </span><span>contents</span><span>: </span><span>write</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>每天到点跑一遍 <code>tsx scripts/refresh-friends-feed.ts</code>，把抓到的结果写进快照文件并提交；提交触发 Pages 部署，朋友圈就连着快照一起更新了。抓取失败也没事，脚本不会报错，构建回退到旧快照，最多就是有一天没更新。</p></section>
<section><h2>踩坑：两个加载不出来的头像<a href="#踩坑两个加载不出来的头像"><span>#</span></a></h2><p>这轮最折腾的反而不是抓取，是头像。</p><p><strong>BlogsClub 的头像</strong>是对方站点的 favicon，浏览器里直接打开好好的，从我博客里加载就 404。排查了半天，用 curl 带不同 Referer 一个个试，发现它 nginx 按 Referer 做防盗链：从它自己站内点过去 200，从外部站点引过来 404。解决办法两层：友链页和朋友圈页的头像 <code>&lt;img&gt;</code> 全加 <code>referrerpolicy="no-referrer"</code>，同时把它的 favicon 下载下来本地托管（<code>public/assets/images/friends/blogsclub-favicon.png</code>），彻底不依赖对方服务器的心情。</p><p><strong>我自己的头像</strong>也翻车了。<code>profileConfig</code> 里的头像是走 Astro 图片管线优化的，产物文件名带哈希（<code>_astro/avatar.CjtmSIpt.avif</code>），我手填的 <code>/assets/images/avatar.avif</code> 这个固定路径线上根本不存在。解决是复制一份到 <code>public/</code> 目录——public 的文件原样进产物，路径不带哈希。</p><p>这两个坑都是构建/引用模型的特性，跟朋友圈本身没关系，但都是”看起来很简单，实际要绕一下”的典型。</p></section>
<section><h2>改动清单：全部新增文件，不碰主题源码<a href="#改动清单全部新增文件不碰主题源码"><span>#</span></a></h2><p>和之前的魔改一个规矩，方便以后合并 CuteLeaf/Firefly 上游：</p>

<table><thead><tr><th>文件</th><th>作用</th><th>改动类型</th></tr></thead><tbody><tr><td><code>src/pages/moments.astro</code></td><td>朋友圈页面，路由 <code>/moments/</code></td><td>新增</td></tr><tr><td><code>src/utils/friends-feed.ts</code></td><td>RSS 抓取、解析、快照读写</td><td>新增</td></tr><tr><td><code>scripts/refresh-friends-feed.ts</code></td><td>定时任务的执行入口</td><td>新增</td></tr><tr><td><code>.github/workflows/refresh-friends-feed.yml</code></td><td>每天定时抓快照并提交</td><td>新增</td></tr><tr><td><code>src/data/friends-feed-snapshot.json</code></td><td>抓取结果快照，构建兜底用</td><td>新增</td></tr><tr><td><code>src/types/friendsConfig.ts</code></td><td>友链类型加 <code>rss</code> 字段</td><td>小改</td></tr><tr><td><code>src/config/friendsConfig.ts</code></td><td>加自己这条友链 + 注释示例</td><td>小改</td></tr><tr><td><code>src/config/navBarConfig.ts</code></td><td>朋友圈、友链、留言提为一级导航</td><td>小改</td></tr></tbody></table><p>改动之后我顺手做的调整：原来”社交”这个父菜单里塞着友链和留言，现在直接拆了，朋友圈、友链、留言都变成一级导航，首页导航栏一眼能看到朋友圈。</p></section>
<section><h2>效果和一些数据<a href="#效果和一些数据"><span>#</span></a></h2><p>当前 10 条启用的友链，8 个探测成功进了时间线（含我自己），40 篇文章进时间线，<code>/moments/</code> 这页 HTML 约 530KB。这个体积只发生在朋友圈这一页，其他页面完全不受影响——首页、文章页该多快还多快，因为抓取是构建时的事，线上没有任何动态请求。</p><p></p><figure><img alt="朋友圈整页：蓝色&quot;本站主理人&quot;卡片和金色&quot;推荐友链&quot;卡片混在普通卡片里" loading="lazy" width="1004" height="2048" src="/_astro/image-001.BOUKihcO_oglXW.webp" /><figcaption>朋友圈整页：蓝色"本站主理人"卡片和金色"推荐友链"卡片混在普通卡片里</figcaption></figure><p></p><p>页面本身还带点小东西：顶部一张”随机一篇文章”的卡片，5 秒自动换一篇（可以关），配色和边框跟着主题走，深浅色都适配。友链文章多的时候有分页，每页 20 篇。</p></section>
<section><h2>后来：给来源加配额，不让聚合站霸榜<a href="#后来给来源加配额不让聚合站霸榜"><span>#</span></a></h2><p>这篇发出去没几天，我自己刷朋友圈发现了新问题：前排几乎全是博友圈、八零圈、BlogsClub 这几个聚合站，真正的个人博客被挤到后面几页去了。</p><p>原因不在数据，在排序——聚合站更新频率是个人博客的几十倍，按时间倒序它天然霸榜，第一页 20 篇里有 14 篇是社区。查的时候还顺手把随机池数了一遍，它本身就是 6&lt;4&gt;，锅根本不在那。</p><p>于是我给时间线加了一层来源配额：<strong>本站 3 成、Blog 4 成、社区 3 成</strong>，再按配比交错排列，翻到哪一页比例都一样。每个来源的取文上限也顺势从 6 篇提到 12 篇，不然本站只有一个源，凑不出 3 成。</p><p>完整过程和排出来的顺序写在<a href="/posts/blog/momentsmix/">《给朋友圈加了层来源配额》</a>里。</p></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>朋友圈这功能最打动我的地方是它把”友链”从一堆死链接变成了活的：谁更新了、什么时候更新的，一眼看过去就是整个圈子的近况。成本就一次构建多花个几十秒（每天定时任务跑，不占我手）——对静态博客来说，这买卖划算。</p><p>后面如果想再进一步，可以给每条时间线加”只看某个友链”的过滤，或者把快照改成增量只抓有变化的站。不急，先跑一阵子看看。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/homestickercard/</id>
      <title type="text">给博客首页加了张可拖拽贴纸卡片：一次合并友好的魔改</title>
      <published>2026-09-06T00:00:00.000Z</published>
      <updated>2026-09-29T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/homestickercard/"/>
      <summary type="text">看到 rainzt.cn 的首页毛玻璃卡片和能拖动的贴纸羡慕了，扒下来装到自己的 Firefly 上。全程只新增文件不动主题源码，以后还能正常合并上游更新，顺便踩了一堆 CSS 的坑。后来又按性能数据把入场动画拆成两段、关掉了 Swup 视口预取；最后一轮重做了贴纸排布，桌面并成一排、手机收成一行，还加了张动图，并把贴纸改成延迟加载——手机端从 149 KB 降到 47 KB。最近又给卡片加了「开场两秒后自动收起」、右下角的贴纸开关，并让底部那排改成随机抽 10 张、左右各 5 张分列，把正中央让给下滑箭头。</summary>
      <content type="html"><![CDATA[<p>前几天刷到 <a href="https://rainzt.cn" target="_blank">rainzt.cn</a>，首页是一张虚线框毛玻璃卡片，头顶贴着胶带、头像旁边挂了个 “You’re Absolutely Right!” 的小贴纸，底下还有一排 Q 版小人，按住还能拖走。我一瞬间就觉得自己的首页太素了。</p>
<p></p><figure><img alt="rainzt.cn 的首页效果，卡片和贴纸都在壁纸区" loading="lazy" width="1600" height="863" src="/_astro/image-001.BZe_zN5o_2bcgH8.webp" /><figcaption>rainzt.cn 的首页效果，卡片和贴纸都在壁纸区</figcaption></figure><p></p>
<p>更舒服的是，我去翻了翻它的源码仓库 —— ::github{repo=“Jarvis0227/Aemeath”}，发现它也是基于 Firefly 二改的。同一种子，那我抄作业的心就更安了。</p>
<section><h2>先扒站：仓库里根本没有这套代码<a href="#先扒站仓库里根本没有这套代码"><span>#</span></a></h2><p>第一反应是去仓库里找实现，结果 <code>sticker</code>、<code>drag</code> 全搜了一遍，啥都没有。它的 GitHub 仓库落后于线上站，这套装饰层根本没开源进去。</p><p>行吧，那就直接扒线上站。<code>curl</code> 把首页 HTML 拉下来，grep 一把 <code>home-wallpaper-sticker</code>，结构立刻清晰了：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-sL</span><span> </span><span>https://rainzt.cn/</span><span> </span><span>-o</span><span> </span><span>rainzt.html</span></div></div><div><div><div>2</div></div><div><span>grep</span><span> </span><span>-o</span><span> </span><span>'class="[^"]*sticker[^"]*"'</span><span> </span><span>rainzt.html</span><span> | </span><span>sort</span><span> </span><span>-u</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>从线上站一共拆出三块料：</p><ol>
<li><strong>卡片 HTML</strong>：一个 <code>article.home-wallpaper-card</code>，头像、身份行、标题、副标题、导航、彩色标签，全是语义化标签</li>
<li><strong>CSS</strong>：<code>home-wallpaper-decor.css</code>，22KB，从卡片毛玻璃到贴纸投影全带</li>
<li><strong>JS</strong>：一段拖拽脚本，Pointer Events 实现，附带移动端四角随机投放和点击复制 toast</li>
</ol><p>贴纸图片就直接从它站点上下载到自己的 <code>public/images/home-stickers/</code>，一共十几张 webp。</p></section>
<section><h2>集成方案：全放新文件，只留一个挂载点<a href="#集成方案全放新文件只留一个挂载点"><span>#</span></a></h2><p>这里我给自己加了个约束：<strong>以后要合并 CuteLeaf/Firefly 上游的更新</strong>。所以这套东西不能像传统魔改那样把主题文件改得到处都是，不然下次合并就是一场战争。</p><p>最终的结构是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>src/config/homeCardConfig.ts              # 新文件：全部配置</span></div></div><div><div><div>2</div></div><div><span>src/components/features/HomeWallpaperDecor.astro   # 新文件：HTML + CSS + JS 全自包含</span></div></div><div><div><div>3</div></div><div><span>public/images/home-stickers/              # 新目录：贴纸素材</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>主题文件只动了 <code>WallpaperSection.astro</code> 一个，改动是纯增量的一小段：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import HomeWallpaperDecor from "@/components/features/HomeWallpaperDecor.astro";</span></div></div><div><div><div>2</div></div><div><span>import { homeCardConfig } from "@/config/homeCardConfig";</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>const { state, backgroundImages, title, bannerPostMeta } = Astro.props;</span></div></div><div><div><div>5</div></div><div><span>const homeCardEnabled = homeCardConfig.enable === true;</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>{homeCardEnabled &amp;&amp; &lt;HomeWallpaperDecor /&gt;}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>卡片上显示什么也全部收进配置文件，换标题、换社交链接、挪贴纸都不用碰组件：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>homeCardConfig</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>identity</span><span>:</span><span> </span><span>"HY"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>title</span><span>:</span><span> </span><span>"折腾进行时"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>subtitle</span><span>:</span><span> </span><span>"Hello, I'm HY."</span><span>,</span></div></div><div><div><div>6</div></div><div><span>  </span><span>navLinks</span><span>:</span><span> [</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>{ </span><span>name</span><span>:</span><span> </span><span>"文章"</span><span>, </span><span>url</span><span>:</span><span> </span><span>"/archive/"</span><span> },</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>{ </span><span>name</span><span>:</span><span> </span><span>"友链"</span><span>, </span><span>url</span><span>:</span><span> </span><span>"/friends/"</span><span> },</span></div></div><div><div><div>9</div></div><div><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>socials</span><span>:</span><span> [</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>{ </span><span>name</span><span>:</span><span> </span><span>"GitHub"</span><span>, </span><span>icon</span><span>:</span><span> </span><span>"fa7-brands:github"</span><span>, </span><span>url</span><span>:</span><span> </span><span>"https://github.com/hy4962"</span><span> },</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>{ </span><span>name</span><span>:</span><span> </span><span>"Email，点击复制"</span><span>, </span><span>icon</span><span>:</span><span> </span><span>"fa7-solid:envelope"</span><span>, </span><span>copy</span><span>:</span><span> </span><span>"admin@9ll.uk"</span><span> },</span></div></div><div><div><div>14</div></div><div><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>stickers</span><span>:</span><span> [</span></div></div><div><div><div>17</div></div><div><span>    </span><span>// 具体使用时，每一项只需要管这五个值</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>{ </span><span>src</span><span>:</span><span> </span><span>"/images/home-stickers/blonde-idol.webp"</span><span>, </span><span>name</span><span>:</span><span> </span><span>"金发偶像"</span><span>, </span><span>top</span><span>:</span><span> </span><span>20</span><span>, </span><span>left</span><span>:</span><span> </span><span>1.2</span><span>, </span><span>width</span><span>:</span><span> </span><span>112</span><span>, </span><span>rotate</span><span>:</span><span> </span><span>4</span><span> },</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>{ </span><span>src</span><span>:</span><span> </span><span>"/images/home-stickers/blue-witch.webp"</span><span>, </span><span>name</span><span>:</span><span> </span><span>"蓝发魔女"</span><span>, </span><span>bottom</span><span>:</span><span> </span><span>5</span><span>, </span><span>left</span><span>:</span><span> </span><span>1.2</span><span>, </span><span>width</span><span>:</span><span> </span><span>98</span><span>, </span><span>rotate</span><span>:</span><span> </span><span>-</span><span>5</span><span> },</span></div></div><div><div><div>20</div></div><div><span>    </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>以后合并上游，就算 <code>WallpaperSection.astro</code> 被改了，冲突也就几行，手工处理一下就完事。</p></section>
<section><h2>使用指南：怎么改成自己的<a href="#使用指南怎么改成自己的"><span>#</span></a></h2><p>先说清楚数据流：<strong>配置文件只描述内容，组件负责渲染，主题只提供一个挂载点</strong>。日常改内容，你只需要动 <code>homeCardConfig.ts</code> 一个文件。</p><section><h3>换文字和链接<a href="#换文字和链接"><span>#</span></a></h3><ul>
<li><code>identity</code> / <code>title</code> / <code>subtitle</code>：卡片上三行字，留空会自动回退到 <code>profileConfig</code> 和 <code>siteConfig</code> 里的名字、站点标题、签名</li>
<li><code>navLinks</code>：卡片标题下方的快捷导航，数组每一项 <code>{ name, url }</code></li>
<li><code>socials</code>：彩色标签社交按钮，最多 5 个，颜色按顺序自动循环。<code>url</code> 是跳转，<code>copy</code> 是点击复制（邮箱、微信号这种就适合用 <code>copy</code>）</li>
</ul></section><section><h3>改贴纸<a href="#改贴纸"><span>#</span></a></h3><p>场景贴纸每一项的字段含义：</p>

<table><thead><tr><th>字段</th><th>含义</th><th>说明</th></tr></thead><tbody><tr><td><code>src</code></td><td>图片路径</td><td>public 目录的绝对路径，如 <code>/images/home-stickers/xxx.webp</code></td></tr><tr><td><code>name</code></td><td>备注名</td><td>不显示，只是给自己看的</td></tr><tr><td><code>top</code> / <code>bottom</code></td><td>纵向定位</td><td>距容器顶/底的百分比，二选一；一排贴纸用 <code>bottom</code> 才能对齐地面线</td></tr><tr><td><code>left</code></td><td>横向定位</td><td>距容器左侧的百分比</td></tr><tr><td><code>width</code></td><td>基准宽度</td><td>px 为单位，实际渲染按视口自动缩放</td></tr><tr><td><code>rotate</code></td><td>旋转角度</td><td>度数，正数顺时针</td></tr></tbody></table><p>纵向用 <code>top</code> 还是 <code>bottom</code> 取决于贴纸在哪一排：<strong>底部那排全部用 <code>bottom</code></strong>，这样无论贴纸多高，脚都踩在同一条线上；左右两列的上半部分用 <code>top</code>。</p><p>还有一个 1.25 倍的全局缩放系数在组件里（<code>HomeWallpaperDecor.astro</code> 顶部的 <code>STICKER_SCALE</code>），想要所有贴纸一起变大变小改它就行，不用一个个调 <code>width</code>。</p></section><section><h3>替换素材<a href="#替换素材"><span>#</span></a></h3><p>新贴纸丢进 <code>public/images/home-stickers/</code>，然后改对应条目的 <code>src</code>。两个注意点：</p><ol>
<li>图片别太大——贴纸实际只显示 100~140px 宽（配置里的 <code>width</code> 再乘组件顶部的 1.25 倍缩放），源图 <strong>192px 宽就够</strong>，太大只会白烧流量</li>
<li>底部一排的贴纸如果新素材身高差得特别大，地面线是靠 <code>bottom</code> 锚定保证的，不用管高度</li>
</ol></section><section><h3>关闭整套装饰<a href="#关闭整套装饰"><span>#</span></a></h3><p><code>enable: false</code>，组件整个不渲染，默认的横幅文字会自己回来，什么都不用动。</p></section></section>
<section><h2>实现细节<a href="#实现细节"><span>#</span></a></h2><section><h3>显隐：不跟 Swup 纠缠，交给 CSS<a href="#显隐不跟-swup-纠缠交给-css"><span>#</span></a></h3><p>Firefly 的壁纸区在 Swup 容器外面，切页不重渲染。这意味着装饰层不能依赖”重新渲染时判断是不是首页”，得自己处理显隐。</p><p>我最后用的方案是纯 CSS 门控，和主题里滚动指示器的思路一致：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.home-wallpaper-decor</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>opacity: </span><span>0</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>visibility: </span><span>hidden</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></div></div><div><div><div>6</div></div><div><span>html</span><span>[</span><span>data-wallpaper-mode</span><span>=</span><span>"banner"</span><span>] </span><span>body</span><span>.is-home</span><span> </span><span>.home-wallpaper-decor</span><span>,</span></div></div><div><div><div>7</div></div><div><span>html</span><span>[</span><span>data-wallpaper-mode</span><span>=</span><span>"fullscreen"</span><span>] </span><span>body</span><span>.is-home</span><span> </span><span>.home-wallpaper-decor</span><span> {</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>opacity: </span><span>1</span><span>;</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>visibility: </span><span>visible</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>body.is-home</code> 是服务端渲染 + Swup 切页时都会维护的类，<code>data-wallpaper-mode</code> 跟着壁纸模式走。这样切去文章页装饰层自动淡出，回到首页自动出现，一行 JS 都不用写。</p><p>入场动画最初是等卡片图片全部加载完再加 <code>is-ready</code> 触发，动画播完用 <code>animationend</code> 事件打上 <code>is-motion-settled</code> 标记固化终态。另外加了个 <code>MutationObserver</code> 盯着 <code>body</code> 的 class，每次从别的页面回首页时重播一遍入场动画。</p><div><div><div></div><div>这段后来改过</div></div><div><p>“等图片加载完才播动画”这个做法后来被推翻了——它把标题的入场一起拖到了图片下载之后。现在文字和图片走两条独立通道，细节见文末「后续更新」一节。</p></div></div></section></section>
<section><h2>贴纸布局：从随手摆到强迫症对齐<a href="#贴纸布局从随手摆到强迫症对齐"><span>#</span></a></h2><p>贴纸位置全用百分比，但默认值不能随便摆——卡片居中占了大约 34%~66% 的横向空间，导航链接和社交标签都在卡片下半部，贴纸直接压上去会挡住点击。</p><p>最终的布局结构是：<strong>左列（金发偶像上、蓝发魔女下）、右列（竹哒上、初音下）、底部中间一排三个</strong>。宽度用 <code>min()</code> 做响应式收缩：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>width: min(105px</span><span>,</span><span> 5</span></span><span>.4688vw</span><span><span>,</span><span> 11</span></span><span>.5068vh</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>中间排一开始我用 <code>top</code> 定位，结果贴纸身高不一，头顶齐了脚不齐，怎么看怎么别扭。后来把底部一排全改成 <code>bottom</code> 锚定——不管多高，脚都踩在同一条地面线上，瞬间工整了：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// bottom（距底）或 top（距顶）二选一</span></div></div><div><div><div>2</div></div><div><span><span>{ </span><span>name</span><span>: </span></span><span>"蓝发魔女"</span><span><span>, </span><span>bottom</span><span>: </span></span><span>5</span><span><span>, </span><span>left</span><span>: </span></span><span>1.2</span><span><span>, </span><span>width</span><span>: </span></span><span>98</span><span><span>, </span><span>rotate</span><span>: </span></span><span>-</span><span>5</span><span> },</span></div></div></code></pre><div><div></div><div></div></div></figure></div><section><h3>拖拽的实现<a href="#拖拽的实现"><span>#</span></a></h3><p>拖拽用 Pointer Events，鼠标和触屏一套代码。指针按下时记录贴纸相对壁纸容器的坐标，移动时换算成 <code>left/top</code> 并夹在壁纸范围内（<code>Math.max(0, Math.min(容器宽 - 贴纸宽, ...))</code>），配合 <code>setPointerCapture</code> 保证拖出元素外也不丢事件。移动端额外禁掉长按弹菜单的 <code>contextmenu</code> 和 iOS 的 <code>touch-callout</code>。</p><p>头像小贴纸的位移写 CSS 变量而不是 <code>style.transform</code>，这是踩坑 4 的教训——fullscreen 模式下主题有 <code>transform: scale(1.05) !important</code>，内联 transform 打不过它。</p><p>刷新后贴纸回到初始位置是故意的：不做 localStorage 记忆，访客拖乱之后下次进来还是整齐的。</p></section><section><h3>移动端的自适应<a href="#移动端的自适应"><span>#</span></a></h3><p>手机端和桌面端是同一套结构，靠脚本换算：解析每个贴纸初始配置里的 <code>top/bottom/left</code> 识别它属于左列、右列还是中间排，然后按卡片实际边界重排——顶部两张贴卡片上方两侧，底部五个 <code>bottom</code> 锚定在卡片下方，中间三个在左右两列之间等宽槽位居中。布局时机挂 <code>load</code> + <code>setTimeout</code> + 防抖 <code>resize</code> 兜底（后台标签页里 rAF 不触发，这个坑下面细说）。</p></section></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><p>这次踩的坑比写代码的时间还长，值得单独一节。</p><section><h3>坑 1：默认横幅文字阴魂不散<a href="#坑-1默认横幅文字阴魂不散"><span>#</span></a></h3><p>装饰卡片上线后，“World and Life” 那套默认横幅文字还叠在卡片上。我明明在服务端把 <code>showHomeText</code> 关了，HTML 里也确实带着 <code>hidden</code> 类——但页面跑起来之后它又出现了。</p><p>翻主题源码才发现有个 <code>syncBannerHomeTextVisibility()</code>，会在运行时按”是否首页 + 壁纸模式”重新计算并把 <code>hidden</code> 类摘掉。它不认识我的卡片，只知道”首页就该显示横幅文字”。</p><p></p><figure><img alt="文字覆盖层和卡片叠在一起的样子" loading="lazy" width="1600" height="900" src="/_astro/image-002.QL16tPrR_1K7Wap.webp" /><figcaption>文字覆盖层和卡片叠在一起的样子</figcaption></figure><p></p><p>不想去改主题的工具函数，最后用同一条件的 CSS 规则直接压死：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>html</span><span>[</span><span>data-wallpaper-mode</span><span>=</span><span>"banner"</span><span>] </span><span>body</span><span>.is-home</span><span> </span><span>.banner-home-text-overlay</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>display: </span><span>none</span><span> </span><span>!important</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>这段 CSS 在我的组件里，组件不渲染时它也不存在，卡片关掉就一切恢复原状。</p></section><section><h3>坑 2：看不见的卡片盒模型挡住了点击<a href="#坑-2看不见的卡片盒模型挡住了点击"><span>#</span></a></h3><p>调试时发现分类栏的”归档”按钮点不动，Playwright 报错说元素被 <code>article.home-wallpaper-card</code> 盖住了。卡片是透明的，但盒模型是实打实的，社交标签下方还伸出去一截。</p><p>解法是把整个卡片设成 <code>pointer-events: none</code>，再只给真正需要交互的子元素恢复：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.home-wallpaper-card</span><span><span> { pointer-events: </span><span>none</span><span>; }</span></span></div></div><div><div><div>2</div></div><div><span>.home-wallpaper-card__nav</span><span> </span><span>a</span><span>,</span></div></div><div><div><div>3</div></div><div><span>.home-wallpaper-card__social-link</span><span>,</span></div></div><div><div><div>4</div></div><div><span>.home-wallpaper-card__avatar-sticker</span><span> {</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>pointer-events: </span><span>auto</span><span>;</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>坑 3：切到 fullscreen 模式，贴纸三连崩<a href="#坑-3切到-fullscreen-模式贴纸三连崩"><span>#</span></a></h3><p>把壁纸模式切成 <code>fullscreen</code> 之后怪事来了：贴纸变大了一圈、下半截被裁掉、滚动一下还糊成一团。</p><p></p><figure><img alt="fullscreen 模式下贴纸被放大裁切又模糊" loading="lazy" width="1600" height="231" src="/_astro/image-003.De00kf1k_Z1NxPky.webp" /><figcaption>fullscreen 模式下贴纸被放大裁切又模糊</figcaption></figure><p></p><p>查出来是主题在 fullscreen 模式下有针对壁纸容器内<strong>所有</strong> <code>img</code> 的规则，一共三条变体，其中最高的一条长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>html</span><span>[</span><span>data-wallpaper-mode</span><span>=</span><span>"fullscreen"</span><span>][</span><span>data-fullscreen-layout</span><span>=</span><span>"classic"</span><span>] </span><span>#wallpaper-wrapper</span><span> </span><span>img</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>width: </span><span><span>100</span><span>%</span></span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>height: </span><span><span>100</span><span>%</span></span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>object-fit: </span><span>cover</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>filter: </span><span>blur</span><span>(</span><span>var</span><span>(</span><span>--fullscreen-blur</span><span>)) </span><span>!important</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>transform: </span><span>scale</span><span>(</span><span>1.05</span><span>) </span><span>!important</span><span>;</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>它的本意是让壁纸图铺满全屏、滚动时渐变模糊，但贴纸图也是 img，全被扫进去了：<code>cover</code> 负责裁切，<code>scale(1.05)</code> 负责放大，<code>blur</code> 负责糊脸，三个症状一次凑齐。</p><p>修法是在我自己的组件里加更高特异性的豁免规则。主题那条是 <code>(1,2,2)</code>，我借自己容器的类提到 <code>(1,3,2)</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>html</span><span>[</span><span>data-wallpaper-mode</span><span>=</span><span>"fullscreen"</span><span>] </span><span>#wallpaper-wrapper</span><span> </span><span>.home-wallpaper-decor</span><span> </span><span>.home-wallpaper-sticker</span><span> </span><span>img</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>width: </span><span><span>100</span><span>%</span></span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>height: </span><span>auto</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>object-fit: </span><span>contain</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>filter: </span><span>none</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>transform: </span><span>none</span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p></p><figure><img alt="修复后 fullscreen 模式恢复正常" loading="lazy" width="1280" height="720" src="/_astro/image-004.o45ADqJ5_Z7CEcV.webp" /><figcaption>修复后 fullscreen 模式恢复正常</figcaption></figure><p></p></section><section><h3>坑 4：!important 大战之下，拖拽失效了<a href="#坑-4important-大战之下拖拽失效了"><span>#</span></a></h3><p>修上面这个的时候又埋了个雷：<code>transform: none !important</code> 把头像小贴纸的拖拽也废了——JS 拖拽写的是内联 <code>style.transform</code>，在内联样式里 <code>!important</code> 之外，任何样式表的 <code>!important</code> 都能把它按在地上摩擦。</p><p>解法是拖拽位移不走 <code>style.transform</code>，改写 CSS 变量：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.home-wallpaper-card__avatar-sticker</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>transform: </span><span>translate</span><span>(</span><span>var</span><span>(</span><span>--avatar-sticker-tx</span><span>, </span><span><span>0</span><span>px</span></span><span>), </span><span>var</span><span>(</span><span>--avatar-sticker-ty</span><span>, </span><span><span>0</span><span>px</span></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><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// fullscreen 下也照样能拖</span></div></div><div><div><div>2</div></div><div><span><span>sticker</span><span>.</span></span><span>style</span><span>.</span><span>setProperty</span><span>(</span><span>"--avatar-sticker-tx"</span><span>, </span><span>`</span><span>${</span><span>translateX</span><span>}</span><span>px`</span><span>);</span></div></div><div><div><div>3</div></div><div><span><span>sticker</span><span>.</span></span><span>style</span><span>.</span><span>setProperty</span><span>(</span><span>"--avatar-sticker-ty"</span><span>, </span><span>`</span><span>${</span><span>translateY</span><span>}</span><span>px`</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>变量赋值是内联的，规则里带 <code>!important</code> 的 <code>transform</code> 引用的还是这份值，两边不打架。</p></section><section><h3>坑 5：移动端随机投放，是我自己想当然<a href="#坑-5移动端随机投放是我自己想当然"><span>#</span></a></h3><p>一开始照搬参考站的移动端方案：只显示 4 张 <code>mobile: true</code> 的贴纸，随机投放到四块写死的百分比区域。结果上线一看不对劲——区域是死的，卡片却是活的，窄屏下卡片占掉九成宽，两块”侧边区域”直接压在卡片上，一张贴纸骑在社交标签上，另外三张干脆没显示。</p><p></p><figure><img alt="手机端最初的随机投放，贴纸压在卡片上" loading="lazy" width="548" height="1167" src="/_astro/image-005.h7HMDFY2_ZLKDMl.webp" /><figcaption>手机端最初的随机投放，贴纸压在卡片上</figcaption></figure><p></p><p>想明白了就把”随机投放”整个删掉：移动端显示全部贴纸，布局和桌面端同一套结构，坐标由脚本按卡片实际边界换算——顶部两张贴在卡片上方，底部五个 <code>bottom</code> 锚定在卡片下方，中间三个在左右两列之间等分槽位。这样不管屏幕多窄都自适应。</p></section><section><h3>坑 6：布局代码写完了，后台标签页里根本不跑<a href="#坑-6布局代码写完了后台标签页里根本不跑"><span>#</span></a></h3><p>自适应布局写完，我自己截图验证，发现贴纸时对时不对，纯属玄学。排查半天是两个坑叠一起：</p><ol>
<li>布局被包在 <code>requestAnimationFrame</code> 里——后台标签页的 rAF 是不触发的，页面在后台加载时布局永远不执行</li>
<li>依赖 <code>matchMedia</code> 的 <code>change</code> 事件切换布局——部分环境（视口仿真等）这个事件压根不派发</li>
</ol><p>解法：布局改同步执行（读 <code>getBoundingClientRect</code> 本身就会强制布局，不需要 rAF），再补 <code>load</code> 事件、<code>setTimeout</code> 校准和防抖 <code>resize</code> 监听兜底。顺手把贴纸坐标的解析从正则换成了临时元素 + <code>cssText</code>，让浏览器自己拆 <code>inset</code> 简写——之前那个正则压根没算上百分号，一直匹配失败，全靠默认值兜底。</p></section><section><h3>坑 7：贴纸素材太大了<a href="#坑-7贴纸素材太大了"><span>#</span></a></h3><p>做完才反应过来，贴纸原图全是 1086×1448 的高清图，加起来 1.6MB，而它们实际只显示一百来像素宽——下载量是显示需求的十倍。用 ffmpeg 压到 192px 宽之后总共 <strong>126.8 KB</strong>，肉眼看不出差别：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>cd</span><span> </span><span>public/images/home-stickers</span></div></div><div><div><div>2</div></div><div><span>for</span><span> </span><span>f</span><span> </span><span>in</span><span> </span><span>*.webp</span><span>; </span><span>do</span></div></div><div><div><div>3</div></div><div><span>  </span><span>ffmpeg</span><span> </span><span>-y</span><span> </span><span>-loglevel</span><span> </span><span>error</span><span> </span><span>-i</span><span> </span><span>"</span><span>$f</span><span>"</span><span> </span><span>-vf</span><span> </span><span>"scale=192:-1"</span><span> </span><span>"tmp/</span><span>$f</span><span>"</span></div></div><div><div><div>4</div></div><div><span>done</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>八张图现在的实际尺寸和体积：</p>

<table><thead><tr><th>素材</th><th>尺寸</th><th>体积</th></tr></thead><tbody><tr><td>金发偶像</td><td>192×256</td><td>16.2 KB</td></tr><tr><td>洛琪希</td><td>192×256</td><td>20.1 KB</td></tr><tr><td>和泉纱雾</td><td>192×256</td><td>14.2 KB</td></tr><tr><td>祢豆子</td><td>192×256</td><td>17.7 KB</td></tr><tr><td>初音未来</td><td>192×256</td><td>20.9 KB</td></tr><tr><td>鹿目圆</td><td>192×256</td><td>16.2 KB</td></tr><tr><td>御坂美琴</td><td>192×256</td><td>16.0 KB</td></tr><tr><td>Claude Code</td><td>160×115</td><td>5.4 KB</td></tr></tbody></table><p>贴纸显示宽度是配置里的 <code>width</code> 乘上组件顶部的 <code>STICKER_SCALE = 1.25</code>，算下来在 98140px 之间。192px 的源图对这个显示尺寸是 1.42 倍，正好覆盖高清屏，再大就是纯浪费。</p></section></section>
<section><h2>效果<a href="#效果"><span>#</span></a></h2><p>手机端不再是另一套布局：和桌面端同一套结构，脚本按卡片实际位置自适应换算，便签自动居中到顶部：</p><p></p><figure><img alt="手机端效果，全部贴纸自适应排布" loading="lazy" width="390" height="844" src="/_astro/image-006.qI7uPewa_ZyrBNl.webp" /><figcaption>手机端效果，全部贴纸自适应排布</figcaption></figure><p></p><p>拖拽是按下即拖，Pointer Events 鼠标触屏通用，位置限制在壁纸范围内，点击邮箱标签会弹”已复制”的 toast：</p><p></p><figure><img alt="拖拽和复制 toast 的验证" loading="lazy" width="1280" height="720" src="/_astro/image-007.D5tR_fZg_CPWGk.webp" /><figcaption>拖拽和复制 toast 的验证</figcaption></figure><p></p><p>刷新后贴纸会回到初始位置，这点是故意的——位置记忆听着美好，但访客拖乱之后就再也回不去了，参考站也是这么做的。</p></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><ol>
<li><code>curl</code> 抓参考站 HTML，拿到卡片结构、CSS、拖拽 JS 三块料，贴纸图下载到 <code>public/images/home-stickers/</code></li>
<li>新建 <code>src/config/homeCardConfig.ts</code>，卡片内容和贴纸位置全配置化</li>
<li>新建 <code>src/components/features/HomeWallpaperDecor.astro</code>，HTML/CSS/JS 自包含</li>
<li><code>WallpaperSection.astro</code> 里加一个挂载点，同时把 <code>showHomeText</code> 短路掉</li>
<li>显隐交给 <code>body.is-home</code> + <code>data-wallpaper-mode</code> 的 CSS 门控</li>
<li>踩坑修复：压默认文字、放开卡片点击、fullscreen 豁免、CSS 变量拖拽、移动端自适应布局</li>
<li>后续：换自己的贴纸素材、入场动画拆成文字/图片两段、关掉 Swup 视口预取、桌面贴纸并成一排、手机端收成一行、加一张动图、贴纸改成延迟加载</li>
</ol></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>这次最值得记的其实不是贴纸本身，而是”合并友好”这个约束：所有逻辑收进两个新文件，主题只留一个挂载点，连踩坑修复都尽量用自己组件里的高优先级规则去覆盖，而不是直接改主题源码。以后上游更新合并过来，该是啥样还是啥样。</p><p>贴纸素材当时用的还是 rainzt 的演示图，后来换成了自己的一套角色（下面说）。想换成自己的图直接丢进 <code>public/images/home-stickers/</code> 改下配置就行。</p></section>
<section><h2>后续更新<a href="#后续更新"><span>#</span></a></h2><p>上面那一版是 9 月初的形态。后面又折腾了几轮，这里按时间补上，免得文章停在一个过时的状态。</p><section><h3>贴纸换成自己的了<a href="#贴纸换成自己的了"><span>#</span></a></h3><p>原来那批是参考站的演示图，8 张都是常见动漫 Q 版角色。后来换成了自己挑的一套，还是 8 张，布局位置一个没动：</p>

<table><thead><tr><th>素材</th><th>位置</th></tr></thead><tbody><tr><td>金发偶像</td><td>左列上（<code>top: 20</code>）</td></tr><tr><td>洛琪希</td><td>左列下（<code>bottom: 5</code>）</td></tr><tr><td>和泉纱雾</td><td>底部左（<code>bottom: 5, left: 48</code>）</td></tr><tr><td>祢豆子</td><td>右列上（<code>top: 20</code>）</td></tr><tr><td>初音未来</td><td>右列下（<code>bottom: 5, left: 94</code>）</td></tr><tr><td>鹿目圆</td><td>底部中（<code>bottom: 5, left: 61.5</code>）</td></tr><tr><td>御坂美琴</td><td>底部中左（<code>bottom: 5, left: 34.5</code>）</td></tr><tr><td>Claude Code</td><td>底部右（<code>bottom: 5</code>）</td></tr></tbody></table><p>换素材只动了 <code>homeCardConfig.ts</code> 里的 <code>src</code> 和少量 <code>width</code>，组件一行没改——这算是当初把内容全配置化的回报。压缩尺寸也顺便从 360px 收到了 192px，8 张图合计 126.8 KB（原本 330KB，再早是 1.6MB）。</p><p>（这批角色和”左右两列 + 底排”的两排布局后来又整个推翻重做了一轮，见下面。）</p></section><section><h3>入场动画拆成了两段<a href="#入场动画拆成了两段"><span>#</span></a></h3><p>这是被一组性能数据逼出来的改动。我跑了几轮 Lighthouse 测移动端，一直有个数字看不懂：最大元素（LCP）要 4 秒多才被浏览器认定”画好了”，而且查不到是哪张图拖的。</p><p>后来把 Lighthouse 的底层 trace 导出来（145MB）流式解析，才发现 LCP 元素根本不是壁纸、也不是卡片，<strong>是卡片里那行标题文字</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>@ 2614 ms  type=text  size=15194  nodeName=SPAN class='home-wallpaper-card__motion-text'</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>顺着查到两个叠加的原因，都在这套入场动画里：</p><ol>
<li><strong>标题的入场关键帧起点是全透明</strong>。它借用了通用的 <code>home-wallpaper-content-enter</code>，0% 是 <code>opacity: 0</code>；而动画用了 <code>animation-fill-mode: both</code>，意味着动画开始<strong>前</strong>就套用 0% 关键帧——于是它在 0.36 秒的延迟期间一直是全透明的，<strong>Chrome 不把 <code>opacity: 0</code> 的元素算作内容绘制</strong>，LCP 自然被推迟。</li>
<li><strong><code>is-ready</code> 要等图片</strong>。前面写的那句”等卡片图片加载完再加 <code>is-ready</code>”，把标题的入场也一起绑在了图片下载上。</li>
</ol><p>改法是两刀：</p><ul>
<li>给标题单独一版关键帧，起点直接 <code>opacity: 1</code>，只保留从下往上滑 12px 的位移；延迟从 <code>0.36s</code> 收到 <code>0.08s</code>。视觉上还是”滑上来”，但第一帧就算画好了。</li>
<li>入场逻辑拆两段：文字动画不再等图，两帧之后直接放行；图片单独走一条 <code>is-media-ready</code> 通道，只控制自己 0.24 秒的淡入。</li>
</ul><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>@keyframes</span><span><span> </span><span>home-wallpaper-title-enter</span><span> {</span></span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>0% { opacity: </span><span>1</span><span>; transform: </span><span>translateY</span><span>(</span><span><span>12</span><span>px</span></span><span>); }</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>to { opacity: </span><span>1</span><span>; transform: </span><span>translateY</span><span>(</span><span>0</span><span>); }</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div><div><div><div>5</div></div><div><span>.home-wallpaper-decor.is-ready</span><span> </span><span>.home-wallpaper-card</span><span> </span><span>h1</span><span> {</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>animation-name: home-wallpaper-title-enter;</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>animation-delay: </span><span><span>0.08</span><span>s</span></span><span>;</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>拆图片通道时留了个安全垫：图片的隐藏规则写成 <code>.is-ready:not(.is-media-ready)</code>，只有 JS 确实接管、入场已启动时才隐藏它。这样脚本失效、或者访客开了”减弱动态效果”，图片都是直接可见的，不会永久空白一块。</p></section><section><h3>顺手关掉了 Swup 的视口预取<a href="#顺手关掉了-swup-的视口预取"><span>#</span></a></h3><p>同一次体检里还挖出一笔大流量：Swup 的预取插件默认会<strong>把导航里露出来的链接全部提前下载</strong>。首页第一屏正好有 7 个链接满足条件（卡片导航 5 个 + RSS / 打赏），移动端一次抓回 483KB 页面 HTML，占总流量 34%。</p><p>改法是把它还原成上游那行 <code>preload: true</code>。这行配置的完整语义是”hover 预取开、视口预取关”，跟我想要的 <code>visible: false</code> 运行时完全等价，但<strong>和上游字节一致</strong>，以后合并上游连 diff 都不会产生——这是这次所有优化里唯一一条能减少合并负担的改动。</p><p>效果（部署后实测）：</p>

<table><thead><tr><th>指标</th><th>改前</th><th>改后</th></tr></thead><tbody><tr><td>移动端 perf</td><td>64</td><td>71</td></tr><tr><td>FCP</td><td>3968 ms</td><td>3006 ms（−24%）</td></tr><tr><td>LCP</td><td>5280 ms</td><td>4506 ms（−15%）</td></tr><tr><td>总传输</td><td>1405 KiB</td><td>916 KiB（−35%）</td></tr><tr><td>预取页面数</td><td>16</td><td>1</td></tr></tbody></table><p>桌面端分数没变（都是 89），但传输量从 2177 KiB 掉到 1142 KiB。桌面模拟带宽高，那 1MB 预取本来就不在关键路径上——所以桌面的收获是<strong>省流量，不是提分</strong>，这点得说清楚。</p><p>所有这些改动依然遵守着”不碰主题源码”的规矩：新增的关键帧、通道类名、门控规则全在 <code>HomeWallpaperDecor.astro</code> 里，这个文件上游根本没有，改它零冲突。<code>astro.config.mjs</code> 那行还反倒更接近上游了。</p></section><section><h3>贴纸并成一排，手机也收成一行<a href="#贴纸并成一排手机也收成一行"><span>#</span></a></h3><p>底排那六张错位贴纸看久了还是觉得不如一排整齐，索性全铺平：桌面端 11 张全部 <code>bottom: 5</code>，脚踩同一条地面线。顺手裁掉两张（夏娜蜜瓜包、露易丝女仆），又加进来一张动图，于是”左右两列 + 底排”这套布局算是彻底下架了。</p><p>摆到一条线上才发现”等距”没那么简单。先按 <code>left</code> 等距排，看着还是歪。原因是各张基准宽度不一样，而图片在盒子里是居中的（宽盒子遇到 <code>img { max-height: 104px }</code> 会上下裁、内容收窄），<code>left</code> 等距换来的是视觉中心不等距，1920 宽下最多能漂 ±11px。改成按视觉中心算就正了：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>中心 C_i = 5 + i × 90 / (n - 1)              // 从 5% 铺到 95%</span></div></div><div><div><div>2</div></div><div><span>left_i   = C_i - width_i × 0.0651042 / 2     // 盒宽% = width × 1.25 / 1920 × 100</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>还有个教训：<strong>张数一变必须重算所有 <code>left</code></strong>，不能只挪动周围两张。从 12 张加到 13 张那次，中心间距从 8.18% 掉到 7.5%，原本够宽的贴纸立刻擦边。换算下来有个闸门：相邻两张 <code>width</code> 之和 ≲ <code>2764.8 / (张数 − 1)</code>。11 张是 276、12 张 251、13 张只剩 230。</p><p>这是”盒子刚好相切”的值，实际要按八折用——<code>rotate</code> 的那 ±3~6° 会把图横向撑出来。</p><p>真正管住碰撞的其实是<strong>可见宽度</strong>，不是盒子宽度。<code>img { width: 100%; max-height: 104px; object-fit: contain }</code> 意味着 <code>可见宽 = min(盒宽, 104 / 宽高比)</code>。抱臂那张高宽比 2.16，盒子给多宽也只显示 48px，压根不参与碰撞。所以 13 张那次我是把几张扁图从 116/112/110 压到 100 才腾出的空间，而<strong>在 1920 上观感完全没变</strong>——它们的实际大小本来由 <code>max-height</code> 决定。后来裁回 11 张，又把这些 <code>width</code> 还了回去：1440 / 1024 上盒子才是限制因素，留着临时瘦身会肉眼可见地小一圈。</p><p></p><figure><img alt="11 张贴纸在桌面端铺成一排" loading="lazy" width="1600" height="900" src="/_astro/image-008.BsDEckat_1ebsMM.webp" /><figcaption>11 张贴纸在桌面端铺成一排</figcaption></figure><p></p><p>手机端也被我改了一轮。原本随机抽 8 张、每行放 5 张，于是分两行、平分 4+4。想改成一行，改完发现 <code>mobileStickerLimit</code> 不是一个随便填的数——<strong>它直接决定排几行</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>每行张数 = floor(97.6 / (尺寸 / 屏宽 × 100 + 1.6))</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>尺寸从 68px 往下试，只要”行数 × 行高”塞得进卡片下方的安全区就定案，<strong>它不会为了凑成一行主动去选更小的尺寸</strong>。68px 时 390 宽能放 5 张，360 宽掉到 4 张。</p><p>所以填 5 是假的：在 390px 的机器上看着是一行，换台 360px 的立刻翻回两行。要”任意机型都一行”只能填 4，窄到 320px（iPhone SE）也还是完整一行。想让 8 张挤进一行得压到 41px 以下，而尺寸候选表最小就是 42px——物理上不可能。</p><p>代价是从 8 张变 4 张。我倒是觉得更好：4 张 68px 铺满一行，比 8 张 50px 挤两行干净。</p><p></p><figure><img alt="手机端随机 4 张贴纸铺成一行" loading="lazy" width="390" height="844" src="/_astro/image-009.BIM6XzdR_Z1xG1f4.webp" /><figcaption>手机端随机 4 张贴纸铺成一行</figcaption></figure><p></p><p>同一天还把卡片上那张”欢迎访问”的便签去掉了——<code>note.enable</code> 设成 <code>false</code>，组件直接不渲染这个节点。这里有个小坑：不能靠清空文字来”隐藏”，便签的黄色方块是 CSS 给的固定尺寸（<code>width: 84px; min-height: 63px; background: #fff1a8</code>），文字空了方块还在；想用 <code>top</code> 挪到 999% 又会往页面外撑出滚动条。</p></section><section><h3>第一张动图贴纸，以及一次翻车的压缩验证<a href="#第一张动图贴纸以及一次翻车的压缩验证"><span>#</span></a></h3><p>11 张里有一张是动的。手上正好有个 6 帧的甩发 GIF（160×143、50ms 一帧、GIF89a 自带透明），就顺手做成了动画 webp——组件本来就是裸 <code>&lt;img src&gt;</code>，浏览器自己会播。</p><p>转换踩到两个坑：</p><ol>
<li><strong>所有帧要按”并集 bbox”统一裁剪</strong>，不能每帧各裁各的。各自裁会让头发甩出去时整只跟着位移，看起来像画面在抖。</li>
<li><strong>缩放必须走预乘 alpha</strong>。直接对 RGBA 跑 LANCZOS，透明区的黑 (0,0,0) 会渗进边缘，红头发外圈会挂一圈黑边。PIL 不做预乘，这一步得自己实现。</li>
</ol><p>做完一看体积 60.2 KB，是普通静态贴纸（8~16 KB）的四倍多，于是开始压。压的过程比转换本身有意思：</p>

<table><thead><tr><th>手段</th><th>结果</th><th>值不值</th></tr></thead><tbody><tr><td><code>alpha_quality</code> 100 → 70</td><td>q90 档 60.2 → 51.3 KB，q75 档 42.6 → 33.6 KB</td><td>白捡的，见下</td></tr><tr><td><code>quality</code> 90 → 70</td><td>51.3 → 32.3 KB</td><td>也白捡</td></tr><tr><td>高 192 → 176</td><td>再省 12%（33.6 → 29.7 KB）</td><td>看不出来，但为 2.6 KB 不值</td></tr><tr><td>高 192 → 原尺寸 139</td><td>19.8 KB（−41%）</td><td>翻车在这里，下面说</td></tr><tr><td>6 帧减到 4 帧</td><td>−7 KB</td><td>动感变段落化</td></tr></tbody></table><p><code>alpha_quality</code> 之所以基本免费，是因为这张图的 alpha 几乎只有 0 和 255 两个值，有损压缩根本碰不到它——实测 alpha 通道平均差 0.19、差超过 10 的像素只占 <strong>0.036%</strong>。</p><p>质量那条曲线在 q50~q70 这段几乎是平的（28.1 / 29.1 / 30.1 / 31.2 / 32.3 KB，从 q50 到 q70 一共只涨 4.2 KB）。也就是说<strong>字节是帧数据本身，不是质量参数</strong>，想再小只能动尺寸或帧数。</p><p>至于翻车的那条：我第一轮对比是用 LANCZOS 把各版本放大到显示尺寸看的，结论是”降到原尺寸也没差”。<strong>这个结论是错的</strong>。贴纸显示高 104 CSS px，2x 屏要 208 设备像素，139 的源图得靠浏览器放大 1.5 倍；而浏览器放大图像用的是双线性级别，<strong>没有 LANCZOS 那么好</strong>。改用 <code>BILINEAR</code> 模拟 2x 屏、再 nearest 放大 4 倍去看，原尺寸那版的描边明显发虚。</p><p>所以最终定在 202×192 / q70 / <code>alpha_quality</code> 70 / 6 帧 = <strong>32.3 KB</strong>，比一开始省 46%，和别的贴纸同分辨率、放大四倍看不出差别。这个”验压缩要先模拟浏览器的滤镜”的坑值得单独记一笔。</p></section><section><h3>贴纸改成延迟加载：手机端从 149 KB 降到 47 KB<a href="#贴纸改成延迟加载手机端从-149-kb-降到-47-kb"><span>#</span></a></h3><p>这个数字是查了才知道的：手机端随机显示 4 张，实际下载的却是全部 11 张加头像，一共 149 KB，然后扔掉三分之二。</p><p>因为服务端输出的是一整排 <code>&lt;img src=... loading="eager"&gt;</code>，随机抽签是页面加载完之后在 JS 里切 <code>display</code> 的——<code>display: none</code> 不阻止下载，抽签也救不了已经发出去的请求。更糟的是这 149 KB 全带着 <code>eager</code>，和壁纸（手机端 220 KB，就是 LCP 元素）抢带宽，等于在首屏最要命的时刻插队。</p><p>改法是服务端不输出 <code>src</code>，只留 <code>data-home-wallpaper-src</code>（这样首屏一个贴纸请求都不会发），赋值交给脚本：</p>

<table><thead><tr><th>场景</th><th>行为</th></tr></thead><tbody><tr><td>手机端</td><td>布局跑完、<code>display</code> 定好之后，<strong>只给抽中的那几张赋 <code>src</code></strong></td></tr><tr><td>桌面端</td><td>等 <code>window.load</code> 之后再统一赋值，避开壁纸的关键路径</td></tr><tr><td>手机 → 桌面 resize</td><td>补齐没加载的</td></tr><tr><td>禁用 JS</td><td><code>&lt;noscript&gt;</code> 里另有一份带真实 <code>src</code> 的贴纸兜底</td></tr></tbody></table><p>实测：</p>

<table><thead><tr><th>视口</th><th>请求数</th><th>字节</th></tr></thead><tbody><tr><td>390×844</td><td><strong>5</strong>（4 张抽中的 + 头像）</td><td><strong>≈47 KB</strong></td></tr><tr><td>1920×1080</td><td>12（11 张 + 头像）</td><td>149 KB，但都在 <code>window.load</code> 之后</td></tr></tbody></table><p>手机端少下 68%，桌面端字节没变但让开了首屏。</p><p>几个连带处理：图片没 <code>src</code> 时高度是 0，所以给 <code>img</code> 加了 <code>opacity: 0</code> 和 <code>img[src] { opacity: 1 }</code> 的淡入，盖住盒子从 0 长到 104px 那一下；手机端解码完要重排一次，否则盒子高度只能拿估算值；从手机 resize 到桌面时得把没加载的补上。</p><p>验证方式也换了一个。<code>--log-net-log</code> 在本机打不开文件，改成临时插一个 <code>?sticker-debug=1</code> 门控的覆盖层，读 <code>performance.getEntriesByType("resource")</code> 里 <code>home-stickers</code> 的条数和 <code>encodedBodySize</code>，截图读数之后再把那段删掉。</p></section><section><h3>卡片开场两秒，然后把位置让给壁纸<a href="#卡片开场两秒然后把位置让给壁纸"><span>#</span></a></h3><p>卡片太抢眼了，壁纸反而成了背景板。于是想要个”开场”：卡片完整亮两秒，然后整体向右滑出屏幕，只留壁纸和贴纸；右边缘挂一根竖排小签，点它还能把卡片叫回来；不想等就点右上角那颗半透明的 <code>−</code>（用减号不用叉，它不是”永久关掉”）。</p><p>听着简单，写起来卡在两个地方。</p><p>第一个是 <strong>CSS animation 的终态压住了后面所有的 transition</strong>。卡片入场用的是 <code>animation: ... both</code>，<code>fill: both</code> 会把动画最后一帧一直挂在元素上，而动画声明的优先级高于普通声明——不先把这段动画摘掉，<code>transition</code> 怎么写都推不动，表现就是”点了没反应，卡片直接瞬移”。所以收起前得先给容器加 <code>is-entrance-done</code> 摘掉卡片自身的动画，再强制一次样式计算（<code>getBoundingClientRect()</code> 之类），位移才真的走得动。</p><p>顺带一个 reduced-motion 的坑：开了”减少动态效果”时动画是 <code>none</code>，<strong>永远不会有 <code>animationend</code></strong>，卡片也就拿不到 <code>is-motion-settled</code>；而收起态那条规则原本靠这层类去压 <code>opacity: 1 !important</code>，同特异性下被更靠后的规则反压——结果是”卡片移出屏幕了却还算亮着”。修法是收起态补一条 <code>is-entrance-done.is-collapsed</code> 的四类组合（0-4-0），不再依赖任何动画事件。</p><p>第二个是<strong>只做水平位移、不做缩放</strong>。手机端那套贴纸布局读的是卡片 rect 的上下边界来决定贴纸落点，只横着挪的话这两个值完全不变——实测收起前后 11 张（手机 4 张）坐标零漂移，不需要重排。要是加了缩放，这套就得整个推翻重算。</p><p>还有个配置上的洁癖：一开始配置里写的是 <code>delay: 3900</code>，意思是”从入场开始算 3.9 秒后收起”——这个数是 1.41s 入场动画加 2.5s 停留手算出来的。以后谁改一下入场节奏，它就悄悄错了，而且新会话里根本看不出 3900 是哪来的。改成了只填语义化的一个 <code>hold: 2000</code>（入场动画落定之后再停留多久），总时长交给脚本监听 <code>animationend</code> 现算。</p><p>“只播一次”的判定也有讲究：用脚本作用域里的<strong>内存变量</strong>，不用 <code>sessionStorage</code>。这个脚本带 <code>data-swup-ignore-script</code> 只在整页加载时跑一次，壁纸区又在 Swup 容器外不会重渲染——于是刷新会重播、从文章返回首页不会重播。用 <code>sessionStorage</code> 反而会把刷新也算进同一次会话，正好和想要的相反。</p></section><section><h3>右下角加了颗「隐藏贴纸」开关<a href="#右下角加了颗隐藏贴纸开关"><span>#</span></a></h3><p>有时就想纯看壁纸。右下角加了一颗毛玻璃圆钮（<code>fa7-solid:eye</code> / <code>eye-slash</code>），点一下整排收走、再点放回，状态存 localStorage，刷新切页都记得住。图标表状态、<code>aria-label</code> 表动作、配 <code>aria-pressed</code>。</p><p>效果是逐张错开的：收起时从左往右一张张淡出，放回时从右往左接上（每张错开 40ms）。JS 只切一个 <code>is-stickers-hidden</code> 类，节奏全在 CSS，靠每张贴纸的 <code>--home-wallpaper-sticker-i</code> 算延迟。</p><p>这里有个很容易写错的点：<strong>两个方向的 transition 要各写各的</strong>。CSS 的 <code>transition</code> 读的是”变化之后”那条声明——收起方向的延迟得写在 <code>.is-stickers-hidden</code> 那条规则里，放回方向写在 <code>.home-wallpaper-sticker</code> 基础规则里。写在一处的话，总有一个方向没有级联。</p><p>然后踩到一个症状极具欺骗性的 bug。我第一版在隐藏规则里写了 <code>animation: none</code>，想着”都藏起来了还跑什么入场动画”。后果是：页面加载时如果 localStorage 记着”已隐藏”，贴纸的入场动画被掐掉 → 不触发 <code>animationend</code> → 拿不到 <code>is-motion-settled</code> → 等用户点”放回”时入场动画重新生效（0.52s 延迟 + 0.76s），把右先左后的级联整个盖掉。</p><p><strong>症状是「刷新过之后放回没有逐张效果，但当天首次加载时是好的」</strong>——第一次跑全流程 7 项断言只挂这一项，差点当成探针误差放过去。修法是干脆不写 <code>animation: none</code>：<code>opacity: 0 !important</code> 本来就压得住动画（<code>!important</code> 声明的优先级高于动画），让它照常跑，状态就统一了。</p><p>另外选择器要多挂一层 <code>.home-wallpaper-decor__stickers</code> 凑到四个类，否则压不过入场动画那条同为 (0,4,0) 的规则——同特异性就得靠源码顺序，太脆。</p></section><section><h3>贴纸给下滑箭头让路（顺带一个被否掉的方案）<a href="#贴纸给下滑箭头让路顺带一个被否掉的方案"><span>#</span></a></h3><p>全屏壁纸模式正中央有颗往下的小箭头，提示可以往下滚。它被贴纸盖住了——不是位置问题，是层级：贴纸层 <code>.home-wallpaper-decor</code> 是 <code>z-index: 25</code>，箭头只有 <code>10</code>，而居中那张小人正好压在它上面（实测重叠 76×64，几乎完全盖住）。</p><p>我第一反应是把箭头抬到 <code>30</code>，顺便给它一块毛玻璃圆底，这样压在贴纸上也像颗按钮。<strong>做出来就被否了</strong>：中央那张贴纸身上凭空多一颗圆钮，不好看。</p><p>于是改成<strong>让贴纸让路</strong>，箭头一行 CSS 都没动：桌面端底部这排从 11 张素材里<strong>每次刷新随机抽 10 张</strong>，左右各 5 张分列，正中央空出 20%（<code>homeCardConfig.desktopStickerRow</code> 的 <code>count</code> / <code>centerGap</code>）。抽中的仍按原本的相对顺序排，不重新洗牌——洗了会左右跨位，看着乱。</p><p></p><figure><img alt="贴纸改成随机 10 张、左右各 5 张分列，正中央留给下滑箭头" loading="lazy" width="1280" height="800" src="/_astro/image-010.ClxAAC5Z_2hGBt0.webp" /><figcaption>贴纸改成随机 10 张、左右各 5 张分列，正中央留给下滑箭头</figcaption></figure><p></p><p>实测两侧净空：1280 宽下 46 / 50px，1024 宽下 29 / 31px，箭头完整露出来，贴纸一张没少看（只是每次少露一张）。</p><p>写这段时踩了四个坑，都挺隐蔽：</p><ol>
<li><strong>抽签结果必须缓存。</strong> 布局在 <code>load</code>、400ms 定时、<code>resize</code> 时会重算好几次，不缓存就每次重抽，贴纸在眼前来回跳。</li>
<li><strong>改完位置必须写回 <code>data-home-wallpaper-initial-style</code>。</strong> 拖拽还原是整条 <code>style</code> 覆盖，手机端排布也读它来分带——只改 <code>style</code> 不改这个属性，拖一下或者转手机就打回原形。</li>
<li><strong>序号要按”看得见的张数”重编。</strong> <code>--home-wallpaper-sticker-i</code> 和 <code>--home-wallpaper-sticker-count</code> 都得跟着改成 10，否则整排收起/放回的错开延迟按 11 张算，最后一张会平白多等一拍。</li>
<li><strong><code>restoreDesktopStickerPositions</code> 会把 <code>display: none</code> 一起抹掉。</strong> 它是拿 initial-style 整条覆盖 inline style 的，所以顺序必须是”还原 → 重新抽签并隐藏 → 矮窗口压缩”，而且矮窗口压缩那个分支也得读同一份抽签结果，否则它会把被抽掉的贴纸重新放出来。</li>
</ol><p>顺带一个性能上的小便宜：被抽掉的那张不给 <code>src</code>，一整页少一个贴纸请求。</p></section><section><h3>还没解决的<a href="#还没解决的"><span>#</span></a></h3><p>说个不太好看的：移动端那 2 秒的白屏直到现在也没完全解释清楚。trace 里能看到渲染阻塞的 CSS 在 612ms 就全部就绪了，但 FCP 还是拖到 2655ms——中间这段空窗目前只能怀疑是字体或主题运行时脚本，没定位到确切的元凶。</p><p>所以性能这块还没收工，下次再测。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/lucky-esa-reverse-proxy/</id>
      <title type="text">Lucky + 阿里云 ESA 反代家里内网服务：一个备案域名实现 IPv4/IPv6 双栈</title>
      <published>2026-09-04T00:00:00.000Z</published>
      <updated>2026-09-11T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/lucky-esa-reverse-proxy/"/>
      <summary type="text">用阿里云 ESA 当国内加速入口，Lucky 负责反代家里内网服务：DDNS、证书、回源规则、CNAME 解析一套流程记录，顺便踩了回源 HOST 和证书配置的坑。和 Cloudflare + Lucky 那篇是同一原理，边缘节点换到国内，同样实现 IPv4/IPv6 双栈访问。</summary>
      <content type="html"><![CDATA[<p>想在外面访问家里的内网服务？之前写过一篇 <a href="/posts/network/ipv6-cloudflare-lucky/">Cloudflare + Lucky 的方案</a>，能用是能用，但 CF 的节点都在境外，国内访问速度基本看运气。这次换成阿里云 ESA（边缘安全加速平台），节点在国内，走的是正经加速线路。</p>
<p>先说清楚，这俩其实是<strong>同一套原理</strong>：家里只有 IPv6 出口，就找一个大厂的边缘节点当门面——边缘节点自己是双栈的，外面的客户端不管走 IPv4 还是 IPv6 都能连上，再由边缘节点回源到家里的 IPv6。CF 那篇用 Cloudflare 当边缘，这篇用 ESA 当边缘，区别只是节点在境内还是境外。所以这篇同样实现了 <strong>IPv4 / IPv6 双栈访问</strong>，外面只有 IPv4 的老设备照样能进。</p>
<p>前提条件就一个：<strong>在国内云服务商那里有一个已经备案的域名</strong>。ESA 要接备案域名，没备案的可以直接关掉这个页面了。</p>
<p>买域名、开通 ESA、接入 ESA 这些就不啰嗦了。接入方式我选的是 CNAME，比 NS 接入更通用一些，DNS 控制权还在自己手里。</p>
<section><h2>双栈是怎么来的<a href="#双栈是怎么来的"><span>#</span></a></h2><p>一句话：<strong>边缘双栈，回源单栈</strong>。</p><p>家里宽带只有 IPv6，所以第一步是给 <code>v6.xxx.com</code> 配一条 AAAA 记录，ESA 回源的时候就顺着这条 IPv6 走。而访客那一侧连的是 ESA 边缘节点，ESA 边缘本身 IPv4、IPv6 都收，于是整条路就通成了：外面不管什么网络进来，先落到 ESA 节点，再由 ESA 用 IPv6 回你家。</p><p>这也是它和 CF 那篇共享的那套思路，唯一要记的就是：<strong>回源那条解析必须是 IPv6（AAAA），门面这层交给 ESA 去双栈</strong>。</p></section>
<section><h2>第一步：Lucky 配一个阿里云 DDNS<a href="#第一步lucky-配一个阿里云-ddns"><span>#</span></a></h2><p>家里宽带有 IPv6，所以先让 Lucky 把家里的 IPv6 地址同步到域名上。DDNS 服务商选<strong>阿里云</strong>，域名假设叫 <code>v6.xxx.com</code>，类型 AAAA，IP 来源网卡 IPv6。</p><p></p><figure><img alt="Lucky 动态域名任务列表：aliyun-v6 任务通过网卡 IPv6 地址同步 AAAA 记录" loading="lazy" width="1669" height="944" src="/_astro/image-001.BSd2QfEy_Z2rzqHj.webp" /><figcaption>Lucky 动态域名任务列表：aliyun-v6 任务通过网卡 IPv6 地址同步 AAAA 记录</figcaption></figure><p></p><p>这步和之前 Cloudflare 那篇基本一样，就是服务商从 Cloudflare 换成阿里云，去 RAM 里建个 AccessKey 填进去就行。</p></section>
<section><h2>第二步：Web 服务反代<a href="#第二步web-服务反代"><span>#</span></a></h2><p>Lucky → Web 服务，添加反代规则：</p><ul>
<li>监听端口：<code>16666</code>，后面 ESA 里回源也要<strong>全部指向 16666</strong></li>
<li>前端：你的域名（比如 <code>fnos.xxx.com</code>）</li>
<li>后端：内网服务的地址（比如 <code>http://192.168.31.31:8080</code>）</li>
</ul><p></p><figure><img alt="Lucky Web 服务规则列表：监听 16666 端口，多条反代规则把域名指向内网各服务" loading="lazy" width="1969" height="553" src="/_astro/image-002.CCzdW97A_ZIlCcY.webp" /><figcaption>Lucky Web 服务规则列表：监听 16666 端口，多条反代规则把域名指向内网各服务</figcaption></figure><p></p></section>
<section><h2>第三步：两端配置证书（双自动续签，不用手动折腾）<a href="#第三步两端配置证书双自动续签不用手动折腾"><span>#</span></a></h2><p>证书这块之前我踩了很大的坑，把事情搞复杂了，这里特地重新说一下。</p><section><h3>1. Lucky 这边：ACME 自动申请<a href="#1-lucky-这边acme-自动申请"><span>#</span></a></h3><p>在 Lucky 申请证书，添加方式选 ACME，颁发机构 Let’s Encrypt，验证方式选阿里云（填 AccessKey 就能自动过 DNS 验证）。</p><p></p><figure><img alt="Lucky 添加证书：ACME 方式，Let's Encrypt 颁发，验证方式选阿里云" loading="lazy" width="705" height="1141" src="/_astro/image-003.QHl-YGUT_Z8pAAS.webp" /><figcaption>Lucky 添加证书：ACME 方式，Let's Encrypt 颁发，验证方式选阿里云</figcaption></figure><p></p><p>Lucky 本身申请的就是 Let’s Encrypt 证书，<strong>到期它自己会自动续签</strong>，压根不用人工干预。</p></section><section><h3>2. ESA 这边：直接申请免费证书，也有自动续签<a href="#2-esa-这边直接申请免费证书也有自动续签"><span>#</span></a></h3><p>我之前写的时候，以为 ESA 免费证书不能下载给 Lucky 用，而且在申请免费证书的时候发现输入框选不了一级域名，才觉得不行，于是大费周章地把 Lucky 里的证书下载出来，跑去 ESA 点“上传自定义证书”，还提醒大家以后记得手动来续签……</p><p>后来我才发现完全不用这么麻烦！<strong>阿里云 ESA 本身就可以直接申请免费证书，而且是平台自动签发、自动续期的！</strong></p><p>之前之所以觉得选不了一级域名，是因为被阿里云控制台的交互给坑了：在「申请免费证书」的证书域名输入框里，它默认不会把一级域名弹出来，<strong>你必须手动把一级域名完整打全</strong>，下面才会弹出匹配选项供你勾选！</p><p></p><figure><img alt="ESA 申请免费证书：证书域名需手动打全一级域名，即可选中并由平台自动签发与续期" loading="lazy" width="1024" height="677" src="/_astro/image-004.SHKq1hoZ_Z1V3Iab.webp" /><figcaption>ESA 申请免费证书：证书域名需手动打全一级域名，即可选中并由平台自动签发与续期</figcaption></figure><p></p><p>选好之后直接确定就行。</p><p>这样两头的证书就彻底顺了：</p><ul>
<li><strong>Lucky 负责内网这一侧</strong>：自己走 ACME 自动申请并自动续期；</li>
<li><strong>阿里云 ESA 负责外网边缘节点</strong>：平台免费证书自动签发并自动续期。</li>
</ul><p>两边各自全自动闭环，谁也不用导谁的证书，以后再也不用担心证书过期要手动去更新了。</p></section></section>
<section><h2>第四步：添加回源规则<a href="#第四步添加回源规则"><span>#</span></a></h2><p>ESA → 规则 → 回源规则，新建一条：</p><ul>
<li>匹配：<strong>所有传入请求</strong></li>
<li>回源协议：<strong>跟随客户端</strong></li>
<li>HTTP 端口和 HTTPS 端口：都填 <code>16666</code></li>
</ul><p></p><figure><img alt="ESA 编辑回源规则：所有传入请求，回源协议跟随客户端，HTTP/HTTPS 端口均为 16666" loading="lazy" width="1516" height="933" src="/_astro/cover.B_ZJWPrt_Z1SJxgH.webp" /><figcaption>ESA 编辑回源规则：所有传入请求，回源协议跟随客户端，HTTP/HTTPS 端口均为 16666</figcaption></figure><p></p></section>
<section><h2>第五步：ESA 域名解析 + DNS 的 CNAME 解析<a href="#第五步esa-域名解析--dns-的-cname-解析"><span>#</span></a></h2><p>先在 ESA 里给站点添加记录，类型 CNAME，记录值指向第一步的 <code>v6.xxx.com</code>。</p><p>这里有个天大的坑：</p><p><strong>回源 HOST 一定要选「跟随请求 HOST」！！！</strong></p><p><strong>回源 HOST 一定要选「跟随请求 HOST」！！！</strong></p><p><strong>回源 HOST 一定要选「跟随请求 HOST」！！！</strong></p><p></p><figure><img alt="ESA 添加记录：CNAME 类型指向 v6.xxx.com，回源 HOST 选择「跟随请求 HOST」" loading="lazy" width="1151" height="691" src="/_astro/image-006.DFagzTct_Z2rzFMl.webp" /><figcaption>ESA 添加记录：CNAME 类型指向 v6.xxx.com，回源 HOST 选择「跟随请求 HOST」</figcaption></figure><p></p><p>否则 ESA 回源时带的 HOST 就不是你在 Lucky 里设置的那个前端域名，Lucky 收到请求压根不知道你要访问哪个服务，直接就 404 了。</p><p>然后去你 DNS 服务商那里，给要用的子域名加一条 CNAME，指向 ESA 分配的接入域名，完事。</p><p></p><figure><img alt="阿里云 DNS 解析记录列表：api 和 fnos 两条 CNAME 记录已生效" loading="lazy" width="2048" height="662" src="/_astro/image-007.UH3nbmXa_Z1fSSbt.webp" /><figcaption>阿里云 DNS 解析记录列表：api 和 fnos 两条 CNAME 记录已生效</figcaption></figure><p></p></section>
<section><h2>可选：强制 HTTPS<a href="#可选强制-https"><span>#</span></a></h2><p>还在 SSL/TLS → 边缘证书页面，把「强制 HTTPS」开关打开，访客走 HTTP 也会被 301 到 HTTPS，建议开着。</p><p></p><figure><img alt="ESA 边缘证书页面：强制 HTTPS 开关已开启" loading="lazy" width="2048" height="878" src="/_astro/image-008.CcsQOTzN_Z19rBCw.webp" /><figcaption>ESA 边缘证书页面：强制 HTTPS 开关已开启</figcaption></figure><p></p></section>
<section><h2>和 Cloudflare 版怎么选<a href="#和-cloudflare-版怎么选"><span>#</span></a></h2><p>两套流程几乎一模一样，差别就这几点：</p>

<table><thead><tr><th></th><th><a href="/posts/network/ipv6-cloudflare-lucky/">Cloudflare 版</a></th><th>阿里云 ESA 版（本篇）</th></tr></thead><tbody><tr><td>边缘节点</td><td>境外</td><td>境内</td></tr><tr><td>国内访问</td><td>基本看运气</td><td>稳，走的是正经加速线路</td></tr><tr><td>前置条件</td><td>域名托管在 CF 就行</td><td><strong>域名必须备案</strong></td></tr></tbody></table><p>国内访问多、手上又有备案域名，就选 ESA；只有个没备案的域名，或者想纯白嫖，就还是 CF 那套。原理完全一样，会了一套，另一套照着抄就行。</p></section>
<section><h2>总结<a href="#总结"><span>#</span></a></h2><p>极简流程过一遍：</p><ol>
<li>Lucky DDNS（阿里云）把 IPv6 同步到 <code>v6.xxx.com</code></li>
<li>Lucky Web 服务反代，监听 <code>16666</code></li>
<li>Lucky 与 ESA 各自配置免费证书（两端均全自动续签，别再手动上传了）</li>
<li>ESA 回源规则：所有请求，端口全部 <code>16666</code></li>
<li>ESA 添加 CNAME 记录，<strong>回源 HOST 选跟随请求 HOST</strong></li>
<li>DNS 服务商加 CNAME 指向 ESA 接入域名</li>
</ol><p>两个坑记一下：</p><ul>
<li><strong>回源 HOST</strong>：不选「跟随请求 HOST」，Lucky 就认不出你要访问哪个服务，这条排查起来最费时间。</li>
<li><strong>ESA 免费证书</strong>：申请免费证书时必须<strong>手动打全一级域名</strong>才会弹出选项，选上就能自动签发续期；两端都自动更新，别再去手动下载上传证书了。</li>
</ul><p>到这里整套就通了：外面用域名直接访问家里服务，ESA 国内节点加速，IPv4 和 IPv6 都能进，全程带 HTTPS。</p><blockquote><p>其实有一个更加简单粗暴的办法，但是不符合我个人：组合优于继承 的理念..</p><p>直接把域名选择NS接入ESA，在lucky里面选择阿里云ESA的DDNS解析泛域名，然后在ESA将泛域名解析的代理状态打开</p><p>去配置回源16666，配置好两边的证书</p><p>回lucky反代各种服务即可</p></blockquote></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/vercel-function-region/</id>
      <title type="text">Vercel 函数默认跑在美国：改个 Region 让响应更快</title>
      <published>2026-09-04T00:00:00.000Z</published>
      <updated>2026-09-07T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/vercel-function-region/"/>
      <summary type="text">发现 Vercel Functions 默认区域在美国 iad1，进后台改一下 Function Region 就能让动态请求少跑一趟美国，顺带记录 Hobby 计划只能选一个区域的坑。</summary>
      <content type="html"><![CDATA[<p>前几天刷到 UpXuu 的一篇文章，说 Vercel 项目还有地区之分。我第一反应是不对啊，Vercel 不是自带全球 CDN 吗，怎么还分地区？结果点进自己项目后台一看，好家伙，Functions 默认全跑在美国华盛顿（<code>iad1</code>），这入口藏得是真深。</p>
<section><h2>这个设置管什么用<a href="#这个设置管什么用"><span>#</span></a></h2><p>先说清楚，免得期望太高。Vercel 的静态资源（HTML、CSS、JS、图片）本来就走全球 CDN，访客从离自己近的节点拿，改不改都一样。受区域影响的只有 <strong>Serverless Functions</strong>——接口路由、SSR 页面、Middleware 这类需要真正跑代码的动态请求。</p>

<table><thead><tr><th>请求类型</th><th>受不受 Region 影响</th></tr></thead><tbody><tr><td>静态资源（HTML / CSS / JS / 图片）</td><td>不受，CDN 就近分发</td></tr><tr><td>Serverless Functions（API / SSR / Middleware）</td><td>受，在你选的区域执行</td></tr></tbody></table><p>所以纯静态博客改了基本没感觉；但只要项目里有接口、SSR 或者评论后端这类动态请求，而访客又在亚太，那把区域改近一点就是有意义的。官方默认 <code>iad1</code> 的理由是大部分外部数据源（数据库之类）托管在美国东部，可对国内访客来说，这一来一回的延迟就不客气了。</p></section>
<section><h2>怎么改<a href="#怎么改"><span>#</span></a></h2><p>操作不难，两分钟的事：</p><ol>
<li>打开 <a href="https://vercel.com/dashboard" target="_blank">Vercel 控制台</a>，进入要改的项目</li>
<li>顶部切到 <strong>Settings</strong>，左侧选 <strong>Functions</strong></li>
<li>找到 <strong>Function Region</strong> 手风琴，展开后按大洲分组，勾一个离访客近的区域，比如亚太的香港 <code>hkg1</code>、东京 <code>hnd1</code>、新加坡 <code>sin1</code>、首尔 <code>icn1</code></li>
<li>右上角 <strong>Save</strong> 保存</li>
</ol><p>注意面板里写着「A new Deployment is required for your changes to take effect」——改完区域<strong>必须重新部署一次</strong>才生效，光点保存没用。</p></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><ol>
<li><strong>Hobby 计划只能选 1 个区域。</strong> 默认北美组的华盛顿 <code>iad1</code> 是勾着的，你要是直接跑去亚太组再勾一个，就变成同时选中两个区域，Hobby 直接不允许。正确姿势是先把 <code>iad1</code> 的勾取消掉，再勾自己想要的区域，保证最后只剩一个。要是带着两个区域的配置去部署，会报 <code>upgrade_required</code> 的错，而且界面上就一句莫名其妙的 unexpected error，原因藏在部署记录里，挺坑的。</li>
<li><strong>改完要重新部署。</strong> Save 之后记得去 Deployments 里 Redeploy 一次，不然设置改了跟没改一样。
<figure><img alt="alt text" loading="lazy" width="2048" height="888" src="/_astro/image.DPY9qJ9Q_fOCkC.webp" /><figcaption>alt text</figcaption></figure></li>
</ol></section>
<section><h2>要不要改<a href="#要不要改"><span>#</span></a></h2><p>我的判断：值得顺手改，但别期待逆天提升。</p><ul>
<li>纯静态站点：改了没感觉，图个心理安慰</li>
<li>有 API / SSR / Middleware：值得改，动态请求少跑一趟美国</li>
<li>访客主要在国内：香港 <code>ap-east-1</code> 物理距离最近；要是数据库在别处（比如 Supabase 常用新加坡），就跟着数据源走</li>
</ul><p>效果嘛，原作者的体感是有一点提升，但不是质变，属于「顺手改一下不亏」的级别。</p></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><ol>
<li>项目 → Settings → Functions → Function Region</li>
<li>取消勾选默认的 <code>iad1</code></li>
<li>勾选一个离访客近的区域（如 <code>hkg1</code>）</li>
<li>Save → Redeploy</li>
</ol></section>
<section><h2>参考资料<a href="#参考资料"><span>#</span></a></h2><ul>
<li><a href="https://upxuu.com/posts/vercel666" target="_blank">你的vercel项目，还能更快 - UpXuu’s blog</a></li>
<li><a href="https://vercel.com/docs/functions/configuring-functions/region" target="_blank">Configuring regions for Vercel Functions - Vercel Docs</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/gname-free-eu-cc/</id>
      <title type="text">GNAME 白嫖 .eu.cc 免费域名：注册续费都 0 元，还能托管 Cloudflare</title>
      <published>2026-08-30T00:00:00.000Z</published>
      <updated>2026-09-01T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/gname-free-eu-cc/"/>
      <summary type="text">GNAME 的 .eu.cc 免费注册活动：每个账号可领 3 张全额抵扣券，注册和续费都是 0 元，溢价域名除外，还能托管到 Cloudflare。整理了领券注册流程、续费入口的坑和接入 Cloudflare 的步骤。</summary>
      <content type="html"><![CDATA[<p>之前对免费域名的印象一直停留在 eu.org：域名长、注册要等审核，一直懒得折腾。前几天刷到一个 .eu.cc 的免费注册活动：GNAME 发全额抵扣券，注册 0 元，续费也 0 元，教程作者实测还能正常托管到 Cloudflare。我把活动页和教程翻了一遍，把规则和坑整理成这篇。</p>
<section><h2>.eu.cc 免费域名活动是什么<a href="#eucc-免费域名活动是什么"><span>#</span></a></h2><p>活动在 <a href="https://www.gname.com/tld-eu-cc.html" target="_blank">GNAME 官方活动页</a>，核心就一句话：<strong>注册就送 .eu.cc 全额抵扣券，用券注册 0 元</strong>。</p><p></p><figure><img alt="image-20260831014434408" loading="lazy" width="1690" height="1028" src="/_astro/image-1.CHs6be2q_2dAkIb.webp" /><figcaption>image-20260831014434408</figcaption></figure><p></p><p>几个关键信息：</p><ul>
<li>注册/登录 GNAME 账号后，每个账号能领 <strong>3 张全额抵扣券</strong>，一张券注册一个域名</li>
<li>.eu.cc 正常价格 $12/年，用券 $0</li>
<li>想多领也有路子：官方给了代理名额，Basic Reseller 激活后 10 张，Advanced Reseller 共 20 张。激活条件我没深究，普通用户 3 个一般够用</li>
<li>转入也有活动价，页面标注低至 $1.88/年</li>
<li>活动页没写截止日期，想薅趁早</li>
</ul><div><div><div></div><div>.eu.cc 到底是个啥</div></div><div><p>它不是 ICANN 认证的顶级域，本质是挂在 .cc 下面的二级域项目。官方的说法是：管理、注册、续费、转移这些权利功能和正常顶级域一样。拿来做个人站点没问题，但”正经长期项目”的定位自己要有数。</p></div></div></section>
<section><h2>注册流程：领券、查询、0 元结算<a href="#注册流程领券查询0-元结算"><span>#</span></a></h2><p>流程就四步：</p><ol>
<li>注册/登录 GNAME，打开<a href="https://www.gname.com/tld-eu-cc.html" target="_blank">活动页</a>领取抵扣券</li>
<li>搜索想要的域名</li>
<li>结算时选全额抵扣券支付，$0 拿下</li>
<li>要接 Cloudflare 的，注册完把 NS 改过去（下面单独说）</li>
</ol><p>看着简单，但有两个坑，单独拎出来说。</p></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><section><h3>溢价域名不能用券<a href="#溢价域名不能用券"><span>#</span></a></h3><p>两字母（<code>aa.eu.cc</code>）、三字母（<code>abc.eu.cc</code>）、品牌词和热门行业词这些属于溢价域名，抵扣券不管用，也不符合免费续费资格，实际价格以搜索结果为准。看到好记的短域名先别激动，搜出来看一眼价格再决定。</p></section><section><h3>免费续费只有一个入口<a href="#免费续费只有一个入口"><span>#</span></a></h3><p>这是整个活动最大的坑，值得展开写：</p><ul>
<li>免费续费<strong>只能</strong>从活动页的 Renew for free 列表提交，登录后你的域名会列在页面里。在”我的域名”里手动续费、或者开自动续费，都不享受免费，按正常价格 $12/年收</li>
<li>窗口问题：官方原话是 “within 90 days of expiration”（到期日 90 天的窗口内）；教程作者的实践是<strong>要进入到期前 90 天，域名才会出现在活动页的可续费列表里</strong>。具体以活动页列表的状态为准，没到窗口就先等着</li>
<li>所以注册完把到期时间记下来，到窗口了去活动页操作，别指望自动续费</li>
</ul><div><div><div></div><div>自动续费反而要钱</div></div><div><p>开了自动续费，到期扣的是正常价格 $12/年，不是 0 元。免费续费只在活动页的 Renew for free 里有效。</p></div></div><p>另外代金券本身也有期限：领取后 30 天有效，过期作废，不可转让。别囤券，领了就注册。</p></section></section>
<section><h2>托管到 Cloudflare<a href="#托管到-cloudflare"><span>#</span></a></h2><p>GNAME 自己的 DNS 也能用（还带个免费的智能解析），但既然是折腾人，直接上 Cloudflare 白嫖全家桶。教程作者实测 .eu.cc 可以当正常域名托管，流程和普通域名一样：</p><ol>
<li>Cloudflare 控制台 → Add a domain，填你的 <code>xxx.eu.cc</code>，选 Free 计划</li>
<li>Cloudflare 给出两个 NS 地址</li>
<li>回 GNAME 的域名管理，把 DNS 服务器改成这两个 NS</li>
<li>回 Cloudflare 触发检查，等生效（几分钟到几小时不等）</li>
</ol><p>接上之后，之前写过的这几套玩法就都能搬过来了：</p><ul>
<li><a href="/posts/network/cloudflareemailrouting/">Cloudflare+Resend域名邮箱</a>：免费收域名邮箱，配 Resend 还能发</li>
<li><a href="/posts/network/cloudmail/">CloudMail 无限收发邮箱</a>：跑在 Workers 上的邮箱服务，收发一体</li>
<li><a href="/posts/deploy/deployimgbed/">Workers 部署一个免费图床</a>：图床也吃 Cloudflare 这套，博客配图够用</li>
<li><a href="/posts/network/cloudflaretunnel/">Cloudflare Tunnel Docker部署教程</a>：不用公网 IP 把内网服务挂到公网</li>
<li><a href="/posts/deploy/cf-pages-edgetunnel/">不用服务器自建节点</a>：节点同样跑在 CF Pages 上，免费白嫖</li>
<li><a href="/posts/network/ipv6-cloudflare-lucky/">IPv6 + Lucky 白嫖双栈 HTTPS</a>：那篇的前提就是”一个托管在 Cloudflare 的域名”，现在域名有了</li>
</ul></section>
<section><h2>适合谁，我的判断<a href="#适合谁我的判断"><span>#</span></a></h2><ul>
<li>适合：博客、导航站、个人主页、在线工具、项目展示页、短链接站、测试站。反正 0 成本，试错随便试，试错了就扔</li>
<li>和 eu.org 比：域名短得多，领券直接注册，不用等审核</li>
<li>不适合：正经长期项目。免费活动随时可能调整，二级域的”根”也不在自己手里，长期项目还是买个正规付费顶级域名踏实</li>
</ul><p>我的判断：拿来折腾是真的香。但”0 元”的前提是记得去活动页续费——这个活动最大的成本不是钱，是记性。</p></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><ol>
<li>注册/登录 GNAME → 活动页领 3 张全额券（30 天内用掉）</li>
<li>搜域名，避开两字母、三字母、品牌词等溢价域名</li>
<li>结算用券，$0 注册</li>
<li>到期前 90 天：活动页 Renew for free 列表里免费续费，别碰”我的域名”里的续费和自动续费</li>
<li>要接 Cloudflare：Add a domain → 改 NS → 等生效</li>
</ol></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/ai/dsh-migrate-docker-ai-repair/</id>
      <title type="text">DSH 迁移 Docker 翻车四连：AI 全程远程替我修好了</title>
      <published>2026-08-29T00:00:00.000Z</published>
      <updated>2026-08-29T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/ai/dsh-migrate-docker-ai-repair/"/>
      <summary type="text">把 Windows 上的 DSH（DeepSeek Harness）连同插件配置迁移到 NAS 的 Docker 容器，启动即崩、连炸四个坑。最后全靠 AI 通过 SSH 远程诊断、打补丁、重装依赖，全程自动修好。</summary>
      <content type="html"><![CDATA[<p>我在 Windows 上用 DSH（DeepSeek Harness）跑了一阵子，陆陆续续装了十好几个插件：dshmarket、dsh-chat-import、dsh-vision-router、dsh-agent-teams、dsh-bill，乱七八糟一堆。用着是挺爽，但有个问题一直膈应：这玩意就住在我这台 Windows 上，想让它一直挂机，电脑就得一直开着，费电。</p>
<p>正好 NAS 上跑着 Docker，我就想，干脆把整个 DSH 连插件带配置一起搬过去，塞进容器里，以后在外面随时能访问。计划很朴素，结果从拷文件那一刻开始，连炸了四个坑，每次都启动即崩。最后是 AI 通过 SSH 远程一条龙修好的，我在旁边基本就负责看戏和拍大腿。</p>
<p>这篇就记一下这个过程，重点说 AI 是怎么全自动修的。</p>
<section><h2>起因：想给 DSH 搬家<a href="#起因想给-dsh-搬家"><span>#</span></a></h2><p>Windows 上的 DSH 存在 <code>C:\Users\XOS\.dsh</code> 里，结构大概是：</p><ul>
<li><code>profiles/web/</code>：web 版 profile，插件清单（<code>package.json</code>）、启用配置（<code>cordis.yml</code>）、依赖都在这</li>
<li><code>settings.yaml</code>：全局配置，模型、皮肤、面板开关之类</li>
<li><code>.credentials.yaml</code>：API key 等凭据</li>
<li>各个插件自己的数据目录，<code>dsh-bill/</code>、<code>dsh-chat-import/</code>、<code>skin-center/</code>……</li>
</ul><p>我最初的想法很直接：整个 <code>.dsh</code> 拷到 NAS 挂载进容器，完事。结果第一步就栽了。</p></section>
<section><h2>坑一：Junction 变成了实体目录<a href="#坑一junction-变成了实体目录"><span>#</span></a></h2><p>先在 Windows 上 robocopy 把 <code>.dsh</code> 备份出来，然后整个丢进容器的挂载目录，启动容器：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Error: dsh: /root/.dsh/profiles/node_modules/@deepseek-ai/dsh exists</span></div></div><div><div><div>2</div></div><div><span>and is not a symlink; remove it so dsh can manage the installation fallback</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>当时人直接傻了：<code>@deepseek-ai/dsh</code> 怎么会「存在但不是符号链接」？</p><p>原因很阴：Windows 上 DSH 核心包的 node_modules 全是 Junction（目录符号链接），真正的实体在 <code>D:\.pnpm-store</code>。我 robocopy 的时候没加 <code>/XJ</code>，这些链接就被解析成实体目录拷了过去。到 Linux 上一看，DSH 期望这里是个干净的 symlink 好自己管理，结果是个实打实的目录，直接罢工。</p><div><div><div></div><div>教训</div></div><div><p>拷贝 <code>.dsh</code> 必须排除 node_modules 和 junction：
<code>robocopy "C:\Users\XOS\.dsh" "D:\dsh-migrate\.dsh" /E /XJ /XD node_modules</code>
node_modules 交给目标机器重新 <code>pnpm install</code>，别搬。</p></div></div></section>
<section><h2>坑二：依赖没装，DSH 拒绝启动<a href="#坑二依赖没装dsh-拒绝启动"><span>#</span></a></h2><p>删掉坏东西重来，把干净的备份（34MB，无 node_modules）传到 NAS 挂载目录。启动，又崩：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Error: dsh: cannot resolve profile bundle "dshmarket" from the dsh</span></div></div><div><div><div>2</div></div><div><span>installation or /root/.dsh/profiles/web; run 'dsh plugin --profile web</span></div></div><div><div><div>3</div></div><div><span>install' if its dependency is not installed</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这个好懂：node_modules 没带过来，DSH 也不会自己装，得进容器跑 <code>pnpm install</code>。麻烦的是容器一启动就崩，根本进不去，<code>docker exec</code> 无从谈起。</p><p>AI 给的办法很骚：用一次性容器，同一个镜像、同一个挂载，覆盖启动命令直接进 shell 装依赖：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>docker</span><span> </span><span>run</span><span> </span><span>--rm</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>  </span><span>-v</span><span> </span><span>/vol1/1000/Docker/DSH:/root</span><span> </span><span>\</span></div></div><div><div><div>3</div></div><div><span>  </span><span>--entrypoint</span><span> </span><span>/bin/bash</span><span> </span><span>\</span></div></div><div><div><div>4</div></div><div><span>  </span><span>smanx/deepseek-harness:devtools-latest</span><span> </span><span>\</span></div></div><div><div><div>5</div></div><div><span>  </span><span>-c</span><span> </span><span>"cd /root/.dsh/profiles/web &amp;&amp; pnpm install --prod --frozen-lockfile"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>web profile 装完再补 dsh-tui 那个 profile，同一个套路：<code>cd /root/.dsh/profiles/dsh-tui &amp;&amp; pnpm install --prod --frozen-lockfile</code>。</p><p>关键点就三个：<code>--rm</code> 用完即毁，<code>--entrypoint /bin/bash</code> 跳过正常启动命令直接给 shell，<code>-v</code> 挂载同一个数据卷。等于在容器外面修容器里面的世界，那个崩掉的容器根本不用管。装完确认插件都回来了：dshmarket、dsh-bill、dsh-vision-router、@nanmicoder/dsh-agent-teams，一个不少。</p><p><img alt="" loading="lazy" width="1536" height="1024" src="/_astro/image.D8SuqKyu_Z1vaF0x.webp" /></p></section>
<section><h2>坑三：凭据文件权限 666，安全校验拦路<a href="#坑三凭据文件权限-666安全校验拦路"><span>#</span></a></h2><p>依赖齐了，启动，还炸：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>credentials-local: /root/.dsh/.credentials.yaml is readable beyond its</span></div></div><div><div><div>2</div></div><div><span>owner (mode 666); run "chmod 600 /root/.dsh/.credentials.yaml" before</span></div></div><div><div><div>3</div></div><div><span>starting again</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这是 Windows 拷文件的经典后遗症：robocopy 备份时把文件权限全放开了，<code>.credentials.yaml</code> 成了 666，谁都能读。DSH 有安全校验，凭据文件必须属主独占。</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>chmod</span><span> </span><span>600</span><span> </span><span>/root/.dsh/.credentials.yaml</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>顺手把 <code>settings.yaml</code>、<code>dsh-ssh.json</code> 也一起收紧。</p></section>
<section><h2>坑四：TUI 插件要 TTY，后台容器没有<a href="#坑四tui-插件要-tty后台容器没有"><span>#</span></a></h2><p>第四次启动，日志里终于不是「启动即退」了，但还是起不来：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Error: dsh-tui requires an interactive terminal (stdout must be a TTY).</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>原因是我的 profile 里启用了 <code>@deepseek-harness-tui/dsh-tui</code>（TUI 界面插件）。它启动时会检测 stdout 是不是 TTY，容器里 <code>dsh web &amp;</code> 是后台跑，没有 TTY，直接抛异常。</p><p>解法也简单：web 部署反正用浏览器访问，TUI 用不上，直接在 profile 的补丁配置里禁掉。在 <code>cordis.patch.yml</code> 追两行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># Docker 后台部署无 TTY，禁用 TUI（web 浏览器访问不受影响）</span></div></div><div><div><div>2</div></div><div><span>- </span><span>id</span><span>: </span><span>dsh-tui</span></div></div><div><div><div>3</div></div><div><span>  </span><span>disabled</span><span>: </span><span>true</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>修好了：容器平稳运行<a href="#修好了容器平稳运行"><span>#</span></a></h2><p>四次折腾完，容器终于 <code>Up</code> 起来了：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[dsh] DSH 就绪（pid 7）</span></div></div><div><div><div>2</div></div><div><span>[proxy] 启动代理：0.0.0.0:3080 -&gt; 127.0.0.1:3079</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>端口探活直接 HTTP 200，浏览器打开就是熟悉的 <code>DeepSeek Harness</code> 界面。插件、配置、聊天记录全都在，等于搬了个家，行李一件没丢。</p></section>
<section><h2>AI 全自动修复是怎么做到的<a href="#ai-全自动修复是怎么做到的"><span>#</span></a></h2><p>说回重点。整个过程里，AI（就我正在用的这个 agent）做的不是「丢几条命令让你自己跑」，而是全程接管：</p><ol>
<li>远程连宿主机：直接 SSH 到 NAS，<code>docker ps -a</code> 看容器状态，<code>docker inspect</code> 确认挂载关系</li>
<li>判断挂载点：一眼看出 <code>-v /vol1/1000/Docker/DSH:/root</code> 意味着整个根目录都在宿主机上，所以「不进容器也能修」</li>
<li>改文件用 sudo 提权：宿主机上 <code>.dsh</code> 属主是 root，AI 自己拿 root 密码提权读写</li>
<li>自动打包传输：本地 <code>tar -czf</code> 打包备份，<code>ssh_upload</code> 传过去，远端解压，再校验文件都在</li>
<li>自我纠错：每修一步就启动一次容器、读日志、分析下一个报错，然后接着修，四个坑全是这么被逐个填平的</li>
<li>顺手清理：tar 包、一次性容器，用完都收拾干净</li>
</ol><p>全程我给它的指令大概就两三条：「拷过去报错了」「容器进不去怎么办」「现在好了吗」。剩下全是它自己来。最爽的是它自己就注意到备份里没有 node_modules、注意到权限是 666、注意到 TUI 要 TTY，每个都是看一眼报错日志就定位到根因，然后给出对应修法。</p><p>以前遇到这种「启动即崩、日志一大坨」的容器问题，我的流程是：搜报错，复制一堆命令，越改越乱，最后重装。这次基本是它修、我看，体验完全不一样。</p></section>
<section><h2>经验总结<a href="#经验总结"><span>#</span></a></h2><p>这套流程以后再遇到同类场景可以直接抄：</p><ol>
<li>备份 <code>.dsh</code> 时排除 node_modules 和 junction（<code>/XJ /XD node_modules</code>）</li>
<li>清单文件（<code>package.json</code>、<code>pnpm-lock.yaml</code>、<code>cordis.yml</code>）必须带，这是重建依赖的依据</li>
<li>到新机器后手动跑一次 <code>pnpm install --prod --frozen-lockfile</code>，别指望 DSH 自动装</li>
<li>迁完用 <code>chmod 600</code> 收紧 <code>.credentials.yaml</code> 等敏感文件</li>
<li>Docker 或后台部署记得禁用 TUI 类插件（<code>cordis.patch.yml</code> 里 <code>disabled: true</code>）</li>
<li>容器崩了进不去？用一次性容器 + 同挂载覆盖 entrypoint，在容器外面修</li>
</ol><p>最值钱的一条还是那句：跨平台迁移，别硬搬 node_modules。让依赖在目标环境重新装，比带着一堆 Windows 链接结构过去折腾省事得多。</p></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>这次折腾给我最大的感受倒不是 DSH 迁成功了，而是「AI 全自动运维」这状态离日常生活真不远了。一个能 SSH、能判断挂载关系、能自己打补丁重启再验证的 agent，处理这种环境迁移问题已经挺能打。当然它也不是万能的，中间好几次我都怀疑它要卡住了，结果它就是硬生生把四个坑一个个填平。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/dsh-docker/</id>
      <title type="text">DeepSeek Harness 上服务器：官方没出 Docker，社区镜像这么选</title>
      <published>2026-08-29T00:00:00.000Z</published>
      <updated>2026-09-01T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/dsh-docker/"/>
      <summary type="text">官方 dsh 只发 npm 包，Web 模式还只允许监听 127.0.0.1。整理两个社区 Docker 镜像的命令、环境变量和选择建议。</summary>
      <content type="html"><![CDATA[<p>最近把 DeepSeek Harness（dsh）当主力 agent 用，结果前两天 Windows 上的全局安装突然起不来，一敲 <code>dsh</code> 就是 <code>ERR_MODULE_NOT_FOUND</code>。修好之后我就动了歪心思：这种工具其实更适合扔在服务器或者 NAS 上 7×24 挂着，家里设备随时连上去用。于是去查它有没有 Docker 版，结论是：<strong>官方没有，但社区有俩能打的</strong>。这篇把调研结果整理一下。另外，把 Windows 上的 dsh 硬搬进 NAS 容器会踩哪些坑，我在<a href="/posts/ai/dsh-migrate-docker-ai-repair/">迁移那篇</a>里记了四个，迁移时可以对照着看。</p>
<section><h2>先说结论：官方目前没有 Docker 版<a href="#先说结论官方目前没有-docker-版"><span>#</span></a></h2><p><a href="https://github.com/deepseek-ai/deepseek-harness" target="_blank">官方仓库</a>的 README 里只写了两种用法：npm 装（<code>npx @deepseek-ai/dsh web</code> 起 Web UI）和源码编译，一个字没提容器。项目还在 developer preview 阶段，官方也明说后续会有破坏性变更。</p><p>更麻烦的是，<code>dsh web</code> 出于安全考虑只监听 <code>127.0.0.1:3080</code>，<code>--host 0.0.0.0</code> 是被禁止的。这意味着就算你自己把 npm 包塞进容器，局域网里其他设备照样连不上，还得自己套一层反代。</p><p>社区镜像就是冲着这两个问题去的。</p></section>
<section><h2>方案一：smanx/deepseek-harness — 内置反代，开箱即用<a href="#方案一smanxdeepseek-harness--内置反代开箱即用"><span>#</span></a></h2><p><a href="https://github.com/deepseek-ai/deepseek-harness/discussions/1762" target="_blank">官方讨论区 #1762</a> 里 smanx 打包的镜像，Docker Hub 和 GHCR 双仓库（<code>ghcr.io/smanx/deepseek-harness</code>，tag 一致）。</p><p>镜像内置一个 Node 反向代理：监听 <code>0.0.0.0:3080</code> 转发到容器内的 <code>127.0.0.1:3079</code>，顺手解决了三件事：</p><ul>
<li>WebSocket 转发（<code>/api/events.mux</code>、<code>/api/events.host</code>），不然网页端消息推不出来；</li>
<li>局域网 IP 属于非安全上下文，<code>crypto.randomUUID</code> 不可用，反代会往 HTML 里注入 polyfill，否则连接一直 pending；</li>
<li>可选的 HTTP Basic Auth。</li>
</ul><p>tag 有三档，按需选：</p>

<table><thead><tr><th>tag</th><th>里面有什么</th></tr></thead><tbody><tr><td><code>latest</code></td><td>Node + DSH + 反代，最精简</td></tr><tr><td><code>devtools-min-latest</code></td><td>加 git、curl、wget、jq、pnpm、uv</td></tr><tr><td><code>devtools-latest</code></td><td>再加 vim、python3、build-essential、tmux，适合让 agent 直接在容器里写代码</td></tr></tbody></table><p>启动命令（原帖原文）：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>docker</span><span> </span><span>run</span><span> </span><span>-d</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>  </span><span>--name</span><span> </span><span>dsh-harness</span><span> </span><span>\</span></div></div><div><div><div>3</div></div><div><span>  </span><span>-p</span><span> </span><span>3080:3080</span><span> </span><span>\</span></div></div><div><div><div>4</div></div><div><span>  </span><span>-v</span><span> </span><span>dsh-data:/root/.dsh</span><span> </span><span>\</span></div></div><div><div><div>5</div></div><div><span>  </span><span>--restart</span><span> </span><span>unless-stopped</span><span> </span><span>\</span></div></div><div><div><div>6</div></div><div><span>  </span><span>smanx/deepseek-harness:latest</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>可用的环境变量：</p><ul>
<li><code>DSH_PORT</code>：dsh web 端口，默认 3079</li>
<li><code>PROXY_PORT</code>：反代端口，默认 3080</li>
<li><code>PROXY_USERNAME</code> / <code>PROXY_PASSWORD</code>：两个都设置才启用 Basic Auth</li>
</ul><p>评论区还有人给了 compose 版，把代码目录也挂进去：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>services</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>dsh-harness</span><span>:</span></div></div><div><div><div>3</div></div><div><span>    </span><span>image</span><span>: </span><span>smanx/deepseek-harness:devtools-latest</span></div></div><div><div><div>4</div></div><div><span>    </span><span>container_name</span><span>: </span><span>dsh-harness</span></div></div><div><div><div>5</div></div><div><span>    </span><span>ports</span><span>:</span></div></div><div><div><div>6</div></div><div><span><span>      </span></span><span>- </span><span>"3080:3080"</span></div></div><div><div><div>7</div></div><div><span>    </span><span>volumes</span><span>:</span></div></div><div><div><div>8</div></div><div><span><span>      </span></span><span>- </span><span>/home/ly/code:/home/ly/code</span><span>   </span><span># ⚠️ 改成你的代码目录</span></div></div><div><div><div>9</div></div><div><span><span>      </span></span><span>- </span><span>/home/ly/.dsh-home:/root</span><span>      </span><span># ⚠️ 改成你的持久化目录</span></div></div><div><div><div>10</div></div><div><span>    </span><span>restart</span><span>: </span><span>unless-stopped</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>方案二：runzhliu/deepseek-harness — 安全加固，带 K8s 参考<a href="#方案二runzhliudeepseek-harness--安全加固带-k8s-参考"><span>#</span></a></h2><p><a href="https://github.com/runzhliu/deepseek-harness-docker" target="_blank">runzhliu 的版本</a>（对应 <code>0.1.1-rc.2</code>，构建参数可换）走的是另一个思路：不碰 0.0.0.0，端口只绑 <code>127.0.0.1</code>，要访问就 SSH 隧道或 <code>kubectl port-forward</code>。安全细节给得挺足：非 root（UID 1000）运行、只读根文件系统、drop ALL capabilities、<code>no-new-privileges</code>、tini 收尸。额外还带了个 noVNC 浏览器桌面（6080 端口），网页里能直接看到容器桌面。</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>docker</span><span> </span><span>run</span><span> </span><span>--rm</span><span> </span><span>--name</span><span> </span><span>deepseek-harness</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>  </span><span>--publish</span><span> </span><span>127.0.0.1:3080:3080</span><span> </span><span>\</span></div></div><div><div><div>3</div></div><div><span>  </span><span>--publish</span><span> </span><span>127.0.0.1:6080:6080</span><span> </span><span>\</span></div></div><div><div><div>4</div></div><div><span>  </span><span>--shm-size</span><span> </span><span>1g</span><span> </span><span>\</span></div></div><div><div><div>5</div></div><div><span>  </span><span>--mount</span><span> </span><span>type=volume,src=dsh-home,dst=/home/node/.dsh</span><span> </span><span>\</span></div></div><div><div><div>6</div></div><div><span>  </span><span>--mount</span><span> </span><span>type=bind,src="</span><span>$PWD</span><span>",dst=/workspace</span><span> </span><span>\</span></div></div><div><div><div>7</div></div><div><span>  </span><span>runzhliu/deepseek-harness:0.1.1-rc.2</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>compose 用法是 <code>docker compose pull</code> 之后 <code>DSH_WORKSPACE=/绝对路径 docker compose up -d</code>。注意工作区目录必须对 UID 1000 可写，否则直接 EACCES。K8s 那边给了单副本 StatefulSet + NetworkPolicy，默认拒绝入站——作者很克制，明确不建议当 HA 扩容用。另有一个预装 dshmarket 的 market 变体（tag <code>0.1.1-rc.2-market.1</code>），要用 <code>compose.market.yaml</code> 显式启用。</p></section>
<section><h2>怎么选<a href="#怎么选"><span>#</span></a></h2>

<table><thead><tr><th></th><th>smanx 版</th><th>runzhliu 版</th></tr></thead><tbody><tr><td>局域网访问</td><td>直接可用（反代监听 0.0.0.0）</td><td>只绑 127.0.0.1</td></tr><tr><td>访问控制</td><td>可选 Basic Auth</td><td>无，靠 SSH 隧道</td></tr><tr><td>容器内工具</td><td>devtools 三档 tag</td><td>精简，另有 market 变体</td></tr><tr><td>安全设计</td><td>常规</td><td>只读根、非 root、无新增特权</td></tr><tr><td>noVNC 桌面</td><td>无</td><td>有（6080）</td></tr><tr><td>K8s</td><td>无</td><td>StatefulSet 参考</td></tr></tbody></table><p>我的判断：NAS 或家里服务器想给全设备用，smanx 顺手得多，记得把 Basic Auth 打开；runzhliu 适合自己一个人用、对安全默认值有要求的场景，K8s 部署直接抄它的配置。</p><div><div><div></div><div>警告</div></div><div><p>dsh 的 Web UI 能执行任意代码，smanx 版的 Basic Auth 还是可选项。不管哪个方案，都别裸奔到公网，要暴露就套反代加 TLS。</p></div></div></section>
<section><h2>踩坑记录：本机 0.1.1-rc.2 的 peer 依赖炸弹<a href="#踩坑记录本机-011-rc2-的-peer-依赖炸弹"><span>#</span></a></h2><p>回到开头那个报错。<code>Cannot find package '@deepseek-ai/cordis-plugin-group'</code>，排查下来不是我的问题，是这版打包的锅：19 个内部包（<code>dsh-invariants</code>、<code>dsh-sandbox</code>、<code>dsh-timeout</code>……）只被声明成 peerDependencies，但没有任何包实际安装它们，其中 <code>dsh-invariants</code> 被 168 处引用，等于大半个 dsh 都等着它。</p><p>解决办法是把缺的包补装进全局包自己的 <code>node_modules</code>（<code>npm install --no-save</code> 指着缺失清单装就行），补完 <code>dsh --version</code> 立刻活过来。顺手还修了一个插件的 MCP 路径解析问题，这里不展开。</p><p>所以如果你自己装 rc.2 也碰到同样的报错，先怀疑依赖没装齐，别急着删配置。社区镜像万一也踩到，<code>docker exec</code> 进容器补依赖，思路一样。</p></section>
<section><h2>部署前的几个注意<a href="#部署前的几个注意"><span>#</span></a></h2><ol>
<li><strong>持久化</strong>：容器内的 <code>.dsh</code> 目录（配置、会话、凭据）一定要挂卷，工作区用 bind mount；反过来，别把 <code>~/.ssh</code>、云凭据、docker socket 挂进容器。</li>
<li><strong>API Key</strong>：headless 运行用 <code>DEEPSEEK_API_KEY</code> 环境变量注入，不要写进镜像层。</li>
<li><strong>架构</strong>：拉取前确认镜像架构和你的设备匹配（NAS 有 x86 也有 arm）。</li>
<li><strong>Windows 本机</strong>想跑 Docker 版需要 Docker Desktop（WSL2），我这种本来就有服务器/NAS 的，直接部署到远端更省心。</li>
</ol></section>
<section><h2>接下来打算<a href="#接下来打算"><span>#</span></a></h2><p>上面这些命令都来自原帖和项目 README，我还没实机跑过——等我把它在 NAS 上用 <code>devtools-latest</code> + Basic Auth 跑起来，再写个实测续篇。</p><p><em>写于 2026 年 8 月，折腾 dsh 的记录。</em></p></section>
<section><h2>图片建议<a href="#图片建议"><span>#</span></a></h2><ul>
<li>位置：文章开头或方案一节，容器跑起来后浏览器访问 <code>http://NAS的IP:3080</code> 的 Web UI 界面截图</li>
<li>位置：踩坑记录一节，<code>ERR_MODULE_NOT_FOUND</code> 的终端报错截图</li>
<li>作用：封面 + 证明服务真的跑起来了 / 报错现场</li>
<li>推荐尺寸：封面 1200×630，正文图宽度不超过 1600</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/openviking-fnos/</id>
      <title type="text">fnOS 部署 OpenViking：给 AI Agent 搭一个本地记忆库</title>
      <published>2026-08-29T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/openviking-fnos/"/>
      <summary type="text">在飞牛 NAS 上用 Docker 部署开源上下文记忆库 OpenViking，数据全放数据盘方便迁移；记录换 embedding 模型必须重建向量索引这个最大的坑。</summary>
      <content type="html"><![CDATA[<p>我 NAS 上跑着几个 AI Agent（Hermes、DeepSeek Harness 之类，后者搬进 Docker 的翻车过程我<a href="/posts/ai/dsh-migrate-docker-ai-repair/">单独记过一篇</a>），一直给他们各自搞记忆有点散。后来看到 OpenViking——火山引擎开源的一个上下文记忆数据库，专给 AI Agent 当”长期记忆”用的，把记忆、资源、技能都当作 <code>viking://</code> 协议下的文件系统来管。反正 NAS 闲着也是闲着，就折腾着部署了一版。</p>
<p>这篇文章是完整的部署记录，最大的坑在最后：换 embedding 模型维度变了，服务直接拒绝启动，必须重建索引。如果你也打算在 fnOS 上跑 OpenViking，照着走能省不少时间。</p>
<section><h2>为什么选 OpenViking<a href="#为什么选-openviking"><span>#</span></a></h2><p>OpenViking 干的事：给 Agent 一个统一的、可检索的长期记忆库。</p><ul>
<li>记忆、资源、技能统一用 <code>viking://</code> URI 管理，像操作文件系统一样 <code>ls</code>、<code>find</code>，不是黑盒向量库</li>
<li>内容写进去时自动分级处理：L0 摘要（约 100 token）、L1 概览（约 2k token）、L2 全文，按需加载，省 token</li>
<li>检索是可观测的，能看它是沿哪条路径找到结果的</li>
<li>会话结束后自动从对话里提取偏好和经验写进长期记忆</li>
</ul><p>选择在 NAS 自托管而不是用云服务，就一个理由：<strong>数据在自己手里</strong>。记忆这种东西我不想放别人服务器上。OpenViking 官方自己也要求 Docker 部署时数据全在挂载目录里，天然适合 NAS。</p><p></p><figure><img alt="OpenViking 数据自托管在我自己的机器上" loading="lazy" width="1000" height="563" src="/_astro/image-001.BKY34qUF_Z18C1qV.webp" /><figcaption>OpenViking 数据自托管在我自己的机器上</figcaption></figure><p></p></section>
<section><h2>准备工作<a href="#准备工作"><span>#</span></a></h2><ul>
<li>fnOS 一台（x86_64），Docker 和 Docker Compose 可用（我这边 Docker 29.7.2 / Compose v5.5.0）</li>
<li>用 SSH 登录 NAS（fnOS 的 <code>hy4962</code> 用户在 docker 组，可以直接跑 docker 命令）</li>
<li>两个模型服务：
<ul>
<li><strong>Embedding（向量化）</strong>：我用 SiliconFlow 的 <code>Qwen/Qwen3-Embedding-4B</code>（原因见下）</li>
<li><strong>VLM（内容理解/摘要）</strong>：走我自己搭的 <a href="/posts/deploy/deploynewapi/">new-api 网关</a>（OpenAI 兼容），模型 <code>deepseek-v4-flash</code></li>
</ul>
</li>
</ul><div><div><div></div><div>端口</div></div><div><p>OpenViking 默认监听 <code>1933</code>，同时提供 HTTP API 和 Web Studio 界面（<code>/studio</code>）。确认端口没被占。</p></div></div></section>
<section><h2>第一步：数据目录<a href="#第一步数据目录"><span>#</span></a></h2><p>部署之前先把目录结构定好，这是后面迁移最省心的一步。OpenViking 容器内所有持久状态都在 <code>/app/.openviking</code> 这一个目录下（配置 + workspace 数据全在里面），所以直接把 NAS 上的目录整个挂进去：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/vol1/1000/Docker/OpenViking/</span></div></div><div><div><div>2</div></div><div><span>├── ov.conf            # 配置（模型、存储、root key）</span></div></div><div><div><div>3</div></div><div><span>├── docker-compose.yml</span></div></div><div><div><div>4</div></div><div><span>├── data/              # workspace：文件库 + 向量库 + bot</span></div></div><div><div><div>5</div></div><div><span>└── cache/             # 模型缓存</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>以后换机器迁移，把这个目录整个拷走就行，数据配置模型一个不落。镜像本体在 Docker 存储里，不占这个目录。</p></section>
<section><h2>第二步：ov.conf 和 docker-compose<a href="#第二步ovconf-和-docker-compose"><span>#</span></a></h2><p>先写 <code>ov.conf</code>。关键三件事：<code>server.root_api_key</code>（容器内绑定 0.0.0.0 时必须有，否则拒绝启动）、embedding、VLM：</p><div><div><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>"server"</span><span>: {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>"host"</span><span>: </span><span>"0.0.0.0"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>    </span><span>"port"</span><span>: </span><span>1933</span><span>,</span></div></div><div><div><div>5</div></div><div><span>    </span><span>"root_api_key"</span><span>: </span><span>"换成你自己的随机串"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>    </span><span>"cors_origins"</span><span>: [</span><span>"*"</span><span>]</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>8</div></div><div><span>  </span><span>"embedding"</span><span>: {</span></div></div><div><div><div>9</div></div><div><span>    </span><span>"dense"</span><span>: {</span></div></div><div><div><div>10</div></div><div><span>      </span><span>"provider"</span><span>: </span><span>"openai"</span><span>,</span></div></div><div><div><div>11</div></div><div><span>      </span><span>"model"</span><span>: </span><span>"Qwen/Qwen3-Embedding-4B"</span><span>,</span></div></div><div><div><div>12</div></div><div><span>      </span><span>"api_base"</span><span>: </span><span>"https://api.siliconflow.cn/v1"</span><span>,</span></div></div><div><div><div>13</div></div><div><span>      </span><span>"api_key"</span><span>: </span><span>"你的 SiliconFlow key"</span><span>,</span></div></div><div><div><div>14</div></div><div><span>      </span><span>"dimension"</span><span>: </span><span>2560</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>},</span></div></div><div><div><div>17</div></div><div><span>  </span><span>"vlm"</span><span>: {</span></div></div><div><div><div>18</div></div><div><span>    </span><span>"provider"</span><span>: </span><span>"openai"</span><span>,</span></div></div><div><div><div>19</div></div><div><span>    </span><span>"model"</span><span>: </span><span>"deepseek-v4-flash"</span><span>,</span></div></div><div><div><div>20</div></div><div><span>    </span><span>"api_base"</span><span>: </span><span>"http://192.168.31.3:3000/v1"</span><span>,</span></div></div><div><div><div>21</div></div><div><span>    </span><span>"api_key"</span><span>: </span><span>"你的 new-api key"</span></div></div><div><div><div>22</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>23</div></div><div><span>  </span><span>"storage"</span><span>: {</span></div></div><div><div><div>24</div></div><div><span>    </span><span>"workspace"</span><span>: </span><span>"/app/.openviking/data"</span><span>,</span></div></div><div><div><div>25</div></div><div><span>    </span><span>"agfs"</span><span>: { </span><span>"backend"</span><span>: </span><span>"local"</span><span> },</span></div></div><div><div><div>26</div></div><div><span>    </span><span>"vectordb"</span><span>: { </span><span>"backend"</span><span>: </span><span>"local"</span><span> }</span></div></div><div><div><div>27</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>28</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>docker-compose.yml</code> 就一行挂载的事：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>services</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>openviking</span><span>:</span></div></div><div><div><div>3</div></div><div><span>    </span><span>image</span><span>: </span><span>ghcr.io/volcengine/openviking:latest</span></div></div><div><div><div>4</div></div><div><span>    </span><span>container_name</span><span>: </span><span>openviking</span></div></div><div><div><div>5</div></div><div><span>    </span><span>restart</span><span>: </span><span>unless-stopped</span></div></div><div><div><div>6</div></div><div><span>    </span><span>ports</span><span>:</span></div></div><div><div><div>7</div></div><div><span><span>      </span></span><span>- </span><span>"1933:1933"</span></div></div><div><div><div>8</div></div><div><span>    </span><span>volumes</span><span>:</span></div></div><div><div><div>9</div></div><div><span><span>      </span></span><span>- </span><span>/vol1/1000/Docker/OpenViking:/app/.openviking</span></div></div><div><div><div>10</div></div><div><span>    </span><span>environment</span><span>:</span></div></div><div><div><div>11</div></div><div><span><span>      </span></span><span>- </span><span>TZ=Asia/Shanghai</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>踩坑记录：换 embedding 模型，服务直接拒绝启动<a href="#踩坑记录换-embedding-模型服务直接拒绝启动"><span>#</span></a></h2><p>这是最大的一个坑。</p><p>Embedding 模型决定向量库的坐标系，所有记忆的向量都按它生成。<strong>换模型 = 换坐标系</strong>，所以向量库必须”一个模型一个池子”。OpenViking 启动时会校验向量库记录的 embedding 元数据和当前配置是否一致，不一致直接拒绝启动，这是保护机制：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>EmbeddingRebuildRequiredError: Existing collection embedding metadata does</span></div></div><div><div><div>2</div></div><div><span>not match current configuration. Rebuild is required...</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>我一开始用的 <code>Qwen3-Embedding-0.6B</code>（1024 维），后来想换 4B，改了配置重启，就撞上这个。而且更关键的是：</p>

<table><thead><tr><th>模型</th><th>维度</th></tr></thead><tbody><tr><td>Qwen3-Embedding-0.6B</td><td>1024</td></tr><tr><td>Qwen3-Embedding-4B / 8B</td><td>2560</td></tr></tbody></table><p><strong>维度都变了</strong>，不是同一个坐标系的问题，是盒子尺寸都不一样了，旧向量完全没法复用。处理方式：</p><ol>
<li>停容器，删掉旧向量库（<code>data/vectordb/context</code>，备份一份再删）</li>
<li>改 <code>ov.conf</code> 里 <code>dimension: 2560</code></li>
<li>启动，向量库按新模型重建</li>
<li>已有内容重新向量化（我直接重新导入一次，173 个文件，210 秒跑完，零错误）</li>
</ol><p>如果你换的是同维度的模型（比如 4B ↔ 8B，都是 2560），理论上可以设 <code>embedding.allow_metadata_override: true</code> 保留旧向量，但<strong>不建议</strong>，不同模型的语义空间不同，新旧向量混在一个库里检索会失真。老老实实重建最干净。</p><div><div><div></div><div>换个 VLM 没事</div></div><div><p>只有 Embedding 要重建。VLM 模型（负责摘要、记忆提取）输出的是文本，跟向量库无关，随便换，改配置重启就行。</p></div></div></section>
<section><h2>账号和权限：一个 Agent 一个用户<a href="#账号和权限一个-agent-一个用户"><span>#</span></a></h2><p>OpenViking 是多租户的：<code>account</code>（工作区）+ <code>user</code>（用户）。<strong>记忆、会话按用户隔离</strong>，<code>viking://resources</code> 是同一工作区共享的。</p><p>实践建议：<strong>每个 Agent 一个 user 账号</strong>，各自用各自的 user key 连接，记忆互不干扰。创建用户走 Admin API（root key 调用）：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-X</span><span> </span><span>POST</span><span> </span><span>http://127.0.0.1:1933/api/v1/admin/accounts/default/users</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>  </span><span>-H</span><span> </span><span>"Content-Type: application/json"</span><span> </span><span>\</span></div></div><div><div><div>3</div></div><div><span>  </span><span>-H</span><span> </span><span>"X-API-Key: &lt;root-key&gt;"</span><span> </span><span>\</span></div></div><div><div><div>4</div></div><div><span>  </span><span>-d</span><span> </span><span>'{"user_id": "hermes", "role": "user"}'</span></div></div><div><div><div>5</div></div><div><span># 返回 user_key，就是给这个 agent 用的</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>注意三种角色的分工：</p>

<table><thead><tr><th>角色</th><th>范围</th><th>能干什么</th></tr></thead><tbody><tr><td>ROOT</td><td>全局</td><td>建工作区、管所有用户（root key）</td></tr><tr><td>ADMIN</td><td>单个工作区</td><td>管本工作区用户、重发 key</td></tr><tr><td>USER</td><td>自己</td><td>只能访问自己的记忆 + 共享资源</td></tr></tbody></table><p><strong>root key 不能直接访问数据 API</strong>（返回 PERMISSION_DENIED），Agent 连接必须用 user key。</p></section>
<section><h2>部署完成长什么样<a href="#部署完成长什么样"><span>#</span></a></h2><p>启动后等健康检查通过：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>http://127.0.0.1:1933/health</span></div></div><div><div><div>2</div></div><div><span># {"status":"ok","healthy":true,...}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>浏览器打开 <code>http://你的NAS-IP:1933/studio</code> 就是 Web Studio：</p><p></p><figure><img alt="OpenViking Web Studio 界面：左侧 Context tree，中间 L0 摘要 / L1 概览" loading="lazy" width="1200" height="668" src="/_astro/image-002.ZtYcCbFY_Z1VvDQf.webp" /><figcaption>OpenViking Web Studio 界面：左侧 Context tree，中间 L0 摘要 / L1 概览</figcaption></figure><p></p><p>给 agent 配连接时就是填这个地址 + user key。我的 Hermes 打开 OpenViking 记忆提供方配置页，<code>ENDPOINT</code> 填 <code>http://192.168.31.3:1933</code>，<code>API KEY</code> 填 hermes 用户的 user key，保存即可。</p></section>
<section><h2>延伸：把 Hindsight 的记忆迁过来<a href="#延伸把-hindsight-的记忆迁过来"><span>#</span></a></h2><p>我之前用 Hindsight 给 Hermes 当记忆库，里面 570 条记忆单元 + 16 个会话全文。迁移路径是 Hindsight API 只读导出 → 转成 markdown → OpenViking 官方 API 导入，不是复制数据库文件（两家存储格式完全不兼容，Hindsight 是 PostgreSQL 图结构，OpenViking 是文件系统 + 向量库）。</p><p>导出（Hindsight 8888 端口 API）：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>"http://127.0.0.1:8888/v1/default/banks/hermes/memories/list?limit=100&amp;offset=0"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>组装成 markdown（一条记忆一个标题块，保留时间/实体/来源），打包上传导入：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-X</span><span> </span><span>POST</span><span> </span><span>http://127.0.0.1:1933/api/v1/resources/temp_upload</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>  </span><span>-H</span><span> </span><span>"X-API-Key: &lt;user-key&gt;"</span><span> </span><span>-F</span><span> </span><span>"file=@hermes-hindsight.zip"</span></div></div><div><div><div>3</div></div><div><span>curl</span><span> </span><span>-X</span><span> </span><span>POST</span><span> </span><span>http://127.0.0.1:1933/api/v1/resources</span><span> </span><span>\</span></div></div><div><div><div>4</div></div><div><span>  </span><span>-H</span><span> </span><span>"Content-Type: application/json"</span><span> </span><span>-H</span><span> </span><span>"X-API-Key: &lt;user-key&gt;"</span><span> </span><span>\</span></div></div><div><div><div>5</div></div><div><span>  </span><span>-d</span><span> </span><span>'{"temp_file_id": "...", "to": "viking://user/hermes/resources/hindsight-import"}'</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>导入后 OpenViking 自动向量化 + 生成摘要，中文语义检索实测命中率不错（“欧盟人工智能法案”这类查询能稳定召回）。要说损失也有：Hindsight 的知识图谱关系边没法迁移，记忆从”图结构”变成了”可语义检索的资源库”，这是迁移的本质，不是意外丢失。</p></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><ol>
<li>建目录 <code>/vol1/1000/Docker/OpenViking</code>，写 <code>ov.conf</code>（root key + embedding + VLM）</li>
<li><code>docker compose up -d</code>，等 <code>/health</code> 返回 ok</li>
<li>客户端连接：<code>http://NAS-IP:1933</code> + 一个 user key（Admin API 创建）</li>
<li>换 embedding 模型 = 重建索引，换 VLM = 随便换</li>
</ol></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>OpenViking 是我目前用下来最顺手的 agent 记忆方案：开源、本地数据、多租户隔离，Web Studio 能直接看记忆结构。折腾下来最大的坑就是向量模型那个，其余都挺顺的。数据全部落在 <code>/vol1/1000/Docker/OpenViking</code>，哪天要换机器，整个目录拷走 + <code>docker save</code> 镜像就完事。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/ipv6-cloudflare-lucky/</id>
      <title type="text">只有 IPv6 也能实现公网 IPV4 访问：Cloudflare + Lucky 白嫖双栈 HTTPS</title>
      <published>2026-08-28T00:00:00.000Z</published>
      <updated>2026-09-11T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/ipv6-cloudflare-lucky/"/>
      <summary type="text">家里设备只有 IPv6 出口：Lucky 做 DDNS 把 IPv6 同步到 Cloudflare，再靠 Cloudflare 代理免费实现双栈访问、去掉端口号和全链路 HTTPS。</summary>
      <content type="html"><![CDATA[<p>我这边网络情况比较特殊，公网只有 IPv6 出口，没有公网 IPv4。内网跑着飞牛 NAS（fnos）和 new-api（<a href="/posts/deploy/deploynewapi/">Docker 部署记录</a>）之类的服务，想在外面访问，走 IPv4 的路全堵死了。</p>
<p>后来折腾出这么一套组合：<strong>Lucky 做 DDNS 把 IPv6 同步到 Cloudflare，再让 Cloudflare 当代理</strong>。白嫖到三样东西：</p>
<ul>
<li>外面走 IPv4 也能访问（双栈）</li>
<li>访问域名不用再带端口号</li>
<li>全链路 HTTPS</li>
</ul>
<p>全程没花一分钱。记录一下怎么接的。</p>
<blockquote><p>补充一下：这套方案的核心就一句——<strong>边缘节点双栈，回源单栈</strong>，家里只有 IPv6 就让双栈的边缘当门面，IPv4 的访客也能进来。所以它是通用的，换哪个边缘平台都成立。后来我又用阿里云 ESA 在国内复刻了一遍，节点在境内、国内访问更稳，代价是域名得备案，细节写在 <a href="/posts/network/lucky-esa-reverse-proxy/">Lucky + 阿里云 ESA 那篇</a> 里。</p></blockquote>
<section><h2>准备工作<a href="#准备工作"><span>#</span></a></h2><ul>
<li>一台部署了 Lucky 的设备。安装过程就不展开了，教程一大把，实在太简单。连安装都搞不定的，下面的配置估计也够呛，不如直接用付费的内网穿透工具，省心。</li>
<li>一个托管在 Cloudflare 的域名，Free 套餐就行（我的是 547466.xyz）。</li>
</ul></section>
<section><h2>第一步：Lucky 添加 IPv6 DDNS<a href="#第一步lucky-添加-ipv6-ddns"><span>#</span></a></h2><p>打开 Lucky → 动态域名，添加一个 DDNS 任务：</p><ul>
<li>服务商选 <strong>Cloudflare</strong>，填上你的域名和 API Token</li>
<li>IPv6 地址来源选 <strong>网卡（ipv6Addr）</strong></li>
<li>域名记录里把要用的域名配上，类型 <strong>AAAA</strong>（我是指向内网飞牛那台机器的）</li>
</ul><p></p><figure><img alt="Lucky 添加 Cloudflare DDNS 任务：通过网卡获取 IPv6 地址，同步 AAAA 记录" loading="lazy" width="1508" height="603" src="/_astro/image-001.U4ioKAaV_Z1uELOA.webp" /><figcaption>Lucky 添加 Cloudflare DDNS 任务：通过网卡获取 IPv6 地址，同步 AAAA 记录</figcaption></figure><p></p><p>任务开起来之后，去 Cloudflare 看一眼解析记录，确认已经同步过去。</p></section>
<section><h2>第二步：Cloudflare 开代理，SSL 先设灵活<a href="#第二步cloudflare-开代理ssl-先设灵活"><span>#</span></a></h2><p>关键一步：每条 DNS 记录旁边的「代理状态」必须开着（橙色云），不然下面说的规则全都不生效。我这边 AAAA 记录都是已代理状态。</p><p></p><figure><img alt="Cloudflare DNS 记录全部处于已代理（橙色云）状态" loading="lazy" width="1258" height="393" src="/_astro/image-002.DD5xBukC_1Hre08.webp" /><figcaption>Cloudflare DNS 记录全部处于已代理（橙色云）状态</figcaption></figure><p></p><p>然后 SSL/TLS → 配置加密模式，先选「<strong>灵活</strong>」。这样访客到 Cloudflare 这段是 HTTPS，Cloudflare 到源站暂时走明文，先把链路跑通。想全链路 HTTPS 的话后面有专门一节。</p><p></p><figure><img alt="Cloudflare 加密模式选择「灵活」：只加密访客到 Cloudflare 的流量" loading="lazy" width="1615" height="1133" src="/_astro/image-003.DT4RhKap_1CqGcL.webp" /><figcaption>Cloudflare 加密模式选择「灵活」：只加密访客到 Cloudflare 的流量</figcaption></figure><p></p></section>
<section><h2>第三步：添加 Origin Rule，把端口去掉<a href="#第三步添加-origin-rule把端口去掉"><span>#</span></a></h2><p>这是整套方案的核心。Cloudflare 对外只开放 80/443，我用一条 <strong>Origin Rules（源站规则）</strong> 把所有入站请求的目标端口重写成 Lucky 的监听端口，这样外面访问 <code>https://你的域名</code> 就够了，不用再记端口。</p><p>操作路径：规则 → 概览 → Origin Rules，添加规则：</p><ul>
<li>匹配条件：<strong>所有传入请求</strong></li>
<li>操作：目标端口 → <strong>重写到 16666</strong></li>
</ul><p></p><figure><img alt="Cloudflare Rules 概览页，Origin Rule「feiniu」已活动：所有请求重写端口 16666" loading="lazy" width="2048" height="957" src="/_astro/cover.BzQKCY3n_8K4qf.webp" /><figcaption>Cloudflare Rules 概览页，Origin Rule「feiniu」已活动：所有请求重写端口 16666</figcaption></figure><p></p><p></p><figure><img alt="编辑 Origin Rule：匹配所有传入请求，目标端口重写到 16666" loading="lazy" width="475" height="1136" src="/_astro/image-005.ytjcjt6c_1NWMpQ.webp" /><figcaption>编辑 Origin Rule：匹配所有传入请求，目标端口重写到 16666</figcaption></figure><p></p><div><div><div></div><div>另一种路子</div></div><div><p>如果你的 IPv6 源站 80 端口可以直接访问，这步可以跳过，Lucky 的 Web 服务直接用 80 端口就行。好处是不走 Cloudflare，网速快非常多；代价是没法双栈访问，外面的 IPv4 进不来。选哪个自己掂量。</p><p>连公网 IPv6 都没有的环境，这套方案就玩不转了，那就只剩 <a href="/posts/network/cloudflaretunnel/">Cloudflare Tunnel</a> 这种穿透方案，部署教程我也写过。</p></div></div></section>
<section><h2>第四步：Lucky 添加 Web 服务<a href="#第四步lucky-添加-web-服务"><span>#</span></a></h2><p>回到 Lucky → Web 服务，添加规则：</p><ul>
<li>监听类型勾上 <strong>IPv6</strong></li>
<li><strong>监听端口 16666</strong>，必须跟上面 Origin Rule 重写的端口保持一致</li>
</ul><p></p><figure><img alt="Lucky 新增 Web 服务规则：监听 IPv6，端口 16666" loading="lazy" width="669" height="1186" src="/_astro/image-006.B6TH0380_21RUYI.webp" /><figcaption>Lucky 新增 Web 服务规则：监听 IPv6，端口 16666</figcaption></figure><p></p><p>规则下面可以加子规则，<strong>前端地址填想要的域名，后端地址填内网实际地址</strong>。我这边挂了两个服务：</p>

<table><thead><tr><th>子规则</th><th>前端地址</th><th>后端地址</th><th>说明</th></tr></thead><tbody><tr><td>fnos</td><td><code>547466.xyz</code></td><td><code>http://192.168.31.3:5666</code></td><td>飞牛 NAS（5666 是 fnOS 默认面板端口）</td></tr><tr><td>newapi</td><td><code>ai.547466.xyz</code></td><td>内网 new-api 服务</td><td>AI 中转站</td></tr></tbody></table><p></p><figure><img alt="Web 服务子规则 fnos：前端 547466.xyz，反向代理到内网 192.168.31.3" loading="lazy" width="684" height="1026" src="/_astro/image-007.B0RGEzMp_1W4FzQ.webp" /><figcaption>Web 服务子规则 fnos：前端 547466.xyz，反向代理到内网 192.168.31.3</figcaption></figure><p></p><p>到这一步，不带端口号访问已经通了。剩下的是可选强化。</p></section>
<section><h2>（可选）全链路 HTTPS<a href="#可选全链路-https"><span>#</span></a></h2><p>默认「灵活」模式下，Cloudflare 到源站这一截是明文。想两头都加密，按下面来：</p><ol>
<li><strong>加密模式改回「完整（严格）」</strong>：SSL/TLS → 配置加密模式，选「完整（严格）」，它会验证源站证书。</li>
</ol><p></p><figure><img alt="Cloudflare 加密模式改回「完整（严格）」，要求验证源站证书" loading="lazy" width="2048" height="983" src="/_astro/image-008.DOIKWsz7_sDKXr.webp" /><figcaption>Cloudflare 加密模式改回「完整（严格）」，要求验证源站证书</figcaption></figure><p></p><ol>
<li><strong>创建源站证书</strong>：SSL/TLS → 源服务器 → Origin 证书，创建一张 Cloudflare 签发的免费源站证书（选你要加密的域名就行）。</li>
</ol><p></p><figure><img alt="Cloudflare Origin 证书列表：*.547466.xyz 与 547466.xyz，有效期到 2041" loading="lazy" width="2048" height="752" src="/_astro/image-009.DQ44uVMv_Z2kCr9B.webp" /><figcaption>Cloudflare Origin 证书列表：*.547466.xyz 与 547466.xyz，有效期到 2041</figcaption></figure><p></p><p>3.<strong>把证书存成 txt</strong>：生成后页面上有「源证书」和「私钥」两段 PEM 内容，分别复制保存成两个文件（我存的是 <code>yuan.txt</code> 和 <code>key.txt</code>）。</p><p></p><figure><img alt="Cloudflare 源证书安装页：PEM 格式的源证书和私钥，分别保存为文件" loading="lazy" width="1581" height="1116" src="/_astro/image-010.DqsP4gIq_TCo1P.webp" /><figcaption>Cloudflare 源证书安装页：PEM 格式的源证书和私钥，分别保存为文件</figcaption></figure><p></p><ol>
<li>
<p><strong>Lucky 上传证书</strong>：Lucky → SSL/TLS 证书 → 添加证书，方式选「文件」，证书选 <code>yuan.txt</code>，Key 选 <code>key.txt</code>。</p>
<p></p><figure><img alt="Lucky SSL/TLS 证书管理：以文件方式添加证书（yuan.txt / key.txt）" loading="lazy" width="1835" height="1061" src="/_astro/image-011.h3TXNSHf_1gjKLm.webp" /><figcaption>Lucky SSL/TLS 证书管理：以文件方式添加证书（yuan.txt / key.txt）</figcaption></figure><p></p>
</li>
<li>
<p><strong>Web 服务开启 TLS</strong>：回到 Web 服务那条规则，把 TLS 打开（最低版本 TLS1.2）</p>
<p></p><figure><img alt="Lucky Web 服务规则开启 TLS（TLS 开关高亮）" loading="lazy" width="1624" height="1156" src="/_astro/image-012.DKq2kdG0_AGocu.webp" /><figcaption>Lucky Web 服务规则开启 TLS（TLS 开关高亮）</figcaption></figure><p></p>
</li>
</ol><p>这样就是全链路 HTTPS：浏览器 → Cloudflare（CF 泛域名证书）→ 源站（Origin 证书）。</p><blockquote><p>lucky里面也可以直接申请Let’s Encrypt的证书，那个也行。加了证书之后，⚠️访问一定记得要带https！</p></blockquote></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><ol>
<li>Lucky 添加 DDNS：网卡取 IPv6 → 同步到 Cloudflare 的 AAAA 记录（开代理）</li>
<li>Cloudflare 加密模式先设「灵活」</li>
<li>Cloudflare 添加 Origin Rule：所有请求目标端口重写到 16666</li>
<li>Lucky Web 服务：监听 16666，子规则按域名反代到内网</li>
<li>（可选）CF 改「完整（严格）」+ Origin 证书 + Lucky 开 TLS → 全链路 HTTPS</li>
</ol></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/cf-pages-edgetunnel/</id>
      <title type="text">不用服务器自建节点：Cloudflare Pages 部署 edgetunnel</title>
      <published>2026-08-27T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/cf-pages-edgetunnel/"/>
      <summary type="text">用 Cloudflare Pages 白嫖部署 cmliu 开源的 edgetunnel，绑上 KV 就能在后台生成订阅，全程不需要服务器。</summary>
      <content type="html"><![CDATA[<p>最近收藏的两篇教程都在推同一个东西——cmliu 开源的 <a href="https://github.com/cmliu/edgetunnel" target="_blank">edgetunnel</a>，部署到 Cloudflare 上就能搭一个自己的节点，不用买服务器，Cloudflare 的免费额度直接白嫖。反正流程不复杂，我就自己动手过了一遍，把步骤和要注意的点记下来。</p>
<div><div><div></div><div>说明</div></div><div><p>仅供学习记录，使用前请了解并遵守当地法律法规。</p></div></div>
<section><h2>准备工作<a href="#准备工作"><span>#</span></a></h2><p>就三样：</p><ol>
<li>一个 Cloudflare 账号（免费版就行）</li>
<li>项目安装包：去 <a href="https://github.com/cmliu/edgetunnel" target="_blank">edgetunnel 仓库</a>下载 <a href="https://github.com/cmliu/edgetunnel/archive/refs/heads/main.zip" target="_blank">main.zip</a>，顺手给作者点个 Star</li>
<li>（可选）一个自己已有的域名，已经托管到 Cloudflare 的最省事</li>
</ol></section>
<section><h2>第一步：创建 Pages 项目并部署<a href="#第一步创建-pages-项目并部署"><span>#</span></a></h2><p>进 Cloudflare 控制台，左侧找到 <strong>Workers 和 Pages</strong>，在创建页面的最底部有一行「想要部署 Pages？开始使用」，从这里进：</p><p></p><figure><img alt="Workers 和 Pages 里部署 Pages 的入口" loading="lazy" width="1483" height="795" src="/_astro/image-001.04NLpHll_ZyTTuQ.webp" /><figcaption>Workers 和 Pages 里部署 Pages 的入口</figcaption></figure><p></p><p>Pages 项目不用连 GitHub，选 <strong>上传资产</strong> 的方式就行。给项目取个名字，点击 <strong>创建项目</strong>，然后把刚才下载的 <code>main.zip</code> 拖进去上传，右下角点 <strong>部署站点</strong>：</p><p></p><figure><img alt="上传 edgetunnel-main.zip 后点击部署站点" loading="lazy" width="1364" height="725" src="/_astro/image-002.BOSKD65A_tnMgG.webp" /><figcaption>上传 edgetunnel-main.zip 后点击部署站点</figcaption></figure><p></p><p>等进度条跑完，首次部署就成了。</p></section>
<section><h2>第二步：设置管理员密码，再重新部署<a href="#第二步设置管理员密码再重新部署"><span>#</span></a></h2><p>这一步是最容易漏的。部署完先别急着访问，要先告诉它管理员密码是多少：</p><ol>
<li>点 <strong>继续处理站点</strong> 进入项目</li>
<li><strong>设置</strong> &gt; <strong>环境变量</strong> &gt; 为生产环境定义变量 &gt; <strong>添加变量</strong></li>
<li>变量名称填 <code>ADMIN</code>，值填你自己定的管理密码，点 <strong>保存</strong></li>
</ol><p>保存之后环境变量不会立即生效，回到 <strong>部署</strong> 选项卡，右下角点 <strong>创建新部署</strong>，把 <code>main.zip</code> 再传一遍，点 <strong>保存并部署</strong>。后面每改一次配置都要这么重新部署一次，记住这个套路。</p></section>
<section><h2>第三步：创建 KV 命名空间并绑定<a href="#第三步创建-kv-命名空间并绑定"><span>#</span></a></h2><p>edgetunnel 需要一个 KV 来存配置数据，不然后台很多东西没法持久化。</p><p>先建空间：在左侧菜单找到 <strong>存储和数据库</strong> &gt; <strong>Workers KV</strong>，点右上角 <strong>Create Instance</strong>，弹窗里给命名空间起个名字，随意：</p><p></p><figure><img alt="创建 KV 命名空间的弹窗" loading="lazy" width="2048" height="1153" src="/_astro/image-003.D4RM60Lm_Z34iYf.webp" /><figcaption>创建 KV 命名空间的弹窗</figcaption></figure><p></p><p>建好之后就能在列表里看到它了：</p><p></p><figure><img alt="Workers KV 列表里出现刚创建的命名空间" loading="lazy" width="1685" height="779" src="/_astro/cover.e7ea1_sE_234SwX.webp" /><figcaption>Workers KV 列表里出现刚创建的命名空间</figcaption></figure><p></p><p>然后绑定到 Pages 项目：打开项目 <strong>设置</strong> 选项卡，往下找到 <strong>绑定</strong>，点 <strong>+ 添加</strong>，在弹窗里选 <strong>KV 命名空间</strong>：</p><p></p><figure><img alt="Pages 项目设置中的绑定入口和 KV 命名空间选项" loading="lazy" width="1685" height="984" src="/_astro/image-005.Bj_Rj15w_Z1jIKKw.webp" /><figcaption>Pages 项目设置中的绑定入口和 KV 命名空间选项</figcaption></figure><p></p><p>关键的地方来了：<strong>变量名称必须填 <code>KV</code></strong>（大小写一致），KV 命名空间选刚才建的那个，点 <strong>保存</strong>：</p><p></p><figure><img alt="变量名称填写 KV 并选择刚创建的命名空间" loading="lazy" width="511" height="994" src="/_astro/image-006.yVikanXN_Z1sEzF4.webp" /><figcaption>变量名称填写 KV 并选择刚创建的命名空间</figcaption></figure><p></p><p>弹窗里会提示”此更改将在下次部署时生效”，所以老规矩，回部署页面再重新部署一次。</p></section>
<section><h2>第四步：绑定自定义域名（可选）<a href="#第四步绑定自定义域名可选"><span>#</span></a></h2><p>不绑域名也能用自带的 <code>xxx.pages.dev</code> 访问，但部分地区 DNS 污染严重，pages.dev 域名根本打不开，所以有自己的域名建议还是绑一下：</p><ol>
<li>在 Pages 控制台切到 <strong>自定义域</strong> 选项卡，点 <strong>设置自定义域</strong></li>
<li>填入你的次级域名。注意别拿根域名来用，比如你的根域名是 <code>example.com</code>，这里就填 <code>cf.example.com</code> 这种</li>
<li>按提示回域名的 DNS 服务商那里，添加一条 CNAME 记录：主机记录 <code>cf</code>，指向 <code>edgetunnel.pages.dev</code>，然后点 <strong>激活域</strong></li>
</ol><p>配置没问题的话证书会自动签发，几分钟就能生效。视频版可以看这个<a href="https://www.youtube.com/watch?v=LeT4jQUh8ok&amp;t=851s" target="_blank">视频教程</a>。</p></section>
<section><h2>第五步：进入后台<a href="#第五步进入后台"><span>#</span></a></h2><p>浏览器访问 <code>https://你的域名/admin</code>（没绑域名就用 <code>https://项目名.pages.dev/admin</code>），输入第二步设置的 ADMIN 密码就能登录。后台长这样：</p><p></p><figure><img alt="edgetunnel 后台设置页面，顶部获取节点链接和自适应订阅" loading="lazy" width="2048" height="1152" src="/_astro/image-007.BW8BgZBB_Z1M2YeB.webp" /><figcaption>edgetunnel 后台设置页面，顶部获取节点链接和自适应订阅</figcaption></figure><p></p><p>上面就是节点链接和订阅地址，复制到 v2rayNG、Clash Verge 这类客户端导入就能用了，软路由上跑 <a href="/posts/network/istoreosinstallopenclash/istoreosinstallopenclash/">OpenClash</a> 的话订阅直接导进去，全家设备都能走。右上角还有个”我是高手！我就是要折腾！“的入口，想玩优选 IP、ProxyIP、订阅转换的可以从那里深入，我目前还没折腾到那一步——优选 IP 单独写过一篇：<a href="/posts/network/cloudflareoptimalip/">Cloudflare Workers优选IP</a>。</p></section>
<section><h2>踩坑与提醒<a href="#踩坑与提醒"><span>#</span></a></h2><ol>
<li><strong>改了配置不生效</strong>：ADMIN 环境变量、KV 绑定都是下次部署才生效，改完必须重新上传 zip 部署一次</li>
<li><strong>KV 变量名称</strong>：必须一字不差地填 <code>KV</code>，不是随便起的名字</li>
<li><strong>自定义域名别用根域名</strong>：根域名留给解析，节点用次级域名就行</li>
<li>免费额度对个人轻度使用完全够，但别拿来做重活，毕竟吃的是 CF 的公益饭</li>
</ol></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><ol>
<li>下载 main.zip</li>
<li>Pages 上传资产部署，设 <code>ADMIN</code> 环境变量，重新部署</li>
<li>建 KV 命名空间，绑定到 Pages，变量名 <code>KV</code>，再重新部署</li>
<li>（可选）绑个自己的次级域名</li>
<li>访问 <code>/admin</code> 登录，复制订阅到客户端</li>
</ol></section>
<section><h2>参考资料<a href="#参考资料"><span>#</span></a></h2><ul>
<li><a href="https://eo.blog.cmliussss.com/p/edt2/" target="_blank">Edgetunnel 2.0 全新版本 - CMLiussss Blog</a></li>
<li><a href="https://www.freedidi.com/23618.html" target="_blank">2026 最强 Cloudflare 免费节点 - 零度博客</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/maimbot/</id>
      <title type="text">Docker 部署 MaiMBot QQ 机器人：从拉镜像到跑起来</title>
      <published>2026-08-23T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/maimbot/"/>
      <summary type="text">用 Docker Compose 一键跑起 MaiMBot + NapCat + sqlite-web 三件套，附完整 compose 文件、QQ 扫码登录、正向 WebSocket 配置和白的名单踩坑。</summary>
      <content type="html"><![CDATA[<section><h2>为什么要折腾<a href="#为什么要折腾"><span>#</span></a></h2><p>之前一直想给 QQ 群搞个 AI 机器人，试了不少方案不是要备案就是要装各种依赖，烦得很。后来刷到 MaiMBot 这个项目，看一眼就知道是怎么个玩法：不用自己写机器人代码，把模型接进去就能让 QQ 里的 bot 陪聊，等于一个账号接大模型的壳。</p><p>研究了一下发现部署倒不算复杂，就是中间有几个坑不踩不知道。这篇把完整流程记录下来，从拉镜像到登录 QQ、接通模型，一次说清楚。</p></section>
<section><h2>三件套都是什么<a href="#三件套都是什么"><span>#</span></a></h2><p>整套东西就是三个容器：</p>

<table><thead><tr><th>容器</th><th>作用</th></tr></thead><tbody><tr><td><code>maim-bot-core</code></td><td>MaiMBot 主程序，接模型、管对话逻辑</td></tr><tr><td><code>maim-bot-napcat</code></td><td>NapCat，负责连 QQ 账号</td></tr><tr><td><code>sqlite-web</code></td><td>网页版看数据库，方便后面对照排查</td></tr></tbody></table><p>原理上 NapCat 把 QQ 账号包装成一个 OneBot 协议服务，MaiMBot 通过正向 WebSocket 连上去，就能收发群消息了。所以核心就两步：NapCat 登录 QQ 并监听，MaiMBot 连上这个监听端口。</p></section>
<section><h2>编写 docker-compose.yml<a href="#编写-docker-composeyml"><span>#</span></a></h2><p>我直接用项目提供的示例，稍微整理了一下。完整文件如下：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>services</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>core</span><span>:</span></div></div><div><div><div>3</div></div><div><span>    </span><span>container_name</span><span>: </span><span>maim-bot-core</span></div></div><div><div><div>4</div></div><div><span>    </span><span>#### prod ####</span></div></div><div><div><div>5</div></div><div><span>    </span><span>image</span><span>: </span><span>sengokucola/maibot:latest</span></div></div><div><div><div>6</div></div><div><span>    </span><span># image: infinitycat/maibot:latest</span></div></div><div><div><div>7</div></div><div><span>    </span><span>#### dev ####</span></div></div><div><div><div>8</div></div><div><span>    </span><span># image: sengokucola/maibot:dev</span></div></div><div><div><div>9</div></div><div><span>    </span><span># image: infinitycat/maibot:dev</span></div></div><div><div><div>10</div></div><div><span>    </span><span>environment</span><span>:</span></div></div><div><div><div>11</div></div><div><span><span>      </span></span><span>- </span><span>TZ=Asia/Shanghai</span></div></div><div><div><div>12</div></div><div><span><span>      </span></span><span>- </span><span>EULA_AGREE=8e6e7d647f7f82d6ea98456b73908656</span></div></div><div><div><div>13</div></div><div><span><span>      </span></span><span>- </span><span>PRIVACY_AGREE=91e5db7659c560bc3545e63859b6ebc0</span></div></div><div><div><div>14</div></div><div><span><span>      </span></span><span>- </span><span>MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1</span><span> </span><span># Docker 无法交互确认旧版升级迁移，默认跳过确认提示</span></div></div><div><div><div>15</div></div><div><span><span>      </span></span><span>- </span><span>MAIBOT_STATISTICS_REPORT_PATH=/MaiMBot/data/maibot_statistics.html</span><span> </span><span># 统计数据输出到共享目录，首次运行可自动创建文件</span></div></div><div><div><div>16</div></div><div><span><span>      </span></span><span>- </span><span>WEBUI_HOST=0.0.0.0</span><span> </span><span># Docker 中需要监听全部容器网卡，宿主机才能通过端口映射访问 WebUI</span></div></div><div><div><div>17</div></div><div><span>    </span><span>#      - EULA_AGREE=8e6e7d647f7f82d6ea98456b73908656 # 同意EULA</span></div></div><div><div><div>18</div></div><div><span>    </span><span>#      - PRIVACY_AGREE=91e5db7659c560bc3545e63859b6ebc0 # 同意隐私条款</span></div></div><div><div><div>19</div></div><div><span>    </span><span>ports</span><span>:</span></div></div><div><div><div>20</div></div><div><span><span>      </span></span><span>- </span><span>"18001:8001"</span><span> </span><span># webui端口</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>#      - "8000:8000"</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>    </span><span>volumes</span><span>:</span></div></div><div><div><div>25</div></div><div><span>      </span><span># 监听地址和端口会用于生成或迁移 ./docker-config/mmc/bot_config.toml 的 maim_message 与 webui 配置段</span></div></div><div><div><div>26</div></div><div><span><span>      </span></span><span>- </span><span>./docker-config/mmc:/MaiMBot/config</span><span> </span><span># 持久化bot配置文件</span></div></div><div><div><div>27</div></div><div><span><span>      </span></span><span>- </span><span>./data/MaiMBot:/MaiMBot/data</span><span> </span><span># 共享目录</span></div></div><div><div><div>28</div></div><div><span><span>      </span></span><span>- </span><span>./data/MaiMBot-plugin-data:/MaiMBot/data/plugins</span><span> </span><span># 插件持久数据目录，与插件源码目录隔离</span></div></div><div><div><div>29</div></div><div><span><span>      </span></span><span>- </span><span>./data/MaiMBot/emoji:/data/emoji</span><span> </span><span># 持久化表情包</span></div></div><div><div><div>30</div></div><div><span><span>      </span></span><span>- </span><span>./data/MaiMBot/plugins:/MaiMBot/plugins</span><span> </span><span># 插件目录</span></div></div><div><div><div>31</div></div><div><span><span>      </span></span><span>- </span><span>./data/MaiMBot/logs:/MaiMBot/logs</span><span> </span><span># 日志目录</span></div></div><div><div><div>32</div></div><div><span><span>      </span></span><span>- </span><span>./depends-data:/MaiMBot/depends-data</span><span> </span><span># 运行时资源文件</span></div></div><div><div><div>33</div></div><div><span>      </span><span># - site-packages:/usr/local/lib/python3.13/site-packages # 持久化Python包，需要时启用</span></div></div><div><div><div>34</div></div><div><span>    </span><span>restart</span><span>: </span><span>always</span></div></div><div><div><div>35</div></div><div><span>    </span><span>networks</span><span>:</span></div></div><div><div><div>36</div></div><div><span><span>      </span></span><span>- </span><span>maim_bot</span></div></div><div><div><div>37</div></div><div>
</div></div><div><div><div>38</div></div><div><span>  </span><span># 启用 HTTPS 时，建议注释掉 core 服务中的 "18001:8001" 端口映射，</span></div></div><div><div><div>39</div></div><div><span>  </span><span># 然后取消注释此 Caddy 反向代理示例块，并按 dashboard/docs/Caddyfile.docker.example 修改域名。</span></div></div><div><div><div>40</div></div><div><span>  </span><span># caddy:</span></div></div><div><div><div>41</div></div><div><span>  </span><span>#   image: caddy:2</span></div></div><div><div><div>42</div></div><div><span>  </span><span>#   container_name: maibot-caddy</span></div></div><div><div><div>43</div></div><div><span>  </span><span>#   restart: always</span></div></div><div><div><div>44</div></div><div><span>  </span><span>#   ports:</span></div></div><div><div><div>45</div></div><div><span>  </span><span>#     - "80:80"</span></div></div><div><div><div>46</div></div><div><span>  </span><span>#     - "443:443"</span></div></div><div><div><div>47</div></div><div><span>  </span><span>#   volumes:</span></div></div><div><div><div>48</div></div><div><span>  </span><span>#     - ./dashboard/docs/Caddyfile.docker.example:/etc/caddy/Caddyfile:ro</span></div></div><div><div><div>49</div></div><div><span>  </span><span>#     - caddy_data:/data</span></div></div><div><div><div>50</div></div><div><span>  </span><span>#     - caddy_config:/config</span></div></div><div><div><div>51</div></div><div><span>  </span><span>#   depends_on:</span></div></div><div><div><div>52</div></div><div><span>  </span><span>#     - core</span></div></div><div><div><div>53</div></div><div><span>  </span><span>#   networks:</span></div></div><div><div><div>54</div></div><div><span>  </span><span>#     - maim_bot</span></div></div><div><div><div>55</div></div><div>
</div></div><div><div><div>56</div></div><div><span>  </span><span>napcat</span><span>:</span></div></div><div><div><div>57</div></div><div><span>    </span><span>environment</span><span>:</span></div></div><div><div><div>58</div></div><div><span><span>      </span></span><span>- </span><span>NAPCAT_UID=1000</span></div></div><div><div><div>59</div></div><div><span><span>      </span></span><span>- </span><span>NAPCAT_GID=1000</span></div></div><div><div><div>60</div></div><div><span><span>      </span></span><span>- </span><span>TZ=Asia/Shanghai</span></div></div><div><div><div>61</div></div><div><span>    </span><span>ports</span><span>:</span></div></div><div><div><div>62</div></div><div><span><span>      </span></span><span>- </span><span>"6099:6099"</span></div></div><div><div><div>63</div></div><div><span>    </span><span>volumes</span><span>:</span></div></div><div><div><div>64</div></div><div><span><span>      </span></span><span>- </span><span>./docker-config/napcat:/app/napcat/config</span><span> </span><span># 持久化napcat配置文件</span></div></div><div><div><div>65</div></div><div><span><span>      </span></span><span>- </span><span>./data/qq:/app/.config/QQ</span><span> </span><span># 持久化QQ本体</span></div></div><div><div><div>66</div></div><div><span><span>      </span></span><span>- </span><span>./data/MaiMBot:/MaiMBot/data</span><span> </span><span># 共享目录</span></div></div><div><div><div>67</div></div><div><span>    </span><span>container_name</span><span>: </span><span>maim-bot-napcat</span></div></div><div><div><div>68</div></div><div><span>    </span><span>restart</span><span>: </span><span>always</span></div></div><div><div><div>69</div></div><div><span>    </span><span>image</span><span>: </span><span>mlikiowa/napcat-docker:latest</span></div></div><div><div><div>70</div></div><div><span>    </span><span>networks</span><span>:</span></div></div><div><div><div>71</div></div><div><span><span>      </span></span><span>- </span><span>maim_bot</span></div></div><div><div><div>72</div></div><div><span>  </span><span>sqlite-web</span><span>:</span></div></div><div><div><div>73</div></div><div><span>    </span><span># 注意：coleifer/sqlite-web 镜像不支持arm64</span></div></div><div><div><div>74</div></div><div><span>    </span><span>image</span><span>: </span><span>coleifer/sqlite-web</span></div></div><div><div><div>75</div></div><div><span>    </span><span>container_name</span><span>: </span><span>sqlite-web</span></div></div><div><div><div>76</div></div><div><span>    </span><span>restart</span><span>: </span><span>always</span></div></div><div><div><div>77</div></div><div><span>    </span><span>ports</span><span>:</span></div></div><div><div><div>78</div></div><div><span><span>      </span></span><span>- </span><span>"8120:8080"</span></div></div><div><div><div>79</div></div><div><span>    </span><span>volumes</span><span>:</span></div></div><div><div><div>80</div></div><div><span><span>      </span></span><span>- </span><span>./data/MaiMBot:/data/MaiMBot</span></div></div><div><div><div>81</div></div><div><span>    </span><span>environment</span><span>:</span></div></div><div><div><div>82</div></div><div><span><span>      </span></span><span>- </span><span>SQLITE_DATABASE=MaiMBot/MaiBot.db</span><span>  </span><span># 你的数据库文件</span></div></div><div><div><div>83</div></div><div><span>    </span><span>networks</span><span>:</span></div></div><div><div><div>84</div></div><div><span><span>      </span></span><span>- </span><span>maim_bot</span></div></div><div><div><div>85</div></div><div>
</div></div><div><div><div>86</div></div><div><span># volumes:         # 若需要持久化Python包时启用</span></div></div><div><div><div>87</div></div><div><span>#   site-packages:</span></div></div><div><div><div>88</div></div><div><span>#   caddy_data:</span></div></div><div><div><div>89</div></div><div><span>#   caddy_config:</span></div></div><div><div><div>90</div></div><div>
</div></div><div><div><div>91</div></div><div><span>networks</span><span>:</span></div></div><div><div><div>92</div></div><div><span>  </span><span>maim_bot</span><span>:</span></div></div><div><div><div>93</div></div><div><span>    </span><span>driver</span><span>: </span><span>bridge</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>几个值得注意的地方：</p><ul>
<li><strong><code>EULA_AGREE</code> 和 <code>PRIVACY_AGREE</code></strong>：如果这两个环境变量没设置，第一次启动会卡在交互确认那里，Docker 环境下你没法在终端里回车，所以一定要带上。上面的值就是同意确认。</li>
<li><strong><code>MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1</code></strong>：老版本迁移过来时跳过交互确认的，新装有没有都一样，不影响。</li>
<li><strong><code>WEBUI_HOST=0.0.0.0</code></strong>：必须设。不设的话 WebUI 只监听容器内部回环地址，宿主机通过端口映射也访问不到。</li>
<li><strong>端口</strong>：WebUI 映射到宿主机的 <code>18001</code>，NapCat WebUI 是 <code>6099</code>，sqlite-web 是 <code>8120</code>。想改就改冒号左边，但记得记好，后面要访问。</li>
</ul></section>
<section><h2>创建目录并启动<a href="#创建目录并启动"><span>#</span></a></h2><p>在你的部署目录下，先手动创建这些文件夹（compose 里的卷挂载到了一堆路径，目录不存在 Docker 会帮你建，但部分插件和日志目录最好先建好，避免权限问题）：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>mkdir</span><span> </span><span>-p</span><span> </span><span>docker-config/mmc</span><span> </span><span>docker-config/napcat</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>         </span><span>data/MaiMBot-plugin-data</span><span> </span><span>data/MaiMBot/emoji</span><span> </span><span>\</span></div></div><div><div><div>3</div></div><div><span>         </span><span>data/MaiMBot/plugins</span><span> </span><span>data/MaiMBot/logs</span><span> </span><span>\</span></div></div><div><div><div>4</div></div><div><span>         </span><span>data/qq</span><span> </span><span>depends-data</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p></p><figure><img alt="需要手动创建的目录结构" loading="lazy" width="705" height="753" src="/_astro/cover.B16ujaD6_Z2aj2RR.webp" /><figcaption>需要手动创建的目录结构</figcaption></figure><p></p><p></p><figure><img alt="napcat 目录下还有三个子文件夹" loading="lazy" width="733" height="479" src="/_astro/image-001.DLeAZVc9_Z8SDL6.webp" /><figcaption>napcat 目录下还有三个子文件夹</figcaption></figure><p></p><p>对应关系如下（按上面 compose 里卷的挂载路径整理）：</p>

<table><thead><tr><th>宿主机目录</th><th>容器内路径</th><th>用途</th></tr></thead><tbody><tr><td><code>docker-config/mmc</code></td><td><code>/MaiMBot/config</code></td><td>bot 配置文件</td></tr><tr><td><code>docker-config/napcat</code></td><td><code>/app/napcat/config</code></td><td>NapCat 配置</td></tr><tr><td><code>data/qq</code></td><td><code>/app/.config/QQ</code></td><td>QQ 登录数据</td></tr><tr><td><code>data/MaiMBot</code></td><td><code>/MaiMBot/data</code></td><td>共享数据</td></tr><tr><td><code>depends-data</code></td><td><code>/MaiMBot/depends-data</code></td><td>运行时资源</td></tr></tbody></table><p>然后启动：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>docker</span><span> </span><span>compose</span><span> </span><span>up</span><span> </span><span>-d</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>第一次会从 Docker Hub 拉几个镜像，国内网络可能要等一会儿。</p></section>
<section><h2>第一步：登录 QQ<a href="#第一步登录-qq"><span>#</span></a></h2><p>先在浏览器打开 <code>http://你的服务器IP:6099</code>，这是 NapCat 的 WebUI。进去会要一个 token：</p><p></p><figure><img alt="NapCat 容器日志，显示 WebUI Access Token" loading="lazy" width="1312" height="701" src="/_astro/image-002.CgKev0l3_D2vbB.webp" /><figcaption>NapCat 容器日志，显示 WebUI Access Token</figcaption></figure><p></p><p>这个 token 在容器日志里：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>docker</span><span> </span><span>logs</span><span> </span><span>maim-bot-napcat</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>日志里会打印一行类似 <code>Access Token: xxxxxxxx</code> 的东西，复制进去就能进 WebUI 了。</p><p>进去之后先扫码登录自己的 QQ。这里提醒一句：<strong>MaiMBot 是拿你的 QQ 号当机器人用的</strong>，扫码登录之后这个号就是 bot 本体，建议用个小号，别把自己大号交给机器人。</p><p>登录成功之后，勾选上<strong>快速登录</strong>，后面重启容器就不用再扫码了：</p><p></p><figure><img alt="开启快速登录，重启免扫码" loading="lazy" width="1345" height="756" src="/_astro/image-009.vdfI7xyt_Zp1c9.webp" /><figcaption>开启快速登录，重启免扫码</figcaption></figure><p></p><div><div><div></div><div>警告</div></div><div><p>扫码登录有封号风险，这是 NapCat 这类框架的通用问题，介意的话别拿主号玩。</p></div></div></section>
<section><h2>第二步：开启正向 WebSocket<a href="#第二步开启正向-websocket"><span>#</span></a></h2><p>在 NapCat WebUI 的<strong>网络配置</strong>里：</p><ol>
<li>新建或启用一个 <strong>正向 WebSocket / WebSocket 服务器</strong>。</li>
<li>监听地址和端口默认即可，常用端口 <code>3166</code>。</li>
<li>可以顺手设一个访问 Token。</li>
<li><strong>IP 监听建议填 <code>0.0.0.0</code></strong>，只监听回环地址的话后面的 MaiBot 容器连不进来。</li>
</ol><p></p><figure><img alt="NapCat 网络配置，新建正向 WebSocket" loading="lazy" width="789" height="873" src="/_astro/image-003.DzrFArqF_Z2pjcsC.webp" /><figcaption>NapCat 网络配置，新建正向 WebSocket</figcaption></figure><p></p><p>这里有个关键点，<strong>端口要映射出去</strong>。因为 NapCat 和 MaiBot 是两个容器，MaiBot 要访问的是宿主机上映射出来的端口。compose 里 NapCat 只映射了 <code>6099</code>，如果你把 WebSocket 监听设在 <code>3166</code>，需要在 compose 的 napcat 服务里补上 <code>"3166:3166"</code> 再 <code>docker compose up -d</code>，启动后就能看到端口映射生效：</p><p></p><figure><img alt="容器端口映射，3166 和 6099 都在监听" loading="lazy" width="1042" height="222" src="/_astro/image-004.Bl6Bdaom_HbqwY.webp" /><figcaption>容器端口映射，3166 和 6099 都在监听</figcaption></figure><p></p><div><div><div></div><div>提示</div></div><div><p>不要把 NapCat WebUI 的 token、MaiBot WebUI 的 Access Token 和 WebSocket 的访问 token 混用，三个是不同东西，各填各的。</p></div></div></section>
<section><h2>第三步：配置 MaiBot 适配器<a href="#第三步配置-maibot-适配器"><span>#</span></a></h2><p>浏览器打开 <code>http://你的服务器IP:18001</code>，进 MaiBot 的 WebUI。首次进入需要 Access Token，同样去容器日志里找：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>docker</span><span> </span><span>logs</span><span> </span><span>maim-bot-core</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p></p><figure><img alt="MaiBot WebUI 首次密钥在日志里" loading="lazy" width="1312" height="700" src="/_astro/image-005.CaYVVNQ1_1kMtf4.webp" /><figcaption>MaiBot WebUI 首次密钥在日志里</figcaption></figure><p></p><p>进去之后，在<strong>适配器</strong>里新建一个连接：</p><ul>
<li>类型选 <strong>NapCat（OneBot）</strong> 相关适配器。</li>
<li>地址填 <code>ws://宿主机IP或域名:3166</code>（就是刚才 NapCat 监听的 WebSocket 地址）。</li>
<li>如果 NapCat 那边设了访问 token，这里填同一个。</li>
</ul><p></p><figure><img alt="适配器配置" loading="lazy" width="2048" height="967" src="/_astro/image-006.4Nqk5e7N_1SDC2a.webp" /><figcaption>适配器配置</figcaption></figure><p></p></section>
<section><h2>第四步：加白名单<a href="#第四步加白名单"><span>#</span></a></h2><p><strong>这个项目默认是白名单模式</strong>，不把人和群加进去的话，机器人谁的消息都不回。在 WebUI 里把<strong>自己的 QQ 号</strong>和<strong>要用的群号</strong>加进白名单，保存。</p><p></p><figure><img alt="把 QQ 号和群号加入白名单" loading="lazy" width="1930" height="979" src="/_astro/image-007.DqTnHO1e_1lSIyi.webp" /><figcaption>把 QQ 号和群号加入白名单</figcaption></figure><p></p><p>不填白名单是最常见的”明明都部署好了就是不回消息”的原因，先查这里。</p></section>
<section><h2>第五步：配置模型<a href="#第五步配置模型"><span>#</span></a></h2><p>剩下的就是模型了，MaiMBot 支持各种 OpenAI 兼容接口。在 WebUI 的模型配置里填上你的 API 地址、Key 和模型名，保存之后到群里发条消息试试。key 攒多了不好管理的话，可以看看我写的 <a href="/posts/deploy/deploynewapi/">NewApi 部署教程</a>，自建一个中转站统一收口。</p></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><ol>
<li><strong>WebUI 打不开</strong>：<code>WEBUI_HOST</code> 没设成 <code>0.0.0.0</code>，或者端口没映射对。</li>
<li><strong>MaiBot 连不上 NapCat</strong>：WebSocket 监听地址不是 <code>0.0.0.0</code>，或者对应端口（如 <code>3166</code>）没映射。</li>
<li><strong>机器人不回复</strong>：先查白名单，绝大多数情况是白名单没加。</li>
<li><strong>WebUI 登录密码忘了</strong>：可以在系统配置页面修改密码，或者查看容器日志里的初始 Access Token。</li>
<li><strong>sqlite-web 报错</strong>：注意 <code>coleifer/sqlite-web</code> 镜像不支持 arm64，ARM 机器上别用这个镜像。</li>
</ol></section>
<section><h2>常用的管理操作<a href="#常用的管理操作"><span>#</span></a></h2><ul>
<li>修改 WebUI 登录密码：在系统配置页面的修改密码弹窗里操作，改完重启容器生效：</li>
</ul><p></p><figure><img alt="WebUI 系统配置-修改密码" loading="lazy" width="1981" height="1003" src="/_astro/image-008.DrXSQ8NW_Z2rQ40l.webp" /><figcaption>WebUI 系统配置-修改密码</figcaption></figure><p></p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>docker</span><span> </span><span>compose</span><span> </span><span>restart</span><span> </span><span>maim-bot-core</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><ol>
<li>写 compose 文件，创建各种数据目录。</li>
<li><code>docker compose up -d</code> 启动三个容器。</li>
<li>浏览器开 <code>6099</code>，日志里拿 token，扫码登录 QQ。</li>
<li>NapCat 里新建正向 WebSocket，监听 <code>0.0.0.0:3166</code>，端口映射出去。</li>
<li>浏览器开 <code>18001</code>，日志里拿 token。</li>
<li>MaiBot 适配器里填 <code>ws://宿主机IP:3166</code>，连上。</li>
<li>把 QQ 号和群号加白名单。</li>
<li>填模型 API，收工。</li>
</ol></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/recommend/bilibili-batch-hide/</id>
      <title type="text">B站批量隐藏投稿：一键把全部稿件设为仅自己可见</title>
      <published>2026-08-23T00:00:00.000Z</published>
      <updated>2026-08-26T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/recommend/bilibili-batch-hide/"/>
      <summary type="text">一个Tampermonkey脚本，在B站稿件管理页面一键把公开视频批量设置为仅自己可见，支持自动翻页、自定义起止页，127个视频实测通过。</summary>
      <content type="html"><![CDATA[<p>B站没有批量修改稿件可见性的功能。如果你有一百多个视频想全部设成”仅自己可见”，一个一个手动点编辑 → 更多设置 → 仅自己可见 → 提交，手都得废。</p>
<p>这个脚本就是干这件事的。脚本用 ZCode 配合商汤的 DeepSeek V4 Flash 模型辅助开发，最终版本稳定跑完 127 个视频。</p>
<section><h2>效果<a href="#效果"><span>#</span></a></h2><p>我在127个视频上实测过，最终结果：</p><ul>
<li>124个视频成功设为仅自己可见</li>
<li>3个之前就已经是仅自己可见，自动跳过</li>
<li>自动翻了13页，没有遗漏</li>
<li>不需要手动翻页，设好起止页数点开始就行</li>
</ul></section>
<section><h2>安装<a href="#安装"><span>#</span></a></h2><section><h3>1. 安装 Tampermonkey<a href="#1-安装-tampermonkey"><span>#</span></a></h3><p>如果你还没装 Tampermonkey，先装一个：</p><ul>
<li><strong>Chrome</strong>：<a href="https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo" target="_blank">Chrome 网上应用店</a></li>
<li><strong>Edge</strong>：<a href="https://microsoftedge.microsoft.com/addons/detail/tampermonkey/iikmkjmpaadaobahmlepeloendndfphd" target="_blank">Edge 扩展</a></li>
<li><strong>Firefox</strong>：<a href="https://addons.mozilla.org/en-US/firefox/addon/tampermonkey/" target="_blank">Firefox Add-ons</a></li>
</ul></section><section><h3>2. 安装脚本<a href="#2-安装脚本"><span>#</span></a></h3><p>把下面的代码复制一下：</p><ol>
<li>点击 Tampermonkey 图标 → <strong>创建新脚本</strong></li>
<li>删除默认的模板内容</li>
<li>粘贴下面的完整代码，保存（Ctrl+S）</li>
</ol><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// ==UserScript==</span></div></div><div><div><div>2</div></div><div><span>// @name         B站批量隐藏投稿</span></div></div><div><div><div>3</div></div><div><span>// @namespace    https://bilibili.com/</span></div></div><div><div><div>4</div></div><div><span>// @version      9.0.0</span></div></div><div><div><div>5</div></div><div><span>// @description  批量将B站稿件设置为"仅自己可见"，支持自定义起止页，自动翻页</span></div></div><div><div><div>6</div></div><div><span>// @match        https://member.bilibili.com/platform/upload-manager/article*</span></div></div><div><div><div>7</div></div><div><span>// @match        https://member.bilibili.com/platform/upload/video/frame*</span></div></div><div><div><div>8</div></div><div><span>// @grant        none</span></div></div><div><div><div>9</div></div><div><span>// ==/UserScript==</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>(</span><span>function</span><span> () {</span></div></div><div><div><div>12</div></div><div><span>    </span><span>'use strict'</span><span>;</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>    </span><span>const</span><span> </span><span>ST</span><span> </span><span>=</span><span> </span><span>'bili_batch_hide_v9'</span><span>;</span></div></div><div><div><div>15</div></div><div><span>    </span><span>const</span><span> </span><span>QU</span><span> </span><span>=</span><span> </span><span>'bili_batch_hide_queue_v9'</span><span>;</span></div></div><div><div><div>16</div></div><div><span>    </span><span>const</span><span> </span><span>LIST_URL</span><span> </span><span>=</span><span> </span><span>'https://member.bilibili.com/platform/upload-manager/article'</span><span>;</span></div></div><div><div><div>17</div></div><div><span>    </span><span>const</span><span> </span><span>eUrl</span><span> </span><span>=</span><span><span> </span><span>b</span><span> </span></span><span>=&gt;</span><span> </span><span>'https://member.bilibili.com/platform/upload/video/frame?type=edit&amp;bvid='</span><span> </span><span>+</span><span><span> </span><span>b</span><span>;</span></span></div></div><div><div><div>18</div></div><div><span>    </span><span>const</span><span> </span><span>slp</span><span> </span><span>=</span><span><span> </span><span>ms</span><span> </span></span><span>=&gt;</span><span> </span><span>new</span><span> </span><span>Promise</span><span><span>(</span><span>r</span><span> </span></span><span>=&gt;</span><span> </span><span>setTimeout</span><span><span>(</span><span>r</span><span>, </span><span>ms</span><span>));</span></span></div></div><div><div><div>19</div></div><div>
</div></div><div><div><div>20</div></div><div><span>    </span><span>function</span><span> </span><span>gs</span><span>() { </span><span>try</span><span> { </span><span>return</span><span> </span><span>JSON</span><span>.</span><span>parse</span><span><span>(</span><span>localStorage</span><span>.</span></span><span>getItem</span><span>(</span><span>ST</span><span>)); } </span><span>catch</span><span><span> (</span><span>e</span><span>) { </span></span><span>return</span><span> </span><span>null</span><span>; } }</span></div></div><div><div><div>21</div></div><div><span>    </span><span>function</span><span> </span><span>ss</span><span><span>(</span><span>s</span><span>) { </span><span>localStorage</span><span>.</span></span><span>setItem</span><span>(</span><span>ST</span><span>, </span><span>JSON</span><span>.</span><span>stringify</span><span><span>(</span><span>s</span><span>)); }</span></span></div></div><div><div><div>22</div></div><div><span>    </span><span>function</span><span> </span><span>cs</span><span><span>() { </span><span>localStorage</span><span>.</span></span><span>removeItem</span><span>(</span><span>ST</span><span><span>); </span><span>localStorage</span><span>.</span></span><span>removeItem</span><span>(</span><span>QU</span><span>); }</span></div></div><div><div><div>23</div></div><div><span>    </span><span>function</span><span> </span><span>gq</span><span>() { </span><span>try</span><span> { </span><span>return</span><span> </span><span>JSON</span><span>.</span><span>parse</span><span><span>(</span><span>localStorage</span><span>.</span></span><span>getItem</span><span>(</span><span>QU</span><span>)) </span><span>||</span><span> []; } </span><span>catch</span><span><span> (</span><span>e</span><span>) { </span></span><span>return</span><span> []; } }</span></div></div><div><div><div>24</div></div><div><span>    </span><span>function</span><span> </span><span>sq</span><span><span>(</span><span>q</span><span>) { </span><span>localStorage</span><span>.</span></span><span>setItem</span><span>(</span><span>QU</span><span>, </span><span>JSON</span><span>.</span><span>stringify</span><span><span>(</span><span>q</span><span>)); }</span></span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div><span>    </span><span>function</span><span> </span><span>curPg</span><span>() {</span></div></div><div><div><div>27</div></div><div><span>        </span><span>const</span><span> </span><span>m</span><span> </span><span>=</span><span><span> </span><span>location</span><span>.</span></span><span>href</span><span>.</span><span>match</span><span>(</span><span>/</span><span>[?&amp;]</span><span>page=(</span><span>\d</span><span><span>+</span><span>)/</span></span><span>);</span></div></div><div><div><div>28</div></div><div><span>        </span><span>return</span><span><span> </span><span>m</span><span> </span></span><span>?</span><span> </span><span>parseInt</span><span><span>(</span><span>m</span><span>[</span></span><span>1</span><span>]) </span><span>:</span><span> </span><span>1</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><span>function</span><span> </span><span>isLP</span><span>() { </span><span>return</span><span><span> </span><span>location</span><span>.</span></span><span>href</span><span>.</span><span>includes</span><span>(</span><span>'/upload-manager/article'</span><span>); }</span></div></div><div><div><div>31</div></div><div><span>    </span><span>function</span><span> </span><span>isEP</span><span>() { </span><span>return</span><span><span> </span><span>location</span><span>.</span></span><span>href</span><span>.</span><span>includes</span><span>(</span><span>'/upload/video/frame'</span><span>); }</span></div></div><div><div><div>32</div></div><div>
</div></div><div><div><div>33</div></div><div><span>    </span><span>function</span><span> </span><span>collect</span><span>() {</span></div></div><div><div><div>34</div></div><div><span>        </span><span>const</span><span> </span><span>links</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'a[href*="/video/BV"]'</span><span>);</span></div></div><div><div><div>35</div></div><div><span>        </span><span>const</span><span> </span><span>seen</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>Set</span><span>();</span></div></div><div><div><div>36</div></div><div><span>        </span><span>const</span><span> </span><span>q</span><span> </span><span>=</span><span> [];</span></div></div><div><div><div>37</div></div><div><span>        </span><span>let</span><span><span> </span><span>sk</span><span> </span></span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>38</div></div><div><span><span>        </span></span><span>links</span><span>.</span><span>forEach</span><span><span>(</span><span>a</span><span> </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>39</div></div><div><span>            </span><span>const</span><span> </span><span>m</span><span> </span><span>=</span><span><span> </span><span>a</span><span>.</span></span><span>href</span><span>.</span><span>match</span><span>(</span><span><span>/</span><span>\/</span><span>video</span><span>\/</span><span>(BV</span></span><span>[a-zA-Z0-9]</span><span><span>+</span><span>)/</span></span><span>);</span></div></div><div><div><div>40</div></div><div><span>            </span><span>if</span><span> (</span><span>!</span><span><span>m</span><span> </span></span><span>||</span><span><span> </span><span>seen</span><span>.</span></span><span>has</span><span><span>(</span><span>m</span><span>[</span></span><span>1</span><span>])) </span><span>return</span><span>;</span></div></div><div><div><div>41</div></div><div><span><span>            </span></span><span>seen</span><span>.</span><span>add</span><span><span>(</span><span>m</span><span>[</span></span><span>1</span><span>]);</span></div></div><div><div><div>42</div></div><div><span>            </span><span>const</span><span> </span><span>row</span><span> </span><span>=</span><span><span> </span><span>a</span><span>.</span></span><span>closest</span><span>(</span><span>'li, div, [class*="item"]'</span><span>);</span></div></div><div><div><div>43</div></div><div><span>            </span><span>if</span><span><span> (</span><span>row</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>row</span><span>.</span></span><span>textContent</span><span>.</span><span>includes</span><span>(</span><span>'仅自己可见'</span><span><span>)) </span><span>sk</span></span><span>++</span><span>;</span></div></div><div><div><div>44</div></div><div><span>            </span><span>else</span><span><span> </span><span>q</span><span>.</span></span><span>push</span><span><span>(</span><span>m</span><span>[</span></span><span>1</span><span>]);</span></div></div><div><div><div>45</div></div><div><span><span>        </span></span><span>});</span></div></div><div><div><div>46</div></div><div><span>        </span><span>return</span><span> { </span><span>queue</span><span>:</span><span> [</span><span>...</span><span>new</span><span> </span><span>Set</span><span><span>(</span><span>q</span><span>)], </span></span><span>skipped</span><span>:</span><span><span> </span><span>sk</span><span> };</span></span></div></div><div><div><div>47</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>48</div></div><div>
</div></div><div><div><div>49</div></div><div><span>    </span><span>function</span><span> </span><span>mkPanel</span><span>() {</span></div></div><div><div><div>50</div></div><div><span>        </span><span>if</span><span><span> (</span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-panel'</span><span>)) </span><span>return</span><span>;</span></div></div><div><div><div>51</div></div><div><span>        </span><span>const</span><span> </span><span>p</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>createElement</span><span>(</span><span>'div'</span><span>);</span></div></div><div><div><div>52</div></div><div><span><span>        </span></span><span>p</span><span>.</span><span>id</span><span> </span><span>=</span><span> </span><span>'bs-panel'</span><span>;</span></div></div><div><div><div>53</div></div><div><span><span>        </span></span><span>p</span><span>.</span><span>style</span><span>.</span><span>cssText</span><span> </span><span>=</span><span> </span><span>`</span></div></div><div><div><div>54</div></div><div><span><span>            </span></span><span>position: fixed; z-index: 999999; right: 20px; top: 100px;</span></div></div><div><div><div>55</div></div><div><span><span>            </span></span><span>width: 270px; padding: 15px; background: #18191c; color: #fff;</span></div></div><div><div><div>56</div></div><div><span><span>            </span></span><span>border-radius: 10px; box-shadow: 0 4px 20px rgba(0,0,0,.35);</span></div></div><div><div><div>57</div></div><div><span><span>            </span></span><span>font-size: 14px; font-family: Arial, sans-serif;</span></div></div><div><div><div>58</div></div><div><span><span>        </span></span><span>`</span><span>;</span></div></div><div><div><div>59</div></div><div><span><span>        </span></span><span>p</span><span>.</span><span>innerHTML</span><span> </span><span>=</span><span> </span><span>`</span></div></div><div><div><div>60</div></div><div><span><span>            </span></span><span>&lt;div style="font-size:16px;font-weight:bold;margin-bottom:10px;"&gt;B站批量隐藏&lt;/div&gt;</span></div></div><div><div><div>61</div></div><div><span><span>            </span></span><span>&lt;div id="bs-cur" style="margin-bottom:6px;color:#aaa;"&gt;当前页：第 </span><span>${</span><span>curPg</span><span>()</span><span>}</span><span> 页&lt;/div&gt;</span></div></div><div><div><div>62</div></div><div><span><span>            </span></span><span>&lt;div id="bs-st" style="margin-bottom:10px;"&gt;状态：等待开始&lt;/div&gt;</span></div></div><div><div><div>63</div></div><div><span><span>            </span></span><span>&lt;div id="bs-cnt" style="margin-bottom:12px;"&gt;已处理：0　跳过：0　失败：0&lt;/div&gt;</span></div></div><div><div><div>64</div></div><div><span><span>            </span></span><span>&lt;div style="margin-bottom:8px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;"&gt;</span></div></div><div><div><div>65</div></div><div><span><span>                </span></span><span>&lt;span style="font-size:12px;color:#aaa;"&gt;从第&lt;/span&gt;</span></div></div><div><div><div>66</div></div><div><span><span>                </span></span><span>&lt;input id="bs-sp" type="number" min="1" max="99" value="1"</span></div></div><div><div><div>67</div></div><div><span><span>                    </span></span><span>style="width:45px;padding:4px;border:1px solid #555;border-radius:4px;background:#2a2a2a;color:#fff;text-align:center;font-size:13px;"&gt;</span></div></div><div><div><div>68</div></div><div><span><span>                </span></span><span>&lt;span style="font-size:12px;color:#aaa;"&gt;页到第&lt;/span&gt;</span></div></div><div><div><div>69</div></div><div><span><span>                </span></span><span>&lt;input id="bs-ep" type="number" min="1" max="99" value="99"</span></div></div><div><div><div>70</div></div><div><span><span>                    </span></span><span>style="width:45px;padding:4px;border:1px solid #555;border-radius:4px;background:#2a2a2a;color:#fff;text-align:center;font-size:13px;"&gt;</span></div></div><div><div><div>71</div></div><div><span><span>                </span></span><span>&lt;span style="font-size:12px;color:#aaa;"&gt;页&lt;/span&gt;</span></div></div><div><div><div>72</div></div><div><span><span>            </span></span><span>&lt;/div&gt;</span></div></div><div><div><div>73</div></div><div><span><span>            </span></span><span>&lt;button id="bs-go" style="width:100%;padding:8px;border:0;border-radius:6px;</span></div></div><div><div><div>74</div></div><div><span><span>                </span></span><span>background:#00aeec;color:#fff;cursor:pointer;margin-bottom:6px;"&gt;开始批量隐藏&lt;/button&gt;</span></div></div><div><div><div>75</div></div><div><span><span>            </span></span><span>&lt;button id="bs-stp" style="width:100%;padding:8px;border:0;border-radius:6px;</span></div></div><div><div><div>76</div></div><div><span><span>                </span></span><span>background:#444;color:#fff;cursor:pointer;margin-bottom:6px;"&gt;停止&lt;/button&gt;</span></div></div><div><div><div>77</div></div><div><span><span>            </span></span><span>&lt;button id="bs-rst" style="width:100%;padding:6px;border:0;border-radius:6px;</span></div></div><div><div><div>78</div></div><div><span><span>                </span></span><span>background:#333;color:#aaa;cursor:pointer;font-size:12px;"&gt;重置状态&lt;/button&gt;</span></div></div><div><div><div>79</div></div><div><span><span>            </span></span><span>&lt;div style="margin-top:10px;color:#aaa;font-size:12px;line-height:1.5;"&gt;</span></div></div><div><div><div>80</div></div><div><span><span>                </span></span><span>自动翻页处理到结束页。&lt;br&gt;已"仅自己可见"的自动跳过。&lt;br&gt;面板在列表页和编辑页都会显示。</span></div></div><div><div><div>81</div></div><div><span><span>            </span></span><span>&lt;/div&gt;</span></div></div><div><div><div>82</div></div><div><span><span>        </span></span><span>`</span><span>;</span></div></div><div><div><div>83</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>body</span><span>.</span><span>appendChild</span><span><span>(</span><span>p</span><span>);</span></span></div></div><div><div><div>84</div></div><div>
</div></div><div><div><div>85</div></div><div><span>        </span><span>const</span><span> </span><span>sp</span><span> </span><span>=</span><span><span> </span><span>localStorage</span><span>.</span></span><span>getItem</span><span>(</span><span>'bs_sp'</span><span>);</span></div></div><div><div><div>86</div></div><div><span>        </span><span>const</span><span> </span><span>ep</span><span> </span><span>=</span><span><span> </span><span>localStorage</span><span>.</span></span><span>getItem</span><span>(</span><span>'bs_ep'</span><span>);</span></div></div><div><div><div>87</div></div><div><span>        </span><span>if</span><span><span> (</span><span>sp</span><span>) </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-sp'</span><span>).</span><span>value</span><span> </span><span>=</span><span><span> </span><span>sp</span><span>;</span></span></div></div><div><div><div>88</div></div><div><span>        </span><span>if</span><span><span> (</span><span>ep</span><span>) </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-ep'</span><span>).</span><span>value</span><span> </span><span>=</span><span><span> </span><span>ep</span><span>;</span></span></div></div><div><div><div>89</div></div><div>
</div></div><div><div><div>90</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>getElementById</span><span>(</span><span>'bs-sp'</span><span>).</span><span>onchange</span><span> </span><span>=</span><span> () </span><span>=&gt;</span><span><span> </span><span>localStorage</span><span>.</span></span><span>setItem</span><span>(</span><span>'bs_sp'</span><span><span>, </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-sp'</span><span>).</span><span>value</span><span>);</span></div></div><div><div><div>91</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>getElementById</span><span>(</span><span>'bs-ep'</span><span>).</span><span>onchange</span><span> </span><span>=</span><span> () </span><span>=&gt;</span><span><span> </span><span>localStorage</span><span>.</span></span><span>setItem</span><span>(</span><span>'bs_ep'</span><span><span>, </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-ep'</span><span>).</span><span>value</span><span>);</span></div></div><div><div><div>92</div></div><div>
</div></div><div><div><div>93</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>getElementById</span><span>(</span><span>'bs-go'</span><span>).</span><span>onclick</span><span> </span><span>=</span><span> </span><span>function</span><span> () {</span></div></div><div><div><div>94</div></div><div><span>            </span><span>if</span><span> (</span><span>gs</span><span>() </span><span>&amp;&amp;</span><span> </span><span>gs</span><span>().</span><span>running</span><span>) { </span><span>stUI</span><span>(</span><span>'运行中，请先停止'</span><span>); </span><span>return</span><span>; }</span></div></div><div><div><div>95</div></div><div><span>            </span><span>const</span><span> </span><span>sp</span><span> </span><span>=</span><span><span> </span><span>Math</span><span>.</span></span><span>max</span><span>(</span><span>1</span><span>, </span><span>parseInt</span><span><span>(</span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-sp'</span><span>).</span><span>value</span><span>) </span><span>||</span><span> </span><span>1</span><span>);</span></div></div><div><div><div>96</div></div><div><span>            </span><span>const</span><span> </span><span>ep</span><span> </span><span>=</span><span><span> </span><span>Math</span><span>.</span></span><span>max</span><span><span>(</span><span>sp</span><span>, </span></span><span>parseInt</span><span><span>(</span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-ep'</span><span>).</span><span>value</span><span>) </span><span>||</span><span> </span><span>99</span><span>);</span></div></div><div><div><div>97</div></div><div><span>            </span><span>cs</span><span>();</span></div></div><div><div><div>98</div></div><div><span>            </span><span>ss</span><span>({</span></div></div><div><div><div>99</div></div><div><span>                </span><span>running</span><span>:</span><span> </span><span>true</span><span>, </span><span>collected</span><span>:</span><span> </span><span>false</span><span>, </span><span>reachedStart</span><span>:</span><span> </span><span>false</span><span>,</span></div></div><div><div><div>100</div></div><div><span>                </span><span>processed</span><span>:</span><span> </span><span>0</span><span>, </span><span>skipped</span><span>:</span><span> </span><span>0</span><span>, </span><span>failed</span><span>:</span><span> </span><span>0</span><span>, </span><span>queue</span><span>:</span><span> [],</span></div></div><div><div><div>101</div></div><div><span>                </span><span>startPage</span><span>:</span><span><span> </span><span>sp</span><span>, </span></span><span>endPage</span><span>:</span><span><span> </span><span>ep</span><span>, </span></span><span>currentPage</span><span>:</span><span> </span><span>0</span><span>, </span><span>done</span><span>:</span><span> </span><span>false</span></div></div><div><div><div>102</div></div><div><span><span>            </span></span><span>});</span></div></div><div><div><div>103</div></div><div><span>            </span><span>sq</span><span>([]);</span></div></div><div><div><div>104</div></div><div><span><span>            </span></span><span>localStorage</span><span>.</span><span>setItem</span><span>(</span><span>'bs_sp'</span><span>, </span><span>String</span><span><span>(</span><span>sp</span><span>));</span></span></div></div><div><div><div>105</div></div><div><span><span>            </span></span><span>localStorage</span><span>.</span><span>setItem</span><span>(</span><span>'bs_ep'</span><span>, </span><span>String</span><span><span>(</span><span>ep</span><span>));</span></span></div></div><div><div><div>106</div></div><div><span>            </span><span>stUI</span><span>(</span><span>'开始（第 '</span><span> </span><span>+</span><span><span> </span><span>sp</span><span> </span></span><span>+</span><span> </span><span>'~'</span><span> </span><span>+</span><span><span> </span><span>ep</span><span> </span></span><span>+</span><span> </span><span>' 页）'</span><span>);</span></div></div><div><div><div>107</div></div><div><span><span>            </span></span><span>location</span><span>.</span><span>href</span><span> </span><span>=</span><span> </span><span>LIST_URL</span><span> </span><span>+</span><span><span> (</span><span>sp</span><span> </span></span><span>&gt;</span><span> </span><span>1</span><span> </span><span>?</span><span> </span><span>'?page='</span><span> </span><span>+</span><span><span> </span><span>sp</span><span> </span></span><span>:</span><span> </span><span>''</span><span>);</span></div></div><div><div><div>108</div></div><div><span><span>        </span></span><span>};</span></div></div><div><div><div>109</div></div><div>
</div></div><div><div><div>110</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>getElementById</span><span>(</span><span>'bs-stp'</span><span>).</span><span>onclick</span><span> </span><span>=</span><span> () </span><span>=&gt;</span><span> { </span><span>cs</span><span>(); </span><span>stUI</span><span>(</span><span>'已停止'</span><span>); </span><span>updP</span><span>(); };</span></div></div><div><div><div>111</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>getElementById</span><span>(</span><span>'bs-rst'</span><span>).</span><span>onclick</span><span> </span><span>=</span><span> () </span><span>=&gt;</span><span> { </span><span>cs</span><span>(); </span><span>stUI</span><span>(</span><span>'已重置'</span><span>); </span><span>updP</span><span>(); };</span></div></div><div><div><div>112</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>113</div></div><div>
</div></div><div><div><div>114</div></div><div><span>    </span><span>function</span><span> </span><span>updP</span><span>() {</span></div></div><div><div><div>115</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>116</div></div><div><span>        </span><span>const</span><span> </span><span>el</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-cnt'</span><span>);</span></div></div><div><div><div>117</div></div><div><span>        </span><span>const</span><span> </span><span>pel</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-cur'</span><span>);</span></div></div><div><div><div>118</div></div><div><span>        </span><span>if</span><span><span> (</span><span>el</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>s</span><span>) </span><span>el</span><span>.</span></span><span>textContent</span><span> </span><span>=</span><span> </span><span>'已处理：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>processed</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>'　跳过：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>skipped</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>'　失败：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>failed</span><span> </span><span>||</span><span> </span><span>0</span><span>);</span></div></div><div><div><div>119</div></div><div><span>        </span><span>else</span><span> </span><span>if</span><span><span> (</span><span>el</span><span>) </span><span>el</span><span>.</span></span><span>textContent</span><span> </span><span>=</span><span> </span><span>'已处理：0　跳过：0　失败：0'</span><span>;</span></div></div><div><div><div>120</div></div><div><span>        </span><span>if</span><span><span> (</span><span>pel</span><span>) </span><span>pel</span><span>.</span></span><span>textContent</span><span> </span><span>=</span><span> </span><span>'当前页：第 '</span><span> </span><span>+</span><span> </span><span>curPg</span><span>() </span><span>+</span><span> </span><span>' 页（目标 '</span><span> </span><span>+</span><span><span> (</span><span>s</span><span> </span></span><span>?</span><span><span> </span><span>s</span><span>.</span></span><span>startPage</span><span> </span><span>+</span><span> </span><span>'~'</span><span> </span><span>+</span><span><span> </span><span>s</span><span>.</span></span><span>endPage</span><span> </span><span>:</span><span> </span><span>'-'</span><span>) </span><span>+</span><span> </span><span>'）'</span><span>;</span></div></div><div><div><div>121</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>122</div></div><div><span>    </span><span>function</span><span> </span><span>stUI</span><span><span>(</span><span>t</span><span>) { </span></span><span>const</span><span> </span><span>el</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-st'</span><span>); </span><span>if</span><span><span> (</span><span>el</span><span>) </span><span>el</span><span>.</span></span><span>textContent</span><span> </span><span>=</span><span> </span><span>'状态：'</span><span> </span><span>+</span><span><span> </span><span>t</span><span>; }</span></span></div></div><div><div><div>123</div></div><div>
</div></div><div><div><div>124</div></div><div><span>    </span><span>function</span><span> </span><span>advance</span><span>() {</span></div></div><div><div><div>125</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>126</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>s</span><span>.</span></span><span>running</span><span> </span><span>||</span><span> </span><span>!</span><span>isLP</span><span>()) </span><span>return</span><span>;</span></div></div><div><div><div>127</div></div><div><span>        </span><span>if</span><span><span> (</span><span>s</span><span>.</span></span><span>done</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>128</div></div><div>
</div></div><div><div><div>129</div></div><div><span><span>        </span></span><span>s</span><span>.</span><span>currentPage</span><span> </span><span>=</span><span> </span><span>curPg</span><span>();</span></div></div><div><div><div>130</div></div><div><span>        </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>131</div></div><div><span>        </span><span>updP</span><span>();</span></div></div><div><div><div>132</div></div><div>
</div></div><div><div><div>133</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span>.</span></span><span>reachedStart</span><span>) {</span></div></div><div><div><div>134</div></div><div><span>            </span><span>if</span><span> (</span><span>curPg</span><span>() </span><span>&lt;</span><span><span> </span><span>s</span><span>.</span></span><span>startPage</span><span>) {</span></div></div><div><div><div>135</div></div><div><span>                </span><span>stUI</span><span>(</span><span>'跳转到第 '</span><span> </span><span>+</span><span><span> </span><span>s</span><span>.</span></span><span>startPage</span><span> </span><span>+</span><span> </span><span>' 页...'</span><span>);</span></div></div><div><div><div>136</div></div><div><span><span>                </span></span><span>location</span><span>.</span><span>href</span><span> </span><span>=</span><span> </span><span>LIST_URL</span><span> </span><span>+</span><span> </span><span>'?page='</span><span> </span><span>+</span><span><span> </span><span>s</span><span>.</span></span><span>startPage</span><span>;</span></div></div><div><div><div>137</div></div><div><span>                </span><span>return</span><span>;</span></div></div><div><div><div>138</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>139</div></div><div><span><span>            </span></span><span>s</span><span>.</span><span>reachedStart</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>140</div></div><div><span>            </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>141</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>142</div></div><div>
</div></div><div><div><div>143</div></div><div><span>        </span><span>if</span><span> (</span><span>curPg</span><span>() </span><span>&gt;</span><span><span> </span><span>s</span><span>.</span></span><span>endPage</span><span>) { </span><span>finish</span><span>(); </span><span>return</span><span>; }</span></div></div><div><div><div>144</div></div><div>
</div></div><div><div><div>145</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span>.</span></span><span>collected</span><span>) {</span></div></div><div><div><div>146</div></div><div><span>            </span><span>const</span><span> { </span><span>queue</span><span>, </span><span>skipped</span><span> } </span><span>=</span><span> </span><span>collect</span><span>();</span></div></div><div><div><div>147</div></div><div><span>            </span><span>if</span><span><span> (</span><span>queue</span><span>.</span></span><span>length</span><span> </span><span>===</span><span> </span><span>0</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'a[href*="/video/BV"]'</span><span>).</span><span>length</span><span> </span><span>===</span><span> </span><span>0</span><span>) {</span></div></div><div><div><div>148</div></div><div><span>                </span><span>stUI</span><span>(</span><span>'等待页面加载...'</span><span>);</span></div></div><div><div><div>149</div></div><div><span>                </span><span>setTimeout</span><span>(() </span><span>=&gt;</span><span> { </span><span>if</span><span> (</span><span>gs</span><span>() </span><span>&amp;&amp;</span><span> </span><span>gs</span><span>().</span><span>running</span><span> </span><span>&amp;&amp;</span><span> </span><span>!</span><span>gs</span><span>().</span><span>done</span><span>) </span><span>advance</span><span>(); }, </span><span>2000</span><span>);</span></div></div><div><div><div>150</div></div><div><span>                </span><span>return</span><span>;</span></div></div><div><div><div>151</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>152</div></div><div><span><span>            </span></span><span>s</span><span>.</span><span>skipped</span><span> </span><span>=</span><span><span> (</span><span>s</span><span>.</span></span><span>skipped</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span><span> </span><span>skipped</span><span>;</span></span></div></div><div><div><div>153</div></div><div><span><span>            </span></span><span>s</span><span>.</span><span>queue</span><span> </span><span>=</span><span><span> </span><span>queue</span><span>;</span></span></div></div><div><div><div>154</div></div><div><span><span>            </span></span><span>s</span><span>.</span><span>collected</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>155</div></div><div><span>            </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>156</div></div><div><span>            </span><span>sq</span><span><span>(</span><span>queue</span><span>);</span></span></div></div><div><div><div>157</div></div><div>
</div></div><div><div><div>158</div></div><div><span>            </span><span>if</span><span><span> (</span><span>queue</span><span>.</span></span><span>length</span><span> </span><span>===</span><span> </span><span>0</span><span>) {</span></div></div><div><div><div>159</div></div><div><span>                </span><span>stUI</span><span>(</span><span>'本页无待处理，进入下一页'</span><span>);</span></div></div><div><div><div>160</div></div><div><span><span>                </span></span><span>s</span><span>.</span><span>collected</span><span> </span><span>=</span><span> </span><span>false</span><span>; </span><span>ss</span><span><span>(</span><span>s</span><span>); </span></span><span>nextPage</span><span>();</span></div></div><div><div><div>161</div></div><div><span>                </span><span>return</span><span>;</span></div></div><div><div><div>162</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>163</div></div><div><span>            </span><span>goEdit</span><span>();</span></div></div><div><div><div>164</div></div><div><span>            </span><span>return</span><span>;</span></div></div><div><div><div>165</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>166</div></div><div>
</div></div><div><div><div>167</div></div><div><span>        </span><span>if</span><span><span> (</span><span>s</span><span>.</span></span><span>queue</span><span>.</span><span>length</span><span> </span><span>&gt;</span><span> </span><span>0</span><span>) { </span><span>goEdit</span><span>(); </span><span>return</span><span>; }</span></div></div><div><div><div>168</div></div><div><span><span>        </span></span><span>s</span><span>.</span><span>collected</span><span> </span><span>=</span><span> </span><span>false</span><span>; </span><span>ss</span><span><span>(</span><span>s</span><span>); </span></span><span>nextPage</span><span>();</span></div></div><div><div><div>169</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>170</div></div><div>
</div></div><div><div><div>171</div></div><div><span>    </span><span>function</span><span> </span><span>goEdit</span><span>() {</span></div></div><div><div><div>172</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>173</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>s</span><span>.</span></span><span>running</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>174</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span>.</span></span><span>queue</span><span> </span><span>||</span><span><span> </span><span>s</span><span>.</span></span><span>queue</span><span>.</span><span>length</span><span> </span><span>===</span><span> </span><span>0</span><span>) { </span><span>advance</span><span>(); </span><span>return</span><span>; }</span></div></div><div><div><div>175</div></div><div><span>        </span><span>const</span><span> </span><span>next</span><span> </span><span>=</span><span><span> </span><span>s</span><span>.</span></span><span>queue</span><span>.</span><span>shift</span><span>();</span></div></div><div><div><div>176</div></div><div><span>        </span><span>sq</span><span><span>(</span><span>s</span><span>.</span></span><span>queue</span><span>);</span></div></div><div><div><div>177</div></div><div><span>        </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>178</div></div><div><span>        </span><span>stUI</span><span>(</span><span>'处理 '</span><span> </span><span>+</span><span><span> </span><span>next</span><span> </span></span><span>+</span><span> </span><span>' | 第 '</span><span> </span><span>+</span><span><span> </span><span>s</span><span>.</span></span><span>currentPage</span><span> </span><span>+</span><span> </span><span>'/'</span><span> </span><span>+</span><span><span> </span><span>s</span><span>.</span></span><span>endPage</span><span> </span><span>+</span><span> </span><span>' 页'</span><span>);</span></div></div><div><div><div>179</div></div><div><span><span>        </span></span><span>location</span><span>.</span><span>href</span><span> </span><span>=</span><span> </span><span>eUrl</span><span><span>(</span><span>next</span><span>);</span></span></div></div><div><div><div>180</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>181</div></div><div>
</div></div><div><div><div>182</div></div><div><span>    </span><span>function</span><span> </span><span>nextPage</span><span>() {</span></div></div><div><div><div>183</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>184</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>s</span><span>.</span></span><span>running</span><span> </span><span>||</span><span><span> </span><span>s</span><span>.</span></span><span>done</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>185</div></div><div><span>        </span><span>if</span><span> (</span><span>curPg</span><span>() </span><span>&gt;=</span><span><span> </span><span>s</span><span>.</span></span><span>endPage</span><span>) { </span><span>finish</span><span>(); </span><span>return</span><span>; }</span></div></div><div><div><div>186</div></div><div><span>        </span><span>stUI</span><span>(</span><span>'进入第 '</span><span> </span><span>+</span><span> (</span><span>curPg</span><span>() </span><span>+</span><span> </span><span>1</span><span>) </span><span>+</span><span> </span><span>' 页...'</span><span>);</span></div></div><div><div><div>187</div></div><div><span><span>        </span></span><span>s</span><span>.</span><span>collected</span><span> </span><span>=</span><span> </span><span>false</span><span>; </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>188</div></div><div><span><span>        </span></span><span>location</span><span>.</span><span>href</span><span> </span><span>=</span><span> </span><span>LIST_URL</span><span> </span><span>+</span><span> </span><span>'?page='</span><span> </span><span>+</span><span> (</span><span>curPg</span><span>() </span><span>+</span><span> </span><span>1</span><span>);</span></div></div><div><div><div>189</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>190</div></div><div>
</div></div><div><div><div>191</div></div><div><span>    </span><span>function</span><span> </span><span>finish</span><span>() {</span></div></div><div><div><div>192</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>(); </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span>) </span></span><span>return</span><span>;</span></div></div><div><div><div>193</div></div><div><span><span>        </span></span><span>s</span><span>.</span><span>done</span><span> </span><span>=</span><span> </span><span>true</span><span>; </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>194</div></div><div><span>        </span><span>const</span><span> </span><span>msg</span><span> </span><span>=</span><span> </span><span>'🎉 全部完成！</span><span>\n</span><span>处理：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>processed</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>'，跳过：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>skipped</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>'，失败：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>failed</span><span> </span><span>||</span><span> </span><span>0</span><span>);</span></div></div><div><div><div>195</div></div><div><span>        </span><span>alert</span><span><span>(</span><span>msg</span><span>); </span></span><span>cs</span><span>(); </span><span>stUI</span><span>(</span><span>'全部完成'</span><span>);</span></div></div><div><div><div>196</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>197</div></div><div>
</div></div><div><div><div>198</div></div><div><span>    </span><span>async</span><span> </span><span>function</span><span> </span><span>handleEP</span><span>() {</span></div></div><div><div><div>199</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>200</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>s</span><span>.</span></span><span>running</span><span> </span><span>||</span><span><span> </span><span>s</span><span>.</span></span><span>done</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>201</div></div><div><span>        </span><span>updP</span><span>(); </span><span>stUI</span><span>(</span><span>'正在编辑...'</span><span>);</span></div></div><div><div><div>202</div></div><div><span>        </span><span>await</span><span> </span><span>slp</span><span>(</span><span>2000</span><span>);</span></div></div><div><div><div>203</div></div><div>
</div></div><div><div><div>204</div></div><div><span>        </span><span>let</span><span><span> </span><span>ok</span><span> </span></span><span>=</span><span> </span><span>false</span><span><span>, </span><span>clicked</span><span> </span></span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>205</div></div><div><span>        </span><span>const</span><span> </span><span>labels</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'span.label'</span><span>);</span></div></div><div><div><div>206</div></div><div><span>        </span><span>for</span><span> (</span><span>const</span><span> </span><span>l</span><span> </span><span>of</span><span><span> </span><span>labels</span><span>) { </span></span><span>if</span><span><span> (</span><span>l</span><span>.</span></span><span>textContent</span><span>.</span><span>trim</span><span>().</span><span>startsWith</span><span>(</span><span>'更多设置'</span><span><span>)) { </span><span>l</span><span>.</span></span><span>click</span><span><span>(); </span><span>clicked</span><span> </span></span><span>=</span><span> </span><span>true</span><span>; </span><span>break</span><span>; } }</span></div></div><div><div><div>207</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>clicked</span><span>) {</span></span></div></div><div><div><div>208</div></div><div><span>            </span><span>const</span><span> </span><span>titles</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'div.title'</span><span>);</span></div></div><div><div><div>209</div></div><div><span>            </span><span>for</span><span> (</span><span>const</span><span> </span><span>t</span><span> </span><span>of</span><span><span> </span><span>titles</span><span>) { </span></span><span>if</span><span><span> (</span><span>t</span><span>.</span></span><span>textContent</span><span>.</span><span>trim</span><span>().</span><span>startsWith</span><span>(</span><span>'更多设置'</span><span><span>)) { </span><span>t</span><span>.</span></span><span>click</span><span><span>(); </span><span>clicked</span><span> </span></span><span>=</span><span> </span><span>true</span><span>; </span><span>break</span><span>; } }</span></div></div><div><div><div>210</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>211</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>clicked</span><span>) {</span></span></div></div><div><div><div>212</div></div><div><span>            </span><span>const</span><span> </span><span>all</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'span, div, a'</span><span>);</span></div></div><div><div><div>213</div></div><div><span>            </span><span>for</span><span> (</span><span>const</span><span> </span><span>e</span><span> </span><span>of</span><span><span> </span><span>all</span><span>) { </span></span><span>if</span><span><span> (</span><span>e</span><span>.</span></span><span>textContent</span><span>.</span><span>trim</span><span>().</span><span>startsWith</span><span>(</span><span>'更多设置'</span><span><span>)) { </span><span>e</span><span>.</span></span><span>click</span><span><span>(); </span><span>clicked</span><span> </span></span><span>=</span><span> </span><span>true</span><span>; </span><span>break</span><span>; } }</span></div></div><div><div><div>214</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>215</div></div><div>
</div></div><div><div><div>216</div></div><div><span>        </span><span>if</span><span><span> (</span><span>clicked</span><span>) {</span></span></div></div><div><div><div>217</div></div><div><span>            </span><span>await</span><span> </span><span>slp</span><span>(</span><span>1200</span><span>);</span></div></div><div><div><div>218</div></div><div><span>            </span><span>const</span><span> </span><span>containers</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'.check-radio-v2-container'</span><span>);</span></div></div><div><div><div>219</div></div><div><span>            </span><span>if</span><span><span> (</span><span>containers</span><span>.</span></span><span>length</span><span> </span><span>&gt;=</span><span> </span><span>2</span><span>) {</span></div></div><div><div><div>220</div></div><div><span>                </span><span>const</span><span> </span><span>isChecked</span><span> </span><span>=</span><span><span> </span><span>containers</span><span>[</span></span><span>1</span><span>].</span><span>querySelector</span><span>(</span><span>'.check-radio-v2-box-checked'</span><span>) </span><span>!==</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>221</div></div><div><span>                </span><span>if</span><span> (</span><span>!</span><span><span>isChecked</span><span>) {</span></span></div></div><div><div><div>222</div></div><div><span>                    </span><span>const</span><span> </span><span>radio</span><span> </span><span>=</span><span><span> </span><span>containers</span><span>[</span></span><span>1</span><span>].</span><span>querySelector</span><span>(</span><span>'.check-radio-v2-box, span'</span><span>);</span></div></div><div><div><div>223</div></div><div><span>                    </span><span>if</span><span><span> (</span><span>radio</span><span>) </span><span>radio</span><span>.</span></span><span>click</span><span>(); </span><span>else</span><span><span> </span><span>containers</span><span>[</span></span><span>1</span><span>].</span><span>click</span><span>();</span></div></div><div><div><div>224</div></div><div><span>                    </span><span>await</span><span> </span><span>slp</span><span>(</span><span>800</span><span>);</span></div></div><div><div><div>225</div></div><div><span><span>                </span></span><span>}</span></div></div><div><div><div>226</div></div><div><span>                </span><span>const</span><span> </span><span>sub</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelector</span><span>(</span><span>'span.submit-add'</span><span>);</span></div></div><div><div><div>227</div></div><div><span>                </span><span>if</span><span><span> (</span><span>sub</span><span>) { </span><span>sub</span><span>.</span></span><span>click</span><span><span>(); </span><span>ok</span><span> </span></span><span>=</span><span> </span><span>true</span><span>; }</span></div></div><div><div><div>228</div></div><div><span>                </span><span>else</span><span> {</span></div></div><div><div><div>229</div></div><div><span>                    </span><span>const</span><span> </span><span>all</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'div, button, span'</span><span>);</span></div></div><div><div><div>230</div></div><div><span>                    </span><span>for</span><span> (</span><span>const</span><span> </span><span>e</span><span> </span><span>of</span><span><span> </span><span>all</span><span>) { </span></span><span>if</span><span><span> (</span><span>e</span><span>.</span></span><span>textContent</span><span>.</span><span>trim</span><span>() </span><span>===</span><span> </span><span>'立即投稿'</span><span><span>) { </span><span>e</span><span>.</span></span><span>click</span><span><span>(); </span><span>ok</span><span> </span></span><span>=</span><span> </span><span>true</span><span>; </span><span>break</span><span>; } }</span></div></div><div><div><div>231</div></div><div><span><span>                </span></span><span>}</span></div></div><div><div><div>232</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>233</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>234</div></div><div>
</div></div><div><div><div>235</div></div><div><span>        </span><span>const</span><span> </span><span>st</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>236</div></div><div><span>        </span><span>if</span><span><span> (</span><span>st</span><span>) { </span></span><span>if</span><span><span> (</span><span>ok</span><span>) </span><span>st</span><span>.</span></span><span>processed</span><span> </span><span>=</span><span><span> (</span><span>st</span><span>.</span></span><span>processed</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>1</span><span>; </span><span>else</span><span><span> </span><span>st</span><span>.</span></span><span>failed</span><span> </span><span>=</span><span><span> (</span><span>st</span><span>.</span></span><span>failed</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>1</span><span>; </span><span>ss</span><span><span>(</span><span>st</span><span>); }</span></span></div></div><div><div><div>237</div></div><div><span>        </span><span>updP</span><span>();</span></div></div><div><div><div>238</div></div><div><span>        </span><span>stUI</span><span><span>(</span><span>ok</span><span> </span></span><span>?</span><span> </span><span>'已提交，返回列表...'</span><span> </span><span>:</span><span> </span><span>'失败，返回列表...'</span><span>);</span></div></div><div><div><div>239</div></div><div><span>        </span><span>await</span><span> </span><span>slp</span><span>(</span><span>2500</span><span>);</span></div></div><div><div><div>240</div></div><div><span>        </span><span>const</span><span> </span><span>st2</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>241</div></div><div><span>        </span><span>const</span><span> </span><span>pg</span><span> </span><span>=</span><span><span> </span><span>st2</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>st2</span><span>.</span></span><span>currentPage</span><span> </span><span>?</span><span><span> </span><span>st2</span><span>.</span></span><span>currentPage</span><span> </span><span>:</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>242</div></div><div><span><span>        </span></span><span>location</span><span>.</span><span>href</span><span> </span><span>=</span><span> </span><span>LIST_URL</span><span> </span><span>+</span><span><span> (</span><span>pg</span><span> </span></span><span>&gt;</span><span> </span><span>1</span><span> </span><span>?</span><span> </span><span>'?page='</span><span> </span><span>+</span><span><span> </span><span>pg</span><span> </span></span><span>:</span><span> </span><span>''</span><span>);</span></div></div><div><div><div>243</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>244</div></div><div>
</div></div><div><div><div>245</div></div><div><span>    </span><span>setTimeout</span><span>(</span><span>function</span><span> () { </span><span>mkPanel</span><span>(); </span><span>updP</span><span>(); }, </span><span>500</span><span>);</span></div></div><div><div><div>246</div></div><div>
</div></div><div><div><div>247</div></div><div><span>    </span><span>if</span><span> (</span><span>isLP</span><span>()) {</span></div></div><div><div><div>248</div></div><div><span>        </span><span>setTimeout</span><span>(</span><span>function</span><span> () {</span></div></div><div><div><div>249</div></div><div><span>            </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>250</div></div><div><span>            </span><span>if</span><span><span> (</span><span>s</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>s</span><span>.</span></span><span>running</span><span> </span><span>&amp;&amp;</span><span> </span><span>!</span><span><span>s</span><span>.</span></span><span>done</span><span>) { </span><span>stUI</span><span>(</span><span>'自动继续...'</span><span>); </span><span>updP</span><span>(); </span><span>advance</span><span>(); </span><span>updP</span><span>(); }</span></div></div><div><div><div>251</div></div><div><span><span>        </span></span><span>}, </span><span>2000</span><span>);</span></div></div><div><div><div>252</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>253</div></div><div>
</div></div><div><div><div>254</div></div><div><span>    </span><span>if</span><span> (</span><span>isEP</span><span>()) { </span><span>handleEP</span><span>(); }</span></div></div><div><div><div>255</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>
<section><h2>使用方法<a href="#使用方法"><span>#</span></a></h2><section><h3>1. 打开稿件管理页面<a href="#1-打开稿件管理页面"><span>#</span></a></h3><p>在浏览器中打开 <a href="https://member.bilibili.com/platform/upload-manager/article" target="_blank">B站创作中心 → 稿件管理</a></p></section><section><h3>2. 设置起止页数<a href="#2-设置起止页数"><span>#</span></a></h3><p>页面右上角会出现一个黑色的控制面板：</p><ul>
<li><strong>从第_页到第_页</strong>：默认处理全部页面（1~99页）。如果你只想处理第3页到第5页，改一下这两个数字就行</li>
<li>设置会自动保存，下次打开还是上次填的数值</li>
</ul></section><section><h3>3. 点击”开始批量隐藏”<a href="#3-点击开始批量隐藏"><span>#</span></a></h3><p>点完按钮后，脚本会自动：</p><ol>
<li>跳转到起始页</li>
<li>收集当前页所有公开视频</li>
<li>逐个进入编辑页，设置为仅自己可见，提交</li>
<li>本页处理完 → 自动翻到下一页</li>
<li>到结束页后停止，弹窗显示统计结果</li>
</ol></section><section><h3>4. 观察进度<a href="#4-观察进度"><span>#</span></a></h3><p>面板会一直显示在页面右上角，无论是在列表页还是编辑页都能看到：</p><ul>
<li>当前在第几页</li>
<li>已处理 / 跳过 / 失败的数量</li>
<li>当前正在处理哪个视频</li>
</ul></section><section><h3>5. 中途想停<a href="#5-中途想停"><span>#</span></a></h3><p>点击 <strong>“停止”</strong> 按钮即可。之后想继续，回到列表页设置好起止页数，再点”开始”就行。</p></section></section>
<section><h2>注意事项<a href="#注意事项"><span>#</span></a></h2><ul>
<li><strong>建议先小范围测试</strong>。比如先设置从第1页到第1页，跑一下确认没问题，再处理全部。</li>
<li>已经”仅自己可见”的稿件会自动跳过，不会重复处理。</li>
<li>处理过程中页面会在列表页和编辑页之间来回跳转，这是正常现象。</li>
<li>如果翻页时卡住了，点 <strong>“重置状态”</strong> 再重新开始。</li>
<li>不兼容B站专栏稿件，只处理视频稿件。</li>
</ul></section>
<section><h2>脚本原理<a href="#脚本原理"><span>#</span></a></h2><p>说起来其实很简单：脚本在列表页收集所有视频的BV ID，逐个跳转到编辑页，自动展开”更多设置”→ 选中”仅自己可见”→ 提交，然后回到列表页取下一条。翻页是通过直接跳转URL实现的，比依赖SPA的”下一页”按钮要稳定得多。</p><p>全程大概就是这个流程：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>列表页收集BV ID → 跳转编辑页 → 展开更多设置 → 选仅自己可见 → 提交 → 回列表页 → 下一个</span></div></div><div><div><div>2</div></div><div><span><span>                                                                    </span></span><span>↓</span></div></div><div><div><div>3</div></div><div><span><span>                                                            </span></span><span>本页完成 → 翻页到下一页</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>如果你有批量处理B站视频的需求，这个脚本应该能帮你省不少时间。用之前记得先备份，毕竟批量操作出了错不太好恢复。</p><p>最后再分享一个设置为公开可见的脚本，直接叫我的zcode+dsv4f在上面的基础直接改的</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// ==UserScript==</span></div></div><div><div><div>2</div></div><div><span>// @name         B站批量公开投稿</span></div></div><div><div><div>3</div></div><div><span>// @namespace    https://bilibili.com/</span></div></div><div><div><div>4</div></div><div><span>// @version      1.0.0</span></div></div><div><div><div>5</div></div><div><span>// @description  批量将B站稿件设置为"公开可见"，支持自定义起止页，自动翻页</span></div></div><div><div><div>6</div></div><div><span>// @match        https://member.bilibili.com/platform/upload-manager/article*</span></div></div><div><div><div>7</div></div><div><span>// @match        https://member.bilibili.com/platform/upload/video/frame*</span></div></div><div><div><div>8</div></div><div><span>// @grant        none</span></div></div><div><div><div>9</div></div><div><span>// ==/UserScript==</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>(</span><span>function</span><span> () {</span></div></div><div><div><div>12</div></div><div><span>    </span><span>'use strict'</span><span>;</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>    </span><span>const</span><span> </span><span>ST</span><span> </span><span>=</span><span> </span><span>'bili_batch_public_v1'</span><span>;</span></div></div><div><div><div>15</div></div><div><span>    </span><span>const</span><span> </span><span>QU</span><span> </span><span>=</span><span> </span><span>'bili_batch_public_queue_v1'</span><span>;</span></div></div><div><div><div>16</div></div><div><span>    </span><span>const</span><span> </span><span>LIST_URL</span><span> </span><span>=</span><span> </span><span>'https://member.bilibili.com/platform/upload-manager/article'</span><span>;</span></div></div><div><div><div>17</div></div><div><span>    </span><span>const</span><span> </span><span>eUrl</span><span> </span><span>=</span><span><span> </span><span>b</span><span> </span></span><span>=&gt;</span><span> </span><span>'https://member.bilibili.com/platform/upload/video/frame?type=edit&amp;bvid='</span><span> </span><span>+</span><span><span> </span><span>b</span><span>;</span></span></div></div><div><div><div>18</div></div><div><span>    </span><span>const</span><span> </span><span>slp</span><span> </span><span>=</span><span><span> </span><span>ms</span><span> </span></span><span>=&gt;</span><span> </span><span>new</span><span> </span><span>Promise</span><span><span>(</span><span>r</span><span> </span></span><span>=&gt;</span><span> </span><span>setTimeout</span><span><span>(</span><span>r</span><span>, </span><span>ms</span><span>));</span></span></div></div><div><div><div>19</div></div><div>
</div></div><div><div><div>20</div></div><div><span>    </span><span>function</span><span> </span><span>gs</span><span>() { </span><span>try</span><span> { </span><span>return</span><span> </span><span>JSON</span><span>.</span><span>parse</span><span><span>(</span><span>localStorage</span><span>.</span></span><span>getItem</span><span>(</span><span>ST</span><span>)); } </span><span>catch</span><span><span> (</span><span>e</span><span>) { </span></span><span>return</span><span> </span><span>null</span><span>; } }</span></div></div><div><div><div>21</div></div><div><span>    </span><span>function</span><span> </span><span>ss</span><span><span>(</span><span>s</span><span>) { </span><span>localStorage</span><span>.</span></span><span>setItem</span><span>(</span><span>ST</span><span>, </span><span>JSON</span><span>.</span><span>stringify</span><span><span>(</span><span>s</span><span>)); }</span></span></div></div><div><div><div>22</div></div><div><span>    </span><span>function</span><span> </span><span>cs</span><span><span>() { </span><span>localStorage</span><span>.</span></span><span>removeItem</span><span>(</span><span>ST</span><span><span>); </span><span>localStorage</span><span>.</span></span><span>removeItem</span><span>(</span><span>QU</span><span>); }</span></div></div><div><div><div>23</div></div><div><span>    </span><span>function</span><span> </span><span>gq</span><span>() { </span><span>try</span><span> { </span><span>return</span><span> </span><span>JSON</span><span>.</span><span>parse</span><span><span>(</span><span>localStorage</span><span>.</span></span><span>getItem</span><span>(</span><span>QU</span><span>)) </span><span>||</span><span> []; } </span><span>catch</span><span><span> (</span><span>e</span><span>) { </span></span><span>return</span><span> []; } }</span></div></div><div><div><div>24</div></div><div><span>    </span><span>function</span><span> </span><span>sq</span><span><span>(</span><span>q</span><span>) { </span><span>localStorage</span><span>.</span></span><span>setItem</span><span>(</span><span>QU</span><span>, </span><span>JSON</span><span>.</span><span>stringify</span><span><span>(</span><span>q</span><span>)); }</span></span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div><span>    </span><span>function</span><span> </span><span>curPg</span><span>() {</span></div></div><div><div><div>27</div></div><div><span>        </span><span>const</span><span> </span><span>m</span><span> </span><span>=</span><span><span> </span><span>location</span><span>.</span></span><span>href</span><span>.</span><span>match</span><span>(</span><span>/</span><span>[?&amp;]</span><span>page=(</span><span>\d</span><span><span>+</span><span>)/</span></span><span>);</span></div></div><div><div><div>28</div></div><div><span>        </span><span>return</span><span><span> </span><span>m</span><span> </span></span><span>?</span><span> </span><span>parseInt</span><span><span>(</span><span>m</span><span>[</span></span><span>1</span><span>]) </span><span>:</span><span> </span><span>1</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><span>function</span><span> </span><span>isLP</span><span>() { </span><span>return</span><span><span> </span><span>location</span><span>.</span></span><span>href</span><span>.</span><span>includes</span><span>(</span><span>'/upload-manager/article'</span><span>); }</span></div></div><div><div><div>31</div></div><div><span>    </span><span>function</span><span> </span><span>isEP</span><span>() { </span><span>return</span><span><span> </span><span>location</span><span>.</span></span><span>href</span><span>.</span><span>includes</span><span>(</span><span>'/upload/video/frame'</span><span>); }</span></div></div><div><div><div>32</div></div><div>
</div></div><div><div><div>33</div></div><div><span>    </span><span>function</span><span> </span><span>collect</span><span>() {</span></div></div><div><div><div>34</div></div><div><span>        </span><span>const</span><span> </span><span>links</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'a[href*="/video/BV"]'</span><span>);</span></div></div><div><div><div>35</div></div><div><span>        </span><span>const</span><span> </span><span>seen</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>Set</span><span>();</span></div></div><div><div><div>36</div></div><div><span>        </span><span>const</span><span> </span><span>q</span><span> </span><span>=</span><span> [];</span></div></div><div><div><div>37</div></div><div><span>        </span><span>let</span><span><span> </span><span>sk</span><span> </span></span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>38</div></div><div><span><span>        </span></span><span>links</span><span>.</span><span>forEach</span><span><span>(</span><span>a</span><span> </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>39</div></div><div><span>            </span><span>const</span><span> </span><span>m</span><span> </span><span>=</span><span><span> </span><span>a</span><span>.</span></span><span>href</span><span>.</span><span>match</span><span>(</span><span><span>/</span><span>\/</span><span>video</span><span>\/</span><span>(BV</span></span><span>[a-zA-Z0-9]</span><span><span>+</span><span>)/</span></span><span>);</span></div></div><div><div><div>40</div></div><div><span>            </span><span>if</span><span> (</span><span>!</span><span><span>m</span><span> </span></span><span>||</span><span><span> </span><span>seen</span><span>.</span></span><span>has</span><span><span>(</span><span>m</span><span>[</span></span><span>1</span><span>])) </span><span>return</span><span>;</span></div></div><div><div><div>41</div></div><div><span><span>            </span></span><span>seen</span><span>.</span><span>add</span><span><span>(</span><span>m</span><span>[</span></span><span>1</span><span>]);</span></div></div><div><div><div>42</div></div><div><span>            </span><span>const</span><span> </span><span>row</span><span> </span><span>=</span><span><span> </span><span>a</span><span>.</span></span><span>closest</span><span>(</span><span>'li, div, [class*="item"]'</span><span>);</span></div></div><div><div><div>43</div></div><div><span>            </span><span>// 跳过已经是公开的（没有"仅自己可见"标记的视为公开）</span></div></div><div><div><div>44</div></div><div><span>            </span><span>if</span><span><span> (</span><span>row</span><span> </span></span><span>&amp;&amp;</span><span> </span><span>!</span><span><span>row</span><span>.</span></span><span>textContent</span><span>.</span><span>includes</span><span>(</span><span>'仅自己可见'</span><span><span>)) </span><span>sk</span></span><span>++</span><span>;</span></div></div><div><div><div>45</div></div><div><span>            </span><span>else</span><span><span> </span><span>q</span><span>.</span></span><span>push</span><span><span>(</span><span>m</span><span>[</span></span><span>1</span><span>]);</span></div></div><div><div><div>46</div></div><div><span><span>        </span></span><span>});</span></div></div><div><div><div>47</div></div><div><span>        </span><span>return</span><span> { </span><span>queue</span><span>:</span><span> [</span><span>...</span><span>new</span><span> </span><span>Set</span><span><span>(</span><span>q</span><span>)], </span></span><span>skipped</span><span>:</span><span><span> </span><span>sk</span><span> };</span></span></div></div><div><div><div>48</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>49</div></div><div>
</div></div><div><div><div>50</div></div><div><span>    </span><span>function</span><span> </span><span>mkPanel</span><span>() {</span></div></div><div><div><div>51</div></div><div><span>        </span><span>if</span><span><span> (</span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-panel'</span><span>)) </span><span>return</span><span>;</span></div></div><div><div><div>52</div></div><div><span>        </span><span>const</span><span> </span><span>p</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>createElement</span><span>(</span><span>'div'</span><span>);</span></div></div><div><div><div>53</div></div><div><span><span>        </span></span><span>p</span><span>.</span><span>id</span><span> </span><span>=</span><span> </span><span>'bs-panel'</span><span>;</span></div></div><div><div><div>54</div></div><div><span><span>        </span></span><span>p</span><span>.</span><span>style</span><span>.</span><span>cssText</span><span> </span><span>=</span><span> </span><span>`</span></div></div><div><div><div>55</div></div><div><span><span>            </span></span><span>position: fixed; z-index: 999999; right: 20px; top: 100px;</span></div></div><div><div><div>56</div></div><div><span><span>            </span></span><span>width: 270px; padding: 15px; background: #18191c; color: #fff;</span></div></div><div><div><div>57</div></div><div><span><span>            </span></span><span>border-radius: 10px; box-shadow: 0 4px 20px rgba(0,0,0,.35);</span></div></div><div><div><div>58</div></div><div><span><span>            </span></span><span>font-size: 14px; font-family: Arial, sans-serif;</span></div></div><div><div><div>59</div></div><div><span><span>        </span></span><span>`</span><span>;</span></div></div><div><div><div>60</div></div><div><span><span>        </span></span><span>p</span><span>.</span><span>innerHTML</span><span> </span><span>=</span><span> </span><span>`</span></div></div><div><div><div>61</div></div><div><span><span>            </span></span><span>&lt;div style="font-size:16px;font-weight:bold;margin-bottom:10px;"&gt;B站批量公开&lt;/div&gt;</span></div></div><div><div><div>62</div></div><div><span><span>            </span></span><span>&lt;div id="bs-cur" style="margin-bottom:6px;color:#aaa;"&gt;当前页：第 </span><span>${</span><span>curPg</span><span>()</span><span>}</span><span> 页&lt;/div&gt;</span></div></div><div><div><div>63</div></div><div><span><span>            </span></span><span>&lt;div id="bs-st" style="margin-bottom:10px;"&gt;状态：等待开始&lt;/div&gt;</span></div></div><div><div><div>64</div></div><div><span><span>            </span></span><span>&lt;div id="bs-cnt" style="margin-bottom:12px;"&gt;已公开：0　跳过：0　失败：0&lt;/div&gt;</span></div></div><div><div><div>65</div></div><div><span><span>            </span></span><span>&lt;div style="margin-bottom:8px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;"&gt;</span></div></div><div><div><div>66</div></div><div><span><span>                </span></span><span>&lt;span style="font-size:12px;color:#aaa;"&gt;从第&lt;/span&gt;</span></div></div><div><div><div>67</div></div><div><span><span>                </span></span><span>&lt;input id="bs-sp" type="number" min="1" max="99" value="1"</span></div></div><div><div><div>68</div></div><div><span><span>                    </span></span><span>style="width:45px;padding:4px;border:1px solid #555;border-radius:4px;background:#2a2a2a;color:#fff;text-align:center;font-size:13px;"&gt;</span></div></div><div><div><div>69</div></div><div><span><span>                </span></span><span>&lt;span style="font-size:12px;color:#aaa;"&gt;页到第&lt;/span&gt;</span></div></div><div><div><div>70</div></div><div><span><span>                </span></span><span>&lt;input id="bs-ep" type="number" min="1" max="99" value="99"</span></div></div><div><div><div>71</div></div><div><span><span>                    </span></span><span>style="width:45px;padding:4px;border:1px solid #555;border-radius:4px;background:#2a2a2a;color:#fff;text-align:center;font-size:13px;"&gt;</span></div></div><div><div><div>72</div></div><div><span><span>                </span></span><span>&lt;span style="font-size:12px;color:#aaa;"&gt;页&lt;/span&gt;</span></div></div><div><div><div>73</div></div><div><span><span>            </span></span><span>&lt;/div&gt;</span></div></div><div><div><div>74</div></div><div><span><span>            </span></span><span>&lt;button id="bs-go" style="width:100%;padding:8px;border:0;border-radius:6px;</span></div></div><div><div><div>75</div></div><div><span><span>                </span></span><span>background:#00aeec;color:#fff;cursor:pointer;margin-bottom:6px;"&gt;开始批量公开&lt;/button&gt;</span></div></div><div><div><div>76</div></div><div><span><span>            </span></span><span>&lt;button id="bs-stp" style="width:100%;padding:8px;border:0;border-radius:6px;</span></div></div><div><div><div>77</div></div><div><span><span>                </span></span><span>background:#444;color:#fff;cursor:pointer;margin-bottom:6px;"&gt;停止&lt;/button&gt;</span></div></div><div><div><div>78</div></div><div><span><span>            </span></span><span>&lt;button id="bs-rst" style="width:100%;padding:6px;border:0;border-radius:6px;</span></div></div><div><div><div>79</div></div><div><span><span>                </span></span><span>background:#333;color:#aaa;cursor:pointer;font-size:12px;"&gt;重置状态&lt;/button&gt;</span></div></div><div><div><div>80</div></div><div><span><span>            </span></span><span>&lt;div style="margin-top:10px;color:#aaa;font-size:12px;line-height:1.5;"&gt;</span></div></div><div><div><div>81</div></div><div><span><span>                </span></span><span>自动翻页处理到结束页。&lt;br&gt;已公开的自动跳过。&lt;br&gt;面板在列表页和编辑页都会显示。</span></div></div><div><div><div>82</div></div><div><span><span>            </span></span><span>&lt;/div&gt;</span></div></div><div><div><div>83</div></div><div><span><span>        </span></span><span>`</span><span>;</span></div></div><div><div><div>84</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>body</span><span>.</span><span>appendChild</span><span><span>(</span><span>p</span><span>);</span></span></div></div><div><div><div>85</div></div><div>
</div></div><div><div><div>86</div></div><div><span>        </span><span>const</span><span> </span><span>sp</span><span> </span><span>=</span><span><span> </span><span>localStorage</span><span>.</span></span><span>getItem</span><span>(</span><span>'bs_sp'</span><span>);</span></div></div><div><div><div>87</div></div><div><span>        </span><span>const</span><span> </span><span>ep</span><span> </span><span>=</span><span><span> </span><span>localStorage</span><span>.</span></span><span>getItem</span><span>(</span><span>'bs_ep'</span><span>);</span></div></div><div><div><div>88</div></div><div><span>        </span><span>if</span><span><span> (</span><span>sp</span><span>) </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-sp'</span><span>).</span><span>value</span><span> </span><span>=</span><span><span> </span><span>sp</span><span>;</span></span></div></div><div><div><div>89</div></div><div><span>        </span><span>if</span><span><span> (</span><span>ep</span><span>) </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-ep'</span><span>).</span><span>value</span><span> </span><span>=</span><span><span> </span><span>ep</span><span>;</span></span></div></div><div><div><div>90</div></div><div>
</div></div><div><div><div>91</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>getElementById</span><span>(</span><span>'bs-sp'</span><span>).</span><span>onchange</span><span> </span><span>=</span><span> () </span><span>=&gt;</span><span><span> </span><span>localStorage</span><span>.</span></span><span>setItem</span><span>(</span><span>'bs_sp'</span><span><span>, </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-sp'</span><span>).</span><span>value</span><span>);</span></div></div><div><div><div>92</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>getElementById</span><span>(</span><span>'bs-ep'</span><span>).</span><span>onchange</span><span> </span><span>=</span><span> () </span><span>=&gt;</span><span><span> </span><span>localStorage</span><span>.</span></span><span>setItem</span><span>(</span><span>'bs_ep'</span><span><span>, </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-ep'</span><span>).</span><span>value</span><span>);</span></div></div><div><div><div>93</div></div><div>
</div></div><div><div><div>94</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>getElementById</span><span>(</span><span>'bs-go'</span><span>).</span><span>onclick</span><span> </span><span>=</span><span> </span><span>function</span><span> () {</span></div></div><div><div><div>95</div></div><div><span>            </span><span>if</span><span> (</span><span>gs</span><span>() </span><span>&amp;&amp;</span><span> </span><span>gs</span><span>().</span><span>running</span><span>) { </span><span>stUI</span><span>(</span><span>'运行中，请先停止'</span><span>); </span><span>return</span><span>; }</span></div></div><div><div><div>96</div></div><div><span>            </span><span>const</span><span> </span><span>sp</span><span> </span><span>=</span><span><span> </span><span>Math</span><span>.</span></span><span>max</span><span>(</span><span>1</span><span>, </span><span>parseInt</span><span><span>(</span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-sp'</span><span>).</span><span>value</span><span>) </span><span>||</span><span> </span><span>1</span><span>);</span></div></div><div><div><div>97</div></div><div><span>            </span><span>const</span><span> </span><span>ep</span><span> </span><span>=</span><span><span> </span><span>Math</span><span>.</span></span><span>max</span><span><span>(</span><span>sp</span><span>, </span></span><span>parseInt</span><span><span>(</span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-ep'</span><span>).</span><span>value</span><span>) </span><span>||</span><span> </span><span>99</span><span>);</span></div></div><div><div><div>98</div></div><div><span>            </span><span>cs</span><span>();</span></div></div><div><div><div>99</div></div><div><span>            </span><span>ss</span><span>({</span></div></div><div><div><div>100</div></div><div><span>                </span><span>running</span><span>:</span><span> </span><span>true</span><span>, </span><span>collected</span><span>:</span><span> </span><span>false</span><span>, </span><span>reachedStart</span><span>:</span><span> </span><span>false</span><span>,</span></div></div><div><div><div>101</div></div><div><span>                </span><span>processed</span><span>:</span><span> </span><span>0</span><span>, </span><span>skipped</span><span>:</span><span> </span><span>0</span><span>, </span><span>failed</span><span>:</span><span> </span><span>0</span><span>, </span><span>queue</span><span>:</span><span> [],</span></div></div><div><div><div>102</div></div><div><span>                </span><span>startPage</span><span>:</span><span><span> </span><span>sp</span><span>, </span></span><span>endPage</span><span>:</span><span><span> </span><span>ep</span><span>, </span></span><span>currentPage</span><span>:</span><span> </span><span>0</span><span>, </span><span>done</span><span>:</span><span> </span><span>false</span></div></div><div><div><div>103</div></div><div><span><span>            </span></span><span>});</span></div></div><div><div><div>104</div></div><div><span>            </span><span>sq</span><span>([]);</span></div></div><div><div><div>105</div></div><div><span><span>            </span></span><span>localStorage</span><span>.</span><span>setItem</span><span>(</span><span>'bs_sp'</span><span>, </span><span>String</span><span><span>(</span><span>sp</span><span>));</span></span></div></div><div><div><div>106</div></div><div><span><span>            </span></span><span>localStorage</span><span>.</span><span>setItem</span><span>(</span><span>'bs_ep'</span><span>, </span><span>String</span><span><span>(</span><span>ep</span><span>));</span></span></div></div><div><div><div>107</div></div><div><span>            </span><span>stUI</span><span>(</span><span>'开始（第 '</span><span> </span><span>+</span><span><span> </span><span>sp</span><span> </span></span><span>+</span><span> </span><span>'~'</span><span> </span><span>+</span><span><span> </span><span>ep</span><span> </span></span><span>+</span><span> </span><span>' 页）'</span><span>);</span></div></div><div><div><div>108</div></div><div><span><span>            </span></span><span>location</span><span>.</span><span>href</span><span> </span><span>=</span><span> </span><span>LIST_URL</span><span> </span><span>+</span><span><span> (</span><span>sp</span><span> </span></span><span>&gt;</span><span> </span><span>1</span><span> </span><span>?</span><span> </span><span>'?page='</span><span> </span><span>+</span><span><span> </span><span>sp</span><span> </span></span><span>:</span><span> </span><span>''</span><span>);</span></div></div><div><div><div>109</div></div><div><span><span>        </span></span><span>};</span></div></div><div><div><div>110</div></div><div>
</div></div><div><div><div>111</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>getElementById</span><span>(</span><span>'bs-stp'</span><span>).</span><span>onclick</span><span> </span><span>=</span><span> () </span><span>=&gt;</span><span> { </span><span>cs</span><span>(); </span><span>stUI</span><span>(</span><span>'已停止'</span><span>); </span><span>updP</span><span>(); };</span></div></div><div><div><div>112</div></div><div><span><span>        </span></span><span>document</span><span>.</span><span>getElementById</span><span>(</span><span>'bs-rst'</span><span>).</span><span>onclick</span><span> </span><span>=</span><span> () </span><span>=&gt;</span><span> { </span><span>cs</span><span>(); </span><span>stUI</span><span>(</span><span>'已重置'</span><span>); </span><span>updP</span><span>(); };</span></div></div><div><div><div>113</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>114</div></div><div>
</div></div><div><div><div>115</div></div><div><span>    </span><span>function</span><span> </span><span>updP</span><span>() {</span></div></div><div><div><div>116</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>117</div></div><div><span>        </span><span>const</span><span> </span><span>el</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-cnt'</span><span>);</span></div></div><div><div><div>118</div></div><div><span>        </span><span>const</span><span> </span><span>pel</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-cur'</span><span>);</span></div></div><div><div><div>119</div></div><div><span>        </span><span>if</span><span><span> (</span><span>el</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>s</span><span>) </span><span>el</span><span>.</span></span><span>textContent</span><span> </span><span>=</span><span> </span><span>'已公开：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>processed</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>'　跳过：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>skipped</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>'　失败：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>failed</span><span> </span><span>||</span><span> </span><span>0</span><span>);</span></div></div><div><div><div>120</div></div><div><span>        </span><span>else</span><span> </span><span>if</span><span><span> (</span><span>el</span><span>) </span><span>el</span><span>.</span></span><span>textContent</span><span> </span><span>=</span><span> </span><span>'已公开：0　跳过：0　失败：0'</span><span>;</span></div></div><div><div><div>121</div></div><div><span>        </span><span>if</span><span><span> (</span><span>pel</span><span>) </span><span>pel</span><span>.</span></span><span>textContent</span><span> </span><span>=</span><span> </span><span>'当前页：第 '</span><span> </span><span>+</span><span> </span><span>curPg</span><span>() </span><span>+</span><span> </span><span>' 页（目标 '</span><span> </span><span>+</span><span><span> (</span><span>s</span><span> </span></span><span>?</span><span><span> </span><span>s</span><span>.</span></span><span>startPage</span><span> </span><span>+</span><span> </span><span>'~'</span><span> </span><span>+</span><span><span> </span><span>s</span><span>.</span></span><span>endPage</span><span> </span><span>:</span><span> </span><span>'-'</span><span>) </span><span>+</span><span> </span><span>'）'</span><span>;</span></div></div><div><div><div>122</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>123</div></div><div><span>    </span><span>function</span><span> </span><span>stUI</span><span><span>(</span><span>t</span><span>) { </span></span><span>const</span><span> </span><span>el</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>getElementById</span><span>(</span><span>'bs-st'</span><span>); </span><span>if</span><span><span> (</span><span>el</span><span>) </span><span>el</span><span>.</span></span><span>textContent</span><span> </span><span>=</span><span> </span><span>'状态：'</span><span> </span><span>+</span><span><span> </span><span>t</span><span>; }</span></span></div></div><div><div><div>124</div></div><div>
</div></div><div><div><div>125</div></div><div><span>    </span><span>function</span><span> </span><span>advance</span><span>() {</span></div></div><div><div><div>126</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>127</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>s</span><span>.</span></span><span>running</span><span> </span><span>||</span><span> </span><span>!</span><span>isLP</span><span>()) </span><span>return</span><span>;</span></div></div><div><div><div>128</div></div><div><span>        </span><span>if</span><span><span> (</span><span>s</span><span>.</span></span><span>done</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>129</div></div><div>
</div></div><div><div><div>130</div></div><div><span><span>        </span></span><span>s</span><span>.</span><span>currentPage</span><span> </span><span>=</span><span> </span><span>curPg</span><span>();</span></div></div><div><div><div>131</div></div><div><span>        </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>132</div></div><div><span>        </span><span>updP</span><span>();</span></div></div><div><div><div>133</div></div><div>
</div></div><div><div><div>134</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span>.</span></span><span>reachedStart</span><span>) {</span></div></div><div><div><div>135</div></div><div><span>            </span><span>if</span><span> (</span><span>curPg</span><span>() </span><span>&lt;</span><span><span> </span><span>s</span><span>.</span></span><span>startPage</span><span>) {</span></div></div><div><div><div>136</div></div><div><span>                </span><span>stUI</span><span>(</span><span>'跳转到第 '</span><span> </span><span>+</span><span><span> </span><span>s</span><span>.</span></span><span>startPage</span><span> </span><span>+</span><span> </span><span>' 页...'</span><span>);</span></div></div><div><div><div>137</div></div><div><span><span>                </span></span><span>location</span><span>.</span><span>href</span><span> </span><span>=</span><span> </span><span>LIST_URL</span><span> </span><span>+</span><span> </span><span>'?page='</span><span> </span><span>+</span><span><span> </span><span>s</span><span>.</span></span><span>startPage</span><span>;</span></div></div><div><div><div>138</div></div><div><span>                </span><span>return</span><span>;</span></div></div><div><div><div>139</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>140</div></div><div><span><span>            </span></span><span>s</span><span>.</span><span>reachedStart</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>141</div></div><div><span>            </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>142</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>143</div></div><div>
</div></div><div><div><div>144</div></div><div><span>        </span><span>if</span><span> (</span><span>curPg</span><span>() </span><span>&gt;</span><span><span> </span><span>s</span><span>.</span></span><span>endPage</span><span>) { </span><span>finish</span><span>(); </span><span>return</span><span>; }</span></div></div><div><div><div>145</div></div><div>
</div></div><div><div><div>146</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span>.</span></span><span>collected</span><span>) {</span></div></div><div><div><div>147</div></div><div><span>            </span><span>const</span><span> { </span><span>queue</span><span>, </span><span>skipped</span><span> } </span><span>=</span><span> </span><span>collect</span><span>();</span></div></div><div><div><div>148</div></div><div><span>            </span><span>if</span><span><span> (</span><span>queue</span><span>.</span></span><span>length</span><span> </span><span>===</span><span> </span><span>0</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'a[href*="/video/BV"]'</span><span>).</span><span>length</span><span> </span><span>===</span><span> </span><span>0</span><span>) {</span></div></div><div><div><div>149</div></div><div><span>                </span><span>stUI</span><span>(</span><span>'等待页面加载...'</span><span>);</span></div></div><div><div><div>150</div></div><div><span>                </span><span>setTimeout</span><span>(() </span><span>=&gt;</span><span> { </span><span>if</span><span> (</span><span>gs</span><span>() </span><span>&amp;&amp;</span><span> </span><span>gs</span><span>().</span><span>running</span><span> </span><span>&amp;&amp;</span><span> </span><span>!</span><span>gs</span><span>().</span><span>done</span><span>) </span><span>advance</span><span>(); }, </span><span>2000</span><span>);</span></div></div><div><div><div>151</div></div><div><span>                </span><span>return</span><span>;</span></div></div><div><div><div>152</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>153</div></div><div><span><span>            </span></span><span>s</span><span>.</span><span>skipped</span><span> </span><span>=</span><span><span> (</span><span>s</span><span>.</span></span><span>skipped</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span><span> </span><span>skipped</span><span>;</span></span></div></div><div><div><div>154</div></div><div><span><span>            </span></span><span>s</span><span>.</span><span>queue</span><span> </span><span>=</span><span><span> </span><span>queue</span><span>;</span></span></div></div><div><div><div>155</div></div><div><span><span>            </span></span><span>s</span><span>.</span><span>collected</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>156</div></div><div><span>            </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>157</div></div><div><span>            </span><span>sq</span><span><span>(</span><span>queue</span><span>);</span></span></div></div><div><div><div>158</div></div><div>
</div></div><div><div><div>159</div></div><div><span>            </span><span>if</span><span><span> (</span><span>queue</span><span>.</span></span><span>length</span><span> </span><span>===</span><span> </span><span>0</span><span>) {</span></div></div><div><div><div>160</div></div><div><span>                </span><span>stUI</span><span>(</span><span>'本页无待处理，进入下一页'</span><span>);</span></div></div><div><div><div>161</div></div><div><span><span>                </span></span><span>s</span><span>.</span><span>collected</span><span> </span><span>=</span><span> </span><span>false</span><span>; </span><span>ss</span><span><span>(</span><span>s</span><span>); </span></span><span>nextPage</span><span>();</span></div></div><div><div><div>162</div></div><div><span>                </span><span>return</span><span>;</span></div></div><div><div><div>163</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>164</div></div><div><span>            </span><span>goEdit</span><span>();</span></div></div><div><div><div>165</div></div><div><span>            </span><span>return</span><span>;</span></div></div><div><div><div>166</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>167</div></div><div>
</div></div><div><div><div>168</div></div><div><span>        </span><span>if</span><span><span> (</span><span>s</span><span>.</span></span><span>queue</span><span>.</span><span>length</span><span> </span><span>&gt;</span><span> </span><span>0</span><span>) { </span><span>goEdit</span><span>(); </span><span>return</span><span>; }</span></div></div><div><div><div>169</div></div><div><span><span>        </span></span><span>s</span><span>.</span><span>collected</span><span> </span><span>=</span><span> </span><span>false</span><span>; </span><span>ss</span><span><span>(</span><span>s</span><span>); </span></span><span>nextPage</span><span>();</span></div></div><div><div><div>170</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>171</div></div><div>
</div></div><div><div><div>172</div></div><div><span>    </span><span>function</span><span> </span><span>goEdit</span><span>() {</span></div></div><div><div><div>173</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>174</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>s</span><span>.</span></span><span>running</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>175</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span>.</span></span><span>queue</span><span> </span><span>||</span><span><span> </span><span>s</span><span>.</span></span><span>queue</span><span>.</span><span>length</span><span> </span><span>===</span><span> </span><span>0</span><span>) { </span><span>advance</span><span>(); </span><span>return</span><span>; }</span></div></div><div><div><div>176</div></div><div><span>        </span><span>const</span><span> </span><span>next</span><span> </span><span>=</span><span><span> </span><span>s</span><span>.</span></span><span>queue</span><span>.</span><span>shift</span><span>();</span></div></div><div><div><div>177</div></div><div><span>        </span><span>sq</span><span><span>(</span><span>s</span><span>.</span></span><span>queue</span><span>);</span></div></div><div><div><div>178</div></div><div><span>        </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>179</div></div><div><span>        </span><span>stUI</span><span>(</span><span>'处理 '</span><span> </span><span>+</span><span><span> </span><span>next</span><span> </span></span><span>+</span><span> </span><span>' | 第 '</span><span> </span><span>+</span><span><span> </span><span>s</span><span>.</span></span><span>currentPage</span><span> </span><span>+</span><span> </span><span>'/'</span><span> </span><span>+</span><span><span> </span><span>s</span><span>.</span></span><span>endPage</span><span> </span><span>+</span><span> </span><span>' 页'</span><span>);</span></div></div><div><div><div>180</div></div><div><span><span>        </span></span><span>location</span><span>.</span><span>href</span><span> </span><span>=</span><span> </span><span>eUrl</span><span><span>(</span><span>next</span><span>);</span></span></div></div><div><div><div>181</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>182</div></div><div>
</div></div><div><div><div>183</div></div><div><span>    </span><span>function</span><span> </span><span>nextPage</span><span>() {</span></div></div><div><div><div>184</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>185</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>s</span><span>.</span></span><span>running</span><span> </span><span>||</span><span><span> </span><span>s</span><span>.</span></span><span>done</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>186</div></div><div><span>        </span><span>if</span><span> (</span><span>curPg</span><span>() </span><span>&gt;=</span><span><span> </span><span>s</span><span>.</span></span><span>endPage</span><span>) { </span><span>finish</span><span>(); </span><span>return</span><span>; }</span></div></div><div><div><div>187</div></div><div><span>        </span><span>stUI</span><span>(</span><span>'进入第 '</span><span> </span><span>+</span><span> (</span><span>curPg</span><span>() </span><span>+</span><span> </span><span>1</span><span>) </span><span>+</span><span> </span><span>' 页...'</span><span>);</span></div></div><div><div><div>188</div></div><div><span><span>        </span></span><span>s</span><span>.</span><span>collected</span><span> </span><span>=</span><span> </span><span>false</span><span>; </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>189</div></div><div><span><span>        </span></span><span>location</span><span>.</span><span>href</span><span> </span><span>=</span><span> </span><span>LIST_URL</span><span> </span><span>+</span><span> </span><span>'?page='</span><span> </span><span>+</span><span> (</span><span>curPg</span><span>() </span><span>+</span><span> </span><span>1</span><span>);</span></div></div><div><div><div>190</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>191</div></div><div>
</div></div><div><div><div>192</div></div><div><span>    </span><span>function</span><span> </span><span>finish</span><span>() {</span></div></div><div><div><div>193</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>(); </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span>) </span></span><span>return</span><span>;</span></div></div><div><div><div>194</div></div><div><span><span>        </span></span><span>s</span><span>.</span><span>done</span><span> </span><span>=</span><span> </span><span>true</span><span>; </span><span>ss</span><span><span>(</span><span>s</span><span>);</span></span></div></div><div><div><div>195</div></div><div><span>        </span><span>alert</span><span>(</span><span>'🎉 全部完成！</span><span>\n</span><span>已公开：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>processed</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>'，跳过：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>skipped</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>'，失败：'</span><span> </span><span>+</span><span><span> (</span><span>s</span><span>.</span></span><span>failed</span><span> </span><span>||</span><span> </span><span>0</span><span>));</span></div></div><div><div><div>196</div></div><div><span>        </span><span>cs</span><span>(); </span><span>stUI</span><span>(</span><span>'全部完成'</span><span>);</span></div></div><div><div><div>197</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>198</div></div><div>
</div></div><div><div><div>199</div></div><div><span>    </span><span>async</span><span> </span><span>function</span><span> </span><span>handleEP</span><span>() {</span></div></div><div><div><div>200</div></div><div><span>        </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>201</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>s</span><span> </span></span><span>||</span><span> </span><span>!</span><span><span>s</span><span>.</span></span><span>running</span><span> </span><span>||</span><span><span> </span><span>s</span><span>.</span></span><span>done</span><span>) </span><span>return</span><span>;</span></div></div><div><div><div>202</div></div><div><span>        </span><span>updP</span><span>(); </span><span>stUI</span><span>(</span><span>'正在编辑...'</span><span>);</span></div></div><div><div><div>203</div></div><div><span>        </span><span>await</span><span> </span><span>slp</span><span>(</span><span>2000</span><span>);</span></div></div><div><div><div>204</div></div><div>
</div></div><div><div><div>205</div></div><div><span>        </span><span>let</span><span><span> </span><span>ok</span><span> </span></span><span>=</span><span> </span><span>false</span><span><span>, </span><span>clicked</span><span> </span></span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>206</div></div><div><span>        </span><span>const</span><span> </span><span>labels</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'span.label'</span><span>);</span></div></div><div><div><div>207</div></div><div><span>        </span><span>for</span><span> (</span><span>const</span><span> </span><span>l</span><span> </span><span>of</span><span><span> </span><span>labels</span><span>) { </span></span><span>if</span><span><span> (</span><span>l</span><span>.</span></span><span>textContent</span><span>.</span><span>trim</span><span>().</span><span>startsWith</span><span>(</span><span>'更多设置'</span><span><span>)) { </span><span>l</span><span>.</span></span><span>click</span><span><span>(); </span><span>clicked</span><span> </span></span><span>=</span><span> </span><span>true</span><span>; </span><span>break</span><span>; } }</span></div></div><div><div><div>208</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>clicked</span><span>) {</span></span></div></div><div><div><div>209</div></div><div><span>            </span><span>const</span><span> </span><span>titles</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'div.title'</span><span>);</span></div></div><div><div><div>210</div></div><div><span>            </span><span>for</span><span> (</span><span>const</span><span> </span><span>t</span><span> </span><span>of</span><span><span> </span><span>titles</span><span>) { </span></span><span>if</span><span><span> (</span><span>t</span><span>.</span></span><span>textContent</span><span>.</span><span>trim</span><span>().</span><span>startsWith</span><span>(</span><span>'更多设置'</span><span><span>)) { </span><span>t</span><span>.</span></span><span>click</span><span><span>(); </span><span>clicked</span><span> </span></span><span>=</span><span> </span><span>true</span><span>; </span><span>break</span><span>; } }</span></div></div><div><div><div>211</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>212</div></div><div><span>        </span><span>if</span><span> (</span><span>!</span><span><span>clicked</span><span>) {</span></span></div></div><div><div><div>213</div></div><div><span>            </span><span>const</span><span> </span><span>all</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'span, div, a'</span><span>);</span></div></div><div><div><div>214</div></div><div><span>            </span><span>for</span><span> (</span><span>const</span><span> </span><span>e</span><span> </span><span>of</span><span><span> </span><span>all</span><span>) { </span></span><span>if</span><span><span> (</span><span>e</span><span>.</span></span><span>textContent</span><span>.</span><span>trim</span><span>().</span><span>startsWith</span><span>(</span><span>'更多设置'</span><span><span>)) { </span><span>e</span><span>.</span></span><span>click</span><span><span>(); </span><span>clicked</span><span> </span></span><span>=</span><span> </span><span>true</span><span>; </span><span>break</span><span>; } }</span></div></div><div><div><div>215</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>216</div></div><div>
</div></div><div><div><div>217</div></div><div><span>        </span><span>if</span><span><span> (</span><span>clicked</span><span>) {</span></span></div></div><div><div><div>218</div></div><div><span>            </span><span>await</span><span> </span><span>slp</span><span>(</span><span>1200</span><span>);</span></div></div><div><div><div>219</div></div><div><span>            </span><span>const</span><span> </span><span>containers</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'.check-radio-v2-container'</span><span>);</span></div></div><div><div><div>220</div></div><div><span>            </span><span>if</span><span><span> (</span><span>containers</span><span>.</span></span><span>length</span><span> </span><span>&gt;=</span><span> </span><span>2</span><span>) {</span></div></div><div><div><div>221</div></div><div><span>                </span><span>const</span><span> </span><span>isChecked</span><span> </span><span>=</span><span><span> </span><span>containers</span><span>[</span></span><span>0</span><span>].</span><span>querySelector</span><span>(</span><span>'.check-radio-v2-box-checked'</span><span>) </span><span>!==</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>222</div></div><div><span>                </span><span>if</span><span> (</span><span>!</span><span><span>isChecked</span><span>) {</span></span></div></div><div><div><div>223</div></div><div><span>                    </span><span>// 点击"公开可见"（第一个容器）</span></div></div><div><div><div>224</div></div><div><span>                    </span><span>const</span><span> </span><span>radio</span><span> </span><span>=</span><span><span> </span><span>containers</span><span>[</span></span><span>0</span><span>].</span><span>querySelector</span><span>(</span><span>'.check-radio-v2-box, span'</span><span>);</span></div></div><div><div><div>225</div></div><div><span>                    </span><span>if</span><span><span> (</span><span>radio</span><span>) </span><span>radio</span><span>.</span></span><span>click</span><span>(); </span><span>else</span><span><span> </span><span>containers</span><span>[</span></span><span>0</span><span>].</span><span>click</span><span>();</span></div></div><div><div><div>226</div></div><div><span>                    </span><span>await</span><span> </span><span>slp</span><span>(</span><span>800</span><span>);</span></div></div><div><div><div>227</div></div><div><span><span>                </span></span><span>}</span></div></div><div><div><div>228</div></div><div><span>                </span><span>const</span><span> </span><span>sub</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelector</span><span>(</span><span>'span.submit-add'</span><span>);</span></div></div><div><div><div>229</div></div><div><span>                </span><span>if</span><span><span> (</span><span>sub</span><span>) { </span><span>sub</span><span>.</span></span><span>click</span><span><span>(); </span><span>ok</span><span> </span></span><span>=</span><span> </span><span>true</span><span>; }</span></div></div><div><div><div>230</div></div><div><span>                </span><span>else</span><span> {</span></div></div><div><div><div>231</div></div><div><span>                    </span><span>const</span><span> </span><span>all</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>querySelectorAll</span><span>(</span><span>'div, button, span'</span><span>);</span></div></div><div><div><div>232</div></div><div><span>                    </span><span>for</span><span> (</span><span>const</span><span> </span><span>e</span><span> </span><span>of</span><span><span> </span><span>all</span><span>) { </span></span><span>if</span><span><span> (</span><span>e</span><span>.</span></span><span>textContent</span><span>.</span><span>trim</span><span>() </span><span>===</span><span> </span><span>'立即投稿'</span><span><span>) { </span><span>e</span><span>.</span></span><span>click</span><span><span>(); </span><span>ok</span><span> </span></span><span>=</span><span> </span><span>true</span><span>; </span><span>break</span><span>; } }</span></div></div><div><div><div>233</div></div><div><span><span>                </span></span><span>}</span></div></div><div><div><div>234</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>235</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>236</div></div><div>
</div></div><div><div><div>237</div></div><div><span>        </span><span>const</span><span> </span><span>st</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>238</div></div><div><span>        </span><span>if</span><span><span> (</span><span>st</span><span>) { </span></span><span>if</span><span><span> (</span><span>ok</span><span>) </span><span>st</span><span>.</span></span><span>processed</span><span> </span><span>=</span><span><span> (</span><span>st</span><span>.</span></span><span>processed</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>1</span><span>; </span><span>else</span><span><span> </span><span>st</span><span>.</span></span><span>failed</span><span> </span><span>=</span><span><span> (</span><span>st</span><span>.</span></span><span>failed</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>+</span><span> </span><span>1</span><span>; </span><span>ss</span><span><span>(</span><span>st</span><span>); }</span></span></div></div><div><div><div>239</div></div><div><span>        </span><span>updP</span><span>();</span></div></div><div><div><div>240</div></div><div><span>        </span><span>stUI</span><span><span>(</span><span>ok</span><span> </span></span><span>?</span><span> </span><span>'已提交，返回列表...'</span><span> </span><span>:</span><span> </span><span>'失败，返回列表...'</span><span>);</span></div></div><div><div><div>241</div></div><div><span>        </span><span>await</span><span> </span><span>slp</span><span>(</span><span>2500</span><span>);</span></div></div><div><div><div>242</div></div><div><span>        </span><span>const</span><span> </span><span>st2</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>243</div></div><div><span>        </span><span>const</span><span> </span><span>pg</span><span> </span><span>=</span><span><span> </span><span>st2</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>st2</span><span>.</span></span><span>currentPage</span><span> </span><span>?</span><span><span> </span><span>st2</span><span>.</span></span><span>currentPage</span><span> </span><span>:</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>244</div></div><div><span><span>        </span></span><span>location</span><span>.</span><span>href</span><span> </span><span>=</span><span> </span><span>LIST_URL</span><span> </span><span>+</span><span><span> (</span><span>pg</span><span> </span></span><span>&gt;</span><span> </span><span>1</span><span> </span><span>?</span><span> </span><span>'?page='</span><span> </span><span>+</span><span><span> </span><span>pg</span><span> </span></span><span>:</span><span> </span><span>''</span><span>);</span></div></div><div><div><div>245</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>246</div></div><div>
</div></div><div><div><div>247</div></div><div><span>    </span><span>setTimeout</span><span>(</span><span>function</span><span> () { </span><span>mkPanel</span><span>(); </span><span>updP</span><span>(); }, </span><span>500</span><span>);</span></div></div><div><div><div>248</div></div><div>
</div></div><div><div><div>249</div></div><div><span>    </span><span>if</span><span> (</span><span>isLP</span><span>()) {</span></div></div><div><div><div>250</div></div><div><span>        </span><span>setTimeout</span><span>(</span><span>function</span><span> () {</span></div></div><div><div><div>251</div></div><div><span>            </span><span>const</span><span> </span><span>s</span><span> </span><span>=</span><span> </span><span>gs</span><span>();</span></div></div><div><div><div>252</div></div><div><span>            </span><span>if</span><span><span> (</span><span>s</span><span> </span></span><span>&amp;&amp;</span><span><span> </span><span>s</span><span>.</span></span><span>running</span><span> </span><span>&amp;&amp;</span><span> </span><span>!</span><span><span>s</span><span>.</span></span><span>done</span><span>) { </span><span>stUI</span><span>(</span><span>'自动继续...'</span><span>); </span><span>updP</span><span>(); </span><span>advance</span><span>(); </span><span>updP</span><span>(); }</span></div></div><div><div><div>253</div></div><div><span><span>        </span></span><span>}, </span><span>2000</span><span>);</span></div></div><div><div><div>254</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>255</div></div><div>
</div></div><div><div><div>256</div></div><div><span>    </span><span>if</span><span> (</span><span>isEP</span><span>()) { </span><span>handleEP</span><span>(); }</span></div></div><div><div><div>257</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>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/ai/comfyui-wrokflow/minimax-h3-workflow/</id>
      <title type="text">MiniMax H3 工作流分享：不用4步LoRA，速度还快5倍</title>
      <published>2026-08-11T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/ai/comfyui-wrokflow/minimax-h3-workflow/"/>
      <summary type="text">分享一个 MiniMax H3 的 ComfyUI 工作流，不用官方4步LoRA，生成速度和质量反而更好</summary>
      <content type="html"><![CDATA[<section><h2>写在前面<a href="#写在前面"><span>#</span></a></h2><p>MiniMax H3 官方说 3060 能跑，但实际上很多人跑起来还是很吃力。我折腾了好几天，找到了一个工作流，不仅能跑，而且直接提速 500%。</p><p>更离谱的是，这个工作流甚至没用官方 4 步 LoRA，但生成速度和质量都比用了 LoRA 的更优秀。</p><p>下面把模型下载、插件、配置、实测数据都整理一遍。</p><p><a href="https://github.com/hy4962/Share/blob/main/Comfyui/Workflow/H3-3.0.json" target="_blank">Share/Comfyui/Workflow/H3-3.0.json at main · hy4962/Share</a></p><p></p><figure><img alt="工作流截图" loading="lazy" width="2048" height="927" src="/_astro/image-006.DkHWCCOB_ZTjaJX.webp" /><figcaption>工作流截图</figcaption></figure><p></p></section>
<section><h2>模型下载<a href="#模型下载"><span>#</span></a></h2>

<table><thead><tr><th>来源</th><th>地址</th></tr></thead><tbody><tr><td>HuggingFace</td><td><a href="https://huggingface.co/Comfy-Org/MiniMax-H3/tree/main" target="_blank">Comfy-Org/MiniMax-H3</a></td></tr><tr><td>国内源</td><td><a href="https://www.modelscope.cn/models/Comfy-Org/MiniMax-H3/files" target="_blank">ModelScope</a></td></tr></tbody></table></section>
<section><h2>我的硬件配置<a href="#我的硬件配置"><span>#</span></a></h2><ul>
<li>GPU：RTX 5070 Ti Laptop（12G 显存）</li>
<li>内存：32G</li>
<li>虚拟内存：32G-64G</li>
</ul></section>
<section><h2>能跑的模型搭配<a href="#能跑的模型搭配"><span>#</span></a></h2><p>下面这套组合实测可以正常运行：</p>

<table><thead><tr><th>文件类型</th><th>文件名</th></tr></thead><tbody><tr><td>diffusion_models</td><td><a href="https://huggingface.co/Comfy-Org/MiniMax-H3/blob/main/diffusion_models/minimax_h3_fl2va_int8_convrot.safetensors" target="_blank">minimax_h3_fl2va_int8_convrot.safetensors</a></td></tr><tr><td>text_encoders</td><td><a href="https://huggingface.co/Comfy-Org/MiniMax-H3/blob/main/text_encoders/qwen3vl_32b_minimax_h3_int8_convrot.safetensors" target="_blank">qwen3vl_32b_minimax_h3_int8_convrot.safetensors</a></td></tr><tr><td>vae</td><td><a href="https://huggingface.co/Comfy-Org/MiniMax-H3/tree/main/vae" target="_blank">MiniMax-H3 VAE</a></td></tr></tbody></table></section>
<section><h2>可能需要的插件<a href="#可能需要的插件"><span>#</span></a></h2><ul>
<li><a href="https://github.com/tl2012tl/comfyUI-llama-TE" target="_blank">comfyUI-llama-TE</a>：qwen3/qwen3.5/qwen3.7/gemma4 图片反推、提示词扩写高速版</li>
<li><a href="https://github.com/tl2012tl/TE_MAN" target="_blank">TE_MAN</a>：首个为 ComfyUI 打造的漫剧/增强生图/生视频插件</li>
</ul><div><div><div></div><div>补充插件</div></div><div><p>有些插件在 ComfyUI Manager 里可能搜不到，可以直接从 GitHub 安装：</p><ul>
<li><a href="https://github.com/goohai/Goohaitools-comfyui" target="_blank">Goohaitools</a>：50 多个实用工具，包括批量加载文件夹图片、自动保存指定 DPI、遮罩分析、人脸矫正等</li>
<li><a href="https://github.com/NikoDemon80/ComfyUI-H3-Motion-Context" target="_blank">H3-Motion-Context</a>：MiniMax H3 的 clip chaining，运动和音频能真正跨片段衔接</li>
</ul></div></div></section>
<section><h2>提示词生成<a href="#提示词生成"><span>#</span></a></h2><p>工作流里自带了提示词模板，用法很简单：</p><ol>
<li>把模板复制给 AI</li>
<li>把你的想法尽可能详细地描述一下</li>
<li>AI 就能生成标准格式的提示词</li>
<li>直接用于生成即可</li>
</ol><div><div><div></div><div>关于提示词模型</div></div><div><p>强烈建议本地部署 Qwen 3.5 无审查版本来生成提示词，怎么在自己电脑上跑起来，可以看我那篇<a href="/posts/ai/qwen35/">本地部署 Qwen3.5 完全指南</a>。哪怕是 Grok，也容易遭到拒绝回答。</p></div></div></section>
<section><h2>细节设置<a href="#细节设置"><span>#</span></a></h2><section><h3>8G 显存用户<a href="#8g-显存用户"><span>#</span></a></h3><p>如果你是 8G 显存，把红色的节点换成绿色的即可。</p><p></p><figure><img alt="8G显存节点替换" loading="lazy" width="1048" height="470" src="/_astro/image-001.3btS3ysE_ZMVDVh.webp" /><figcaption>8G显存节点替换</figcaption></figure><p></p></section><section><h3>一致性模式选择<a href="#一致性模式选择"><span>#</span></a></h3><p>使用 Max 模式可以保持非常优秀的人物一致性，但速度会慢一倍。一般 Match 模式也够用了。</p><p></p><figure><img alt="一致性模式对比" loading="lazy" width="632" height="865" src="/_astro/image-002.4D0yKj_S_Z6MVIQ.webp" /><figcaption>一致性模式对比</figcaption></figure><p></p></section><section><h3>采样步数<a href="#采样步数"><span>#</span></a></h3><p>由于没有使用 4 步 LoRA，所以步数可以自己设置。默认 10 步，速度很快，质量也不错。追求更高质量可以设成 15 步，这个工作流很快，多几步没什么影响。</p><p></p><figure><img alt="采样步数设置" loading="lazy" width="1553" height="996" src="/_astro/image-003.DsXmzj2D_Z1jO4Lj.webp" /><figcaption>采样步数设置</figcaption></figure><p></p></section></section>
<section><h2>实测数据<a href="#实测数据"><span>#</span></a></h2><p>跑了好几天，生成了三四十个 10 秒视频，以下是两个典型场景的耗时：</p><section><h3>场景一：0.4 像素，8 秒视频<a href="#场景一04-像素8-秒视频"><span>#</span></a></h3><p>总时长 249 秒，直出 0.4 像素 8 秒视频。</p><p></p><figure><img alt="0.4像素8秒视频耗时" loading="lazy" width="560" height="827" src="/_astro/image-004.CyHApJQl_ZXfwGB.webp" /><figcaption>0.4像素8秒视频耗时</figcaption></figure><p></p></section><section><h3>场景二：0.9 像素，10 秒视频<a href="#场景二09-像素10-秒视频"><span>#</span></a></h3><p>也可以直出 0.9 像素 10 秒视频，总时长约 1000 秒。</p><p></p><figure><img alt="0.9像素10秒视频耗时" loading="lazy" width="1216" height="725" src="/_astro/image-005.DBEFjRA4_Z2nnuXh.webp" /><figcaption>0.9像素10秒视频耗时</figcaption></figure><p></p></section></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><section><h3>衣服或场景对不上文字描述<a href="#衣服或场景对不上文字描述"><span>#</span></a></h3><p>有时候衣服或者部分场景对不上文字描述，这是因为在 Full-Reference Mode（全参考模式）下，AI 的优先级逻辑是：</p><ol>
<li><strong>最高优先级</strong>：<code>&lt;Picture N&gt;</code>（参考图）中的视觉特征</li>
<li><strong>次级优先级</strong>：<code>&lt;Subject N&gt;</code> 定义的文字描述</li>
<li><strong>最低优先级</strong>：<code>detailed_description</code> 中的动态场景描写</li>
</ol><p>不过实测更强烈的文字描述可以跨越优先级，所以描述写详细一点是有用的。</p></section><section><h3>提示词 Skill 推荐<a href="#提示词-skill-推荐"><span>#</span></a></h3><p>目前只推荐官方原版的提示词 Skill。试了很多其他 UP 主的，发现效果都不如官方 Skill。有些 UP 的提示词适用范围太小，有些太短，不太通用。</p><p><a href="https://github.com/MiniMax-AI/MiniMax-H3/tree/main/skills" target="_blank">MiniMax-H3/skills at main · MiniMax-AI/MiniMax-H3</a></p></section></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>这个工作流的核心优势就是：不用官方 4 步 LoRA，速度反而更快，质量也不错。对于显存有限的用户来说，是个比较好的选择。</p><p>如果有什么问题或者更好的配置方案，欢迎交流。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/ai/comfyui-wrokflow/krea2fourviewshare/</id>
      <title type="text">Krea2 四视图生成：必备模型与可选 LoRA 分享</title>
      <published>2026-08-10T00:00:00.000Z</published>
      <updated>2026-08-26T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/ai/comfyui-wrokflow/krea2fourviewshare/"/>
      <summary type="text">分享 Krea2 四视图生成的必备模型和可选 LoRA。</summary>
      <content type="html"><![CDATA[<p>最近在用 Krea2 生成四视图，整理了一下必备模型和可选 LoRA，方便后续使用。</p>
<p><a href="https://github.com/hy4962/Share/blob/main/Comfyui/Workflow/Krea2%E5%9B%9B%E8%A7%86%E5%9B%BE.json" target="_blank">Share/Comfyui/Workflow/Krea2四视图.json at main · hy4962/Share</a></p>
<section><h2>必备模型<a href="#必备模型"><span>#</span></a></h2><p>以下三个模型是 Krea2 四视图生成的基础，缺一不可。</p><ol>
<li><strong>Krea2 模型</strong>：<a href="https://huggingface.co/Comfy-Org/Krea-2/blob/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors" target="_blank">krea2_turbo_fp8_scaled.safetensors</a> - 核心扩散模型</li>
<li><strong>文本编码器</strong>：<a href="https://huggingface.co/Comfy-Org/Qwen3-VL/blob/main/text_encoders/qwen3vl_4b_bf16.safetensors" target="_blank">qwen3vl_4b_bf16.safetensors</a> - Qwen3-VL 文本编码器</li>
<li><strong>VAE</strong>：<a href="https://huggingface.co/hy4962/Share/blob/main/Comfyui/vae/qwenImage_qwenImageVAE.safetensors" target="_blank">qwenImage_qwenImageVAE.safetensors</a> - QwenImage VAE</li>
</ol><p>这三个模型都需要下载到 ComfyUI 的对应目录中。</p></section>
<section><h2>可选 LoRA<a href="#可选-lora"><span>#</span></a></h2><p>可选的 LoRA 模型来自魔搭社区的作者 <a href="https://www.modelscope.cn/profile/yan303145427" target="_blank">yan303145427</a>，他分享了多个 Krea2 风格的 LoRA。</p><p>我个人目前只用 <a href="https://www.modelscope.cn/models/yan303145427/krea2-Cc-FQWZ-ArtStyle/file/view/20260716204803/krea2-Cc%E9%A3%8E%E6%83%85%E4%B8%87%E7%A7%8D-%E9%A3%8E%E6%A0%BC.safetensors?status=2" target="_blank">krea2-Cc风情万种-风格</a> 这个 LoRA。这个作者的 krea2 lora 都很不错，可以根据自己的风格选择。</p><p></p><figure><img alt="示例图" loading="lazy" width="2048" height="974" src="/_astro/cover.D6alHxf6_Z2f1VzN.webp" /><figcaption>示例图</figcaption></figure><p></p></section>
<section><h2>使用说明<a href="#使用说明"><span>#</span></a></h2><p>下载好模型后，可以参考这个 <a href="https://www.bilibili.com/video/BV14Zu16YERZ/?share_source=copy_web&amp;vd_source=ed7d1d02f65398903f7bdbaab5c74522" target="_blank">Bilibili 视频</a> 配置工作流。视频里详细介绍了四视图生成的提示词技巧和加速流配置，这里就不重复了。</p><p>如果遇到问题，可以参考视频评论区或作者的其他分享。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/recommend/flydigbs3/</id>
      <title type="text">压风散热器 Flydigi BS3：主要是为了给硬盘降温和防尘</title>
      <published>2026-08-10T00:00:00.000Z</published>
      <updated>2026-08-26T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/recommend/flydigbs3/"/>
      <summary type="text">买了飞智 BS3，主要解决 GW7000 2TB 打游戏 62 度的问题，顺带防尘，没下官方软件，用的是开源的 THRM。</summary>
      <content type="html"><![CDATA[<p>这玩意确实不是智商税，真的有用。不过我买它不是冲着 CPU/GPU 降温去的，主要就两个需求。但是不得不承认他也确实让我的烤鸡温度下降了5到8度(我没用30w的pd头和线跑满血)</p>
<p><strong>一是硬盘降温。</strong></p>
<p>我的 GW7000 2TB，打游戏直接 62 度，明明没啥读写。更气的是这不是笔记本的问题——我之前台式机也用这块盘，也是 60 度。台式机上还因为用的是盈通花嫁的 7800XT，matx 主板空间本来就挤，当时也没太好的办法，只能忍。</p>
<p>换了笔记本之后，这盘还是这样，索性就试试压风散热器能不能压一下。</p>
<p><strong>二是防尘。</strong></p>
<p>这东西公认防尘效果很优秀，据说一年笔记本里面都不带进灰的，算是意外的好处。</p>
<hr />
<p>看来看去在 BS2 Pro 和 BS3 之间选，看到 BS3 也就 189，还是买新不买旧——虽然 BS2 Pro 比 BS3 散热效果更好，但价格差不多的情况下选新款没什么毛病。</p>
<hr />
<p>然后是软件这块。开源社区有人做了第三方控制软件 THRM，智能风扇曲线、硬件监控、RGB、配置文件都有，Windows 和 Linux 都支持。</p>
<p>我压根没下过官方配套软件……买之前就看到这个项目了，直接用的这个。里面的智能学习功能确实好使，起码我没发现什么坏处。它会根据你的使用习惯自动调整风扇曲线，还有功耗预测前馈——就是 CPU/GPU 功耗突然上来的时候，转速会提前跟上，不会让温度先飙一波再说。</p>
<p>硬件监控也挺全的，CPU/GPU 温度、功耗、散热器转速都能看到，还能输出到 RTSS 游戏内叠加层，打游戏的时候直接看转速，挺方便的。</p>
<p>RGB 灯效也能调，亮度、动画速度、灯效模式都能自定义，不过我个人不太在意这个。</p>
<p><a href="https://github.com/TIANLI0/THRM" target="_blank">TIANLI0/THRM</a></p>
<p></p><figure><img alt="THRM" loading="lazy" width="1536" height="1024" src="/_astro/thrm-poster-light.D7MtYeBj_Z2c1Vbu.webp" /><figcaption>THRM</figcaption></figure><p></p>
<p></p><figure><img alt="image-20260809235045641" loading="lazy" width="1280" height="960" src="/_astro/image-20260809235045641.CywDYmni_Z12qxsS.webp" /><figcaption>image-20260809235045641</figcaption></figure><p></p>
<p></p><figure><img alt="截图 1" loading="lazy" width="2048" height="1155" src="/_astro/screenshot-01.CUYxzGee_ZwH9Gr.webp" /><figcaption>截图 1</figcaption></figure><p></p>
<p></p><figure><img alt="截图 2" loading="lazy" width="2048" height="1152" src="/_astro/screenshot-02.Cc371xXc_Z2bdEOk.webp" /><figcaption>截图 2</figcaption></figure><p></p>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/ai/comfyui-wrokflow/anima-inpainting-workflow/</id>
      <title type="text">Anima 局部重绘工作流分享</title>
      <published>2026-08-09T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/ai/comfyui-wrokflow/anima-inpainting-workflow/"/>
      <summary type="text">一个好用的 ComfyUI 局部重绘工作流，用 Anima 模型修复换装/修复手指脚趾也很方便</summary>
      <content type="html"><![CDATA[<p>最近在折腾 ComfyUI 的局部重绘，找到了一个挺好用的工作流，分享一下。</p>
<section><h2>工作流下载<a href="#工作流下载"><span>#</span></a></h2><p><a href="https://github.com/hy4962/Share/blob/main/Comfyui/Workflow/Anima%E6%AD%A3%E5%BC%8F%E7%89%88-%E5%B1%80%E9%83%A8%E9%87%8D%E7%BB%98%2B%E9%AB%98%E6%B8%85%E6%94%BE%E5%A4%A7.json" target="_blank">Share/Comfyui/Workflow/Anima正式版-局部重绘+高清放大.json at main · hy4962/Share</a></p></section>
<section><h2>模型准备<a href="#模型准备"><span>#</span></a></h2><p>用的是 Anima 这个模型：<a href="https://civitai.red/models/2458426/anima?modelVersionId=2945208" target="_blank">Anima - base-v1.0 | Anima Checkpoint | Civitai</a></p><p>右边三个模型都要下载，别漏了。三个文件放哪个目录、turbo lora 怎么用，之前那篇 <a href="/posts/ai/comfyui-wrokflow/anima-text-to-image-workflow/">Anima 文生图工作流</a>里写过，这里就不重复了。</p><p></p><figure><img alt="模型选择" loading="lazy" width="1693" height="966" src="/_astro/model-select.BXIL5mTp_11gHHY.webp" /><figcaption>模型选择</figcaption></figure><p></p></section>
<section><h2>工作流结构<a href="#工作流结构"><span>#</span></a></h2><p>整体预览：</p><p></p><figure><img alt="工作流预览" loading="lazy" width="2048" height="974" src="/_astro/workflow-overview.BTfR8Dc-_cbcBO.webp" /><figcaption>工作流预览</figcaption></figure><p></p></section>
<section><h2>使用方法<a href="#使用方法"><span>#</span></a></h2><p>导入图片后，打开遮罩：</p><p></p><figure><img alt="打开遮罩" loading="lazy" width="631" height="1009" src="/_astro/open-mask.B9VZJR7F_ZCq0iY.webp" /><figcaption>打开遮罩</figcaption></figure><p></p><p>把不透明拉满，需要改哪里就涂黑哪里：</p><p></p><figure><img alt="涂抹遮罩" loading="lazy" width="2048" height="937" src="/_astro/paint-mask.CNS_og9V_Z2aQOS8.webp" /><figcaption>涂抹遮罩</figcaption></figure><p></p></section>
<section><h2>使用体验<a href="#使用体验"><span>#</span></a></h2><p>这个工作流我主要用来做局部修复，<strong>换装效果非常好</strong>。比如想给图里的人换件衣服，圈出衣服区域涂黑，生成出来很自然，和原图融合得很好。</p><p>手指、脚趾这些细节修起来也很方便，圈个遮罩就能搞定。Anima 模型本身就不容易出这些部位的错误，但万一有问题，用这个工作流修复也很顺手。</p><p>使用上也没什么坑，主要就是注意把模型放对位置就行了。</p></section>
<section><h2>总结<a href="#总结"><span>#</span></a></h2><p>ComfyUI 局部重绘工作流，用 Anima 模型，人像修复效果很好，推荐试试。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/ai/comfyui-wrokflow/upscaleworkflow/</id>
      <title type="text">ComfyUI 图像放大工作流：从文生图工作流中抽取的独立放大方案</title>
      <published>2026-08-06T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/ai/comfyui-wrokflow/upscaleworkflow/"/>
      <summary type="text">分享我从 ComfyUI 文生图工作流中抽取的独立图像放大工作流，包含模型配置、节点设置和使用心得</summary>
      <content type="html"><![CDATA[<p>最近在用 ComfyUI 做图像放大，发现把放大功能从文生图工作流里抽出来单独用，确实方便很多。</p>
<p>之前的 <a href="/posts/ai/comfyui-wrokflow/anima-text-to-image-workflow/">Anima 文生图工作流</a>里也带放大功能，但每次想单独放大一张图，还得加载整个文生图流程，加载模型多、显存占用大，而且参数混在一起不好调。干脆直接抽出来，做成一个独立的放大工作流。</p>
<p><a href="https://github.com/hy4962/Share/blob/main/Comfyui/Workflow/%E6%94%BE%E5%A4%A7.json" target="_blank">Share/Comfyui/Workflow/放大.json at main · hy4962/Share</a></p>
<section><h2>为什么单独抽出来<a href="#为什么单独抽出来"><span>#</span></a></h2><p>主要原因就一个：<strong>方便抽卡和管理</strong>。</p><p>文生图工作流里，放大参数和生成参数混在一起，想调放大效果还得在一堆节点里找。单独抽出来之后，所有放大相关的配置一目了然，改参数也方便。</p><p>另外就是显存管理。文生图工作流加载的模型多，放大只是其中一个环节。单独做放大工作流，可以只加载需要的模型，显存占用小一些。</p></section>
<section><h2>工作流结构<a href="#工作流结构"><span>#</span></a></h2><p>整个工作流结构很清晰：</p><p></p><figure><img alt="工作流整体配置" loading="lazy" width="2048" height="972" src="/_astro/workflow-overview.2VgtoZ9o_1uIGp2.webp" /><figcaption>工作流整体配置</figcaption></figure><p></p><p>主要分几个部分：</p><ol>
<li><strong>模型加载区</strong>：加载 UNET、CLIP、VAE 和 LoRA 模型</li>
<li><strong>图像输入</strong>：加载要放大的原图</li>
<li><strong>图像预处理</strong>：按比例缩放到目标尺寸</li>
<li><strong>采样器</strong>：进行放大处理</li>
<li><strong>后处理</strong>：色彩匹配和保存</li>
</ol></section>
<section><h2>模型配置<a href="#模型配置"><span>#</span></a></h2><p>这个工作流需要几个特定的模型文件：</p><p><strong>主模型</strong>：Flux2-Klein-9B-True-v2-fp8mixed.safetensors</p><ul>
<li>这是 Klein 系列的主模型，专门针对图像放大优化</li>
</ul><p><strong>LoRA 模型</strong>：Klein_动漫高清放大.safetensors</p><ul>
<li>这个 LoRA 是专门做动漫风格高清放大的，效果比通用放大好很多</li>
</ul><p><strong>CLIP 模型</strong>：qwen_3_8b_fp8mixed.safetensors</p><ul>
<li>文本编码器，用 Qwen 3 8B 的 fp8 混合精度版本</li>
</ul><p><strong>VAE 模型</strong>：flux2-vae.safetensors</p><ul>
<li>Flux2 的 VAE，负责图像的编解码</li>
</ul><p>模型下载地址都在 HuggingFace 上，直接搜文件名就能找到：</p><ul>
<li>主模型：<a href="https://huggingface.co/buckets/hy4962/Flux2-Klein-9B-True-V2-bucket/tree/Flux2-Klein-9B-True-v2-fp8mixed.safetensors" target="_blank">Flux2-Klein-9B-True-v2-fp8mixed.safetensors</a></li>
<li>LoRA模型：<a href="https://huggingface.co/buckets/hy4962/Klein_AnimeHDupscaling-bucket/tree/Klein_%E5%8A%A8%E6%BC%AB%E9%AB%98%E6%B8%85%E6%94%BE%E5%A4%A7.safetensors" target="_blank">Klein_动漫高清放大.safetensors</a></li>
<li>CLIP模型：<a href="https://huggingface.co/buckets/hy4962/qwen_3_8b_fp8mixed-bucket/tree/qwen_3_8b_fp8mixed.safetensors" target="_blank">qwen_3_8b_fp8mixed.safetensors</a></li>
<li>VAE模型：<a href="https://huggingface.co/buckets/hy4962/flux2-dev-bucket/tree/split_files/vae/flux2-vae.safetensors" target="_blank">flux2-vae.safetensors</a></li>
</ul></section>
<section><h2>关键节点设置<a href="#关键节点设置"><span>#</span></a></h2><p>这个工作流里有个很重要的设置，必须注意：</p><p></p><figure><img alt="节点设置要求" loading="lazy" width="432" height="742" src="/_astro/node-settings.B2s0epkO_Zw3OqE.webp" /><figcaption>节点设置要求</figcaption></figure><p></p><p>在 PainterFluxImageEdit 节点里，有两个参数必须设置为特定值：</p><ul>
<li><strong>mode</strong>：必须设置为 <code>1_image</code></li>
<li><strong>batch_size</strong>：必须设置为 <code>1</code></li>
</ul></section>
<section><h2>使用体验<a href="#使用体验"><span>#</span></a></h2><p>单独用这个工作流放大图像，确实比在文生图工作流里方便很多：</p><ol>
<li><strong>加载速度快</strong>：只加载需要的模型，不用加载整个文生图流程</li>
<li><strong>参数清晰</strong>：所有放大相关参数都在一起，改起来方便</li>
<li><strong>显存占用小</strong>：模型少了，显存压力也小了</li>
</ol><p>不过也有个问题：这个工作流是针对动漫风格优化的，放大真人图像效果可能不如专门的放大模型。</p></section>
<section><h2>总结<a href="#总结"><span>#</span></a></h2><p>把这个放大工作流从文生图里抽出来，是我用 ComfyUI 以来做过最正确的决定之一。管理方便、调参方便、显存占用也小。</p><p>如果你也在用 ComfyUI 做图像放大，建议也试试单独做一个放大工作流。特别是如果你经常需要放大动漫风格的图像，这个 Klein 系列的模型效果真的不错。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/deploynewapi/</id>
      <title type="text">Docker 一键部署 NewApi API 中转站</title>
      <published>2026-07-28T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/deploynewapi/"/>
      <summary type="text">NewApi 是目前大多数 API 中转站都在用的统一网关管理工具，支持几乎所有主流 AI 模型接入，可对外售卖也可个人使用</summary>
      <content type="html"><![CDATA[<p>现在市面上绝大多数 API 中转站，后台用的都是 NewApi。</p>
<p>NewApi 是一个开源的 AI 接口管理与分发系统，基于 One-API 二次开发，功能比原版更丰富。核心作用是把你手上的各种上游 API（OpenAI、Claude、Gemini、各家国产大模型……）统一收进来，然后对外暴露一个标准的 OpenAI 兼容接口。下游不管接的是什么模型，调用方式完全一样，换模型不用改代码。</p>
<p>用途主要两种：</p>
<ul>
<li><strong>对外售卖</strong>：做中转站，卖 API 额度给用户，有完整的计费、充值、用量统计体系</li>
<li><strong>个人使用</strong>：统一管理自己的一堆 key，项目里只配一个 NewApi 地址就够了，不用到处存 key</li>
</ul>
<section><h2>NewApi 能做什么<a href="#newapi-能做什么"><span>#</span></a></h2><p><strong>渠道管理</strong>：把各家上游 API 添加进来，配置模型映射、优先级、负载均衡。同一个模型名可以对应多个渠道，自动故障转移。支持的模型几乎覆盖全球所有主流官方服务，也可以自定义添加任何 OpenAI 兼容的接口。</p><p><strong>令牌系统</strong>：对外发放 API Key（令牌），可以设置额度上限、IP 限制、模型白名单、过期时间。每个令牌的用量独立统计，方便管理多个项目或多个用户。</p><p><strong>用户管理</strong>：支持用户注册、充值、配额管理。商业部署的话有完整的前台界面，用户可以自助充值、查看用量、管理自己的令牌。</p><p><strong>计费系统</strong>：按 token 计费，可以自定义每个模型的倍率。调用日志完整记录，可以按时间、用户、模型、渠道多维度查询。</p><p><strong>监控与告警</strong>：渠道有健康检查，自动禁用失效的 key，支持邮件告警。</p></section>
<section><h2>部署架构<a href="#部署架构"><span>#</span></a></h2><p>这套 Docker Compose 起了三个容器：</p>

<table><thead><tr><th>容器</th><th>镜像</th><th>作用</th></tr></thead><tbody><tr><td><code>new-api</code></td><td><code>calciumion/new-api:latest</code></td><td>主服务，提供 Web 界面和 API 端点</td></tr><tr><td><code>new-api-redis</code></td><td><code>redis:latest</code></td><td>缓存层，存储会话、速率限制计数等</td></tr><tr><td><code>new-api-postgres</code></td><td><code>postgres:15</code></td><td>主数据库，存储用户、渠道、日志等所有持久化数据</td></tr></tbody></table><p>Redis 和 PostgreSQL 都配置了持久化，重启不丢数据。</p></section>
<section><h2>Docker Compose 配置<a href="#docker-compose-配置"><span>#</span></a></h2><p>创建 <code>docker-compose.yml</code> 文件：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>version</span><span>: </span><span>'3.4'</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>services</span><span>:</span></div></div><div><div><div>4</div></div><div><span>  </span><span>new-api</span><span>:</span></div></div><div><div><div>5</div></div><div><span>    </span><span>image</span><span>: </span><span>calciumion/new-api:latest</span></div></div><div><div><div>6</div></div><div><span>    </span><span>container_name</span><span>: </span><span>new-api</span></div></div><div><div><div>7</div></div><div><span>    </span><span>restart</span><span>: </span><span>always</span></div></div><div><div><div>8</div></div><div><span>    </span><span>command</span><span>: </span><span>--log-dir /app/logs</span></div></div><div><div><div>9</div></div><div><span>    </span><span>ports</span><span>:</span></div></div><div><div><div>10</div></div><div><span><span>      </span></span><span>- </span><span>'3000:3000'</span><span>  </span><span># 如果3000端口冲突，可以改成 '3001:3000'</span></div></div><div><div><div>11</div></div><div><span>    </span><span>volumes</span><span>:</span></div></div><div><div><div>12</div></div><div><span><span>      </span></span><span>- </span><span>/your/data/path:/data</span><span>          </span><span># ⚠️ 改成你自己的路径</span></div></div><div><div><div>13</div></div><div><span><span>      </span></span><span>- </span><span>/your/logs/path:/app/logs</span><span>      </span><span># ⚠️ 改成你自己的路径</span></div></div><div><div><div>14</div></div><div><span>    </span><span>environment</span><span>:</span></div></div><div><div><div>15</div></div><div><span>      </span><span># ⚠️ 数据库连接字符串（请确保密码与下方 postgres 的密码一致）</span></div></div><div><div><div>16</div></div><div><span><span>      </span></span><span>- </span><span>SQL_DSN=postgresql://newapi_user:YourStrongPassword123!@postgres:5432/new-api</span></div></div><div><div><div>17</div></div><div><span><span>      </span></span><span>- </span><span>REDIS_CONN_STRING=redis://redis:6379</span></div></div><div><div><div>18</div></div><div><span><span>      </span></span><span>- </span><span>TZ=Asia/Shanghai</span></div></div><div><div><div>19</div></div><div><span><span>      </span></span><span>- </span><span>ERROR_LOG_ENABLED=true</span></div></div><div><div><div>20</div></div><div><span><span>      </span></span><span>- </span><span>BATCH_UPDATE_ENABLED=true</span></div></div><div><div><div>21</div></div><div><span>    </span><span>depends_on</span><span>:</span></div></div><div><div><div>22</div></div><div><span><span>      </span></span><span>- </span><span>redis</span></div></div><div><div><div>23</div></div><div><span><span>      </span></span><span>- </span><span>postgres</span></div></div><div><div><div>24</div></div><div><span>    </span><span>healthcheck</span><span>:</span></div></div><div><div><div>25</div></div><div><span>      </span><span>test</span><span>: [</span><span>'CMD-SHELL'</span><span>, </span><span>"wget -q -O - http://localhost:3000/api/status | grep -o '</span><span>\"</span><span>success</span><span>\"</span><span>:</span><span>\\</span><span>s*true' || exit 1"</span><span>]</span></div></div><div><div><div>26</div></div><div><span>      </span><span>interval</span><span>: </span><span>30s</span></div></div><div><div><div>27</div></div><div><span>      </span><span>timeout</span><span>: </span><span>10s</span></div></div><div><div><div>28</div></div><div><span>      </span><span>retries</span><span>: </span><span>3</span></div></div><div><div><div>29</div></div><div>
</div></div><div><div><div>30</div></div><div><span>  </span><span>redis</span><span>:</span></div></div><div><div><div>31</div></div><div><span>    </span><span>image</span><span>: </span><span>redis:latest</span></div></div><div><div><div>32</div></div><div><span>    </span><span>container_name</span><span>: </span><span>new-api-redis</span></div></div><div><div><div>33</div></div><div><span>    </span><span>restart</span><span>: </span><span>always</span></div></div><div><div><div>34</div></div><div><span>    </span><span>command</span><span>: </span><span>redis-server --appendonly yes</span><span>  </span><span># 开启Redis持久化</span></div></div><div><div><div>35</div></div><div>
</div></div><div><div><div>36</div></div><div><span>  </span><span>postgres</span><span>:</span></div></div><div><div><div>37</div></div><div><span>    </span><span>image</span><span>: </span><span>postgres:15</span></div></div><div><div><div>38</div></div><div><span>    </span><span>container_name</span><span>: </span><span>new-api-postgres</span></div></div><div><div><div>39</div></div><div><span>    </span><span>restart</span><span>: </span><span>always</span></div></div><div><div><div>40</div></div><div><span>    </span><span>environment</span><span>:</span></div></div><div><div><div>41</div></div><div><span>      </span><span>POSTGRES_USER</span><span>: </span><span>newapi_user</span></div></div><div><div><div>42</div></div><div><span>      </span><span>POSTGRES_PASSWORD</span><span>: </span><span>YourStrongPassword123!</span><span>  </span><span># ⚠️ 这里建议修改为你自己的强密码</span></div></div><div><div><div>43</div></div><div><span>      </span><span>POSTGRES_DB</span><span>: </span><span>new-api</span></div></div><div><div><div>44</div></div><div><span>    </span><span>volumes</span><span>:</span></div></div><div><div><div>45</div></div><div><span><span>      </span></span><span>- </span><span>/your/postgres/data:/var/lib/postgresql/data</span><span>  </span><span># ⚠️ 改成你自己的路径，数据持久化防止重启丢失</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><blockquote><p><strong>部署前必改的三处地方</strong>：</p><ul>
<li><code>new-api</code> 的两个 <code>volumes</code> 路径换成你自己机器上的实际目录</li>
<li><code>postgres</code> 的 <code>volumes</code> 路径同上</li>
<li><code>POSTGRES_PASSWORD</code> 和 <code>SQL_DSN</code> 里的密码必须保持一致，改一处另一处也要跟着改</li>
</ul></blockquote></section>
<section><h2>启动服务<a href="#启动服务"><span>#</span></a></h2><p>在 <code>docker-compose.yml</code> 所在目录执行：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>docker-compose</span><span> </span><span>up</span><span> </span><span>-d</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>构建完后就能直接跑。</p><p></p><figure><img alt="服务启动后的登录页面" loading="lazy" width="1092" height="325" src="/_astro/screenshot-01.DWKEViTh_qYx4M.webp" /><figcaption>服务启动后的登录页面</figcaption></figure><p></p></section>
<section><h2>初始化配置<a href="#初始化配置"><span>#</span></a></h2><p>首次访问 <code>http://localhost:3000</code> 会进入初始化向导，只需要走一遍，后续不会再出现。</p><p><strong>第一步：选择部署模式</strong></p><p></p><figure><img alt="选择部署模式" loading="lazy" width="2048" height="990" src="/_astro/screenshot-02.CD9ZP74-_Z1Yvrp2.webp" /><figcaption>选择部署模式</figcaption></figure><p></p><ul>
<li><strong>个人版</strong>：简化了用户体系，适合自用，没有充值、用户注册等商业功能</li>
<li><strong>商业版</strong>：完整功能，有用户注册、充值、计费体系，适合对外提供服务</li>
</ul><p>我是个人使用，所以选了个人。</p><p><strong>第二步：设置管理员账号</strong></p><p></p><figure><img alt="配置管理员账号" loading="lazy" width="2048" height="990" src="/_astro/screenshot-03.CCSpoc2K_ZDqbVA.webp" /><figcaption>配置管理员账号</figcaption></figure><p></p><p>设置管理员用户名和密码，这是你登录后台的主账号。</p><p><strong>第三步：完成初始化</strong></p><p></p><figure><img alt="初始化完成" loading="lazy" width="2048" height="990" src="/_astro/screenshot-04.CA3Jys6t_ZQsf1P.webp" /><figcaption>初始化完成</figcaption></figure><p></p><p></p><figure><img alt="进入系统" loading="lazy" width="2048" height="990" src="/_astro/screenshot-05.YKdWnCUL_ZpqMEn.webp" /><figcaption>进入系统</figcaption></figure><p></p><p>初始化完成后进入主界面。</p></section>
<section><h2>后台界面<a href="#后台界面"><span>#</span></a></h2><p><strong>用户后台</strong>：普通用户视角，可以查看自己的用量、管理令牌、查看调用记录。</p><p></p><figure><img alt="用户后台" loading="lazy" width="2048" height="990" src="/_astro/screenshot-07.DTtvunAr_P1pjJ.webp" /><figcaption>用户后台</figcaption></figure><p></p><p><strong>管理后台</strong>：管理员视角，可以管理所有渠道、用户、令牌、查看全站日志和统计。</p><p></p><figure><img alt="站点管理后台" loading="lazy" width="2048" height="990" src="/_astro/screenshot-08.CwEenzEl_lcOTl.webp" /><figcaption>站点管理后台</figcaption></figure><p></p></section>
<section><h2>接入上游 API<a href="#接入上游-api"><span>#</span></a></h2><p>进入管理后台 → <strong>渠道</strong> → 新建渠道，把你的上游 API 添加进来。</p><p></p><figure><img alt="渠道配置页面" loading="lazy" width="2048" height="990" src="/_astro/screenshot-06.BEq6bJNT_Z1ywfJf.webp" /><figcaption>渠道配置页面</figcaption></figure><p></p><p>NewApi 内置了几乎全球所有主流官方模型的预设配置，OpenAI、Anthropic、Google、Azure、各家国产大模型都有。添加渠道时选好类型，填入你的 API Key，再配置一下这个渠道支持哪些模型即可。</p><p>一个模型可以对应多个渠道，NewApi 会按照你设置的优先级和权重自动做负载均衡，某个渠道挂了会自动切换到下一个。</p><p><strong>添加完渠道后，还需要创建令牌</strong>：管理后台 → <strong>令牌</strong> → 新建令牌，生成一个供调用的 API Key。下游项目的 <code>base_url</code> 填你的 NewApi 地址，<code>api_key</code> 填这里生成的令牌就行了。</p><hr /><p>配置完成后，所有请求统一走 NewApi 转发。额度管理、调用记录、限流规则都在一个地方，不用再到各个项目里单独维护 key 了。</p><p>我的这套服务跑在内网，想在外面访问的话，我是配合 Cloudflare + Lucky 搞的双栈域名反代，过程记在<a href="/posts/network/ipv6-cloudflare-lucky/">这篇</a>里，有需要可以参考。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/cloudflare_cors/</id>
      <title type="text">给 Cloudflare Tunnel 后面的 API 开启 CORS</title>
      <published>2026-07-28T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/cloudflare_cors/"/>
      <summary type="text">记录如何通过 Cloudflare 响应头转换规则，为 Tunnel 后面的 Memos API 添加 CORS 配置。</summary>
      <content type="html"><![CDATA[<section><h1>给 Cloudflare Tunnel 后面的 API 开启 CORS<a href="#给-cloudflare-tunnel-后面的-api-开启-cors"><span>#</span></a></h1><p>我的 Memos API 通过 <a href="/posts/network/cloudflaretunnel/">Cloudflare Tunnel</a> 暴露在 <code>memos.9ll.uk</code>，前端则运行在 <code>9ll.uk</code>。两个地址的来源不同，浏览器发起 API 请求时就会受到同源策略限制，需要在响应中补上 CORS 相关响应头。</p><p>这里没有修改 Memos 本身，而是直接使用 Cloudflare 的响应头转换规则，在响应返回浏览器之前添加所需的 CORS 头。</p><section><h2>先检查 API 地址<a href="#先检查-api-地址"><span>#</span></a></h2><p>首先检查前端填写的 API 地址。域名末尾不要带 <code>/</code>，写成：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://memos.9ll.uk</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>而不是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://memos.9ll.uk/</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这里后续的 API 路径会由请求端继续拼接。如果基础地址末尾已经带了 <code>/</code>，就可能和后面以 <code>/</code> 开头的路径拼出重复斜杠。</p><p></p><figure><img alt="Memos API 地址配置" loading="lazy" width="614" height="454" src="/_astro/memos-api-url.CyZvA55L_Zuhomp.webp" /><figcaption>Memos API 地址配置</figcaption></figure><p></p></section><section><h2>创建响应头转换规则<a href="#创建响应头转换规则"><span>#</span></a></h2><p>进入 Cloudflare 控制台，选择对应域名，然后打开 <strong>规则</strong> 页面。点击 <strong>创建规则</strong>，选择 <strong>响应头转换规则</strong>。</p><p></p><figure><img alt="在 Cloudflare 中创建响应头转换规则" loading="lazy" width="1900" height="1159" src="/_astro/cloudflare-create-rule.CC_IvD7s_Z2n6zAc.webp" /><figcaption>在 Cloudflare 中创建响应头转换规则</figcaption></figure><p></p><p>这类规则会在 Cloudflare 边缘修改返回给浏览器的响应头。源站不需要跟着改，比较适合给 Tunnel 后面的服务补充 CORS 配置。</p></section><section><h2>设置匹配条件<a href="#设置匹配条件"><span>#</span></a></h2><p>为了不让规则影响整个域名，我这里只匹配 Memos 的 API 请求：</p>

<table><thead><tr><th>字段</th><th>运算符</th><th>值</th></tr></thead><tbody><tr><td>主机名</td><td>等于</td><td><code>memos.9ll.uk</code></td></tr><tr><td>URI 路径</td><td>开头为</td><td><code>/api/</code></td></tr></tbody></table><p>对应的规则表达式是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>(http.host eq "memos.9ll.uk" and starts_with(http.request.uri.path, "/api/"))</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这样，只有访问 <code>memos.9ll.uk/api/</code> 下的请求才会被添加 CORS 响应头，其他页面和路径不会受到影响。</p></section><section><h2>添加 CORS 响应头<a href="#添加-cors-响应头"><span>#</span></a></h2><p>操作选择 <strong>修改响应头</strong>，然后使用 <strong>设置静态</strong> 添加下面三项：</p>

<table><thead><tr><th>响应头</th><th>值</th></tr></thead><tbody><tr><td><code>Access-Control-Allow-Origin</code></td><td><code>https://9ll.uk</code></td></tr><tr><td><code>Access-Control-Allow-Methods</code></td><td><code>GET, OPTIONS</code></td></tr><tr><td><code>Access-Control-Allow-Headers</code></td><td><code>Content-Type, Authorization</code></td></tr></tbody></table><p></p><figure><img alt="Cloudflare CORS 响应头规则配置" loading="lazy" width="1868" height="1145" src="/_astro/cloudflare-cors-rule.B72CrWoq_15tLRx.webp" /><figcaption>Cloudflare CORS 响应头规则配置</figcaption></figure><p></p><p>这三项分别控制：</p><ul>
<li><code>Access-Control-Allow-Origin</code>：允许 <code>https://9ll.uk</code> 这个来源读取响应。这里写的是具体来源，不是对所有网站开放的 <code>*</code>。</li>
<li><code>Access-Control-Allow-Methods</code>：允许 <code>GET</code> 请求和浏览器的 <code>OPTIONS</code> 预检请求。</li>
<li><code>Access-Control-Allow-Headers</code>：允许请求携带 <code>Content-Type</code> 和 <code>Authorization</code>。</li>
</ul><p>保存并部署规则即可。</p></section><section><h2>注意请求范围<a href="#注意请求范围"><span>#</span></a></h2><p>截图中的配置只开放了 <code>GET</code> 和 <code>OPTIONS</code>。如果你的前端还需要通过 API 创建、修改或删除内容，就要根据实际请求补充 <code>POST</code>、<code>PUT</code>、<code>PATCH</code> 或 <code>DELETE</code>，不能直接照抄这一项。</p><p><code>Access-Control-Allow-Origin</code> 也要替换成你自己的前端来源，协议、域名和端口都要一致。如果跨域请求需要携带凭据，就不能把这里写成 <code>*</code>。</p><p>配置时先把规则限制在对应的 API 域名和路径，再按真实请求填写允许的来源、方法和请求头。Cloudflare Tunnel 负责暴露服务；响应头转换规则在浏览器收到响应前补上 CORS 配置。</p></section><section><h2>参考资料<a href="#参考资料"><span>#</span></a></h2><ul>
<li><a href="https://developers.cloudflare.com/rules/transform/response-header-modification/" target="_blank">Cloudflare：Response Header Transform Rules</a></li>
<li><a href="https://developers.cloudflare.com/rules/transform/response-header-modification/create-dashboard/" target="_blank">Cloudflare：在控制台创建响应头转换规则</a></li>
<li><a href="https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Guides/CORS" target="_blank">MDN：跨源资源共享（CORS）</a></li>
</ul></section></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/ai/comfyui-wrokflow/anima-text-to-image-workflow/</id>
      <title type="text">Anima 文生图工作流分享</title>
      <published>2026-07-27T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/ai/comfyui-wrokflow/anima-text-to-image-workflow/"/>
      <summary type="text">ComfyUI 跑 Anima 模型的文生图工作流，三个模型文件怎么放、turbo lora 怎么用、提示词在哪找，整理了一下分享出来</summary>
      <content type="html"><![CDATA[<p>前阵子在 B 站刷到了 <a href="https://space.bilibili.com/3690999272442168" target="_blank">每日提钢小助手5号</a> 分享的 Anima 工作流，折腾了一下，感觉挺好用的，就想着整理出来分享一下。</p>
<p>这篇只包含文生图的部分。放大我单独抽出来做了另一套工作流，不喜欢混在一起，后来整理成<a href="/posts/ai/comfyui-wrokflow/upscaleworkflow/">独立的图像放大工作流</a>发出来了。</p>
<hr />
<section><h2>工作流文件<a href="#工作流文件"><span>#</span></a></h2><p>工作流放在 GitHub 上：</p><p><a href="https://github.com/hy4962/Share/blob/main/Comfyui/Workflow/Anima%E6%AD%A3%E5%BC%8F%E7%89%88-%E6%96%87%E7%94%9F%E5%9B%BE%E5%B7%A5%E4%BD%9C%E6%B5%81%20(1).json" target="_blank">Anima 正式版-文生图工作流</a></p><p>下载下来直接拖进 ComfyUI 就能用。</p><hr /></section>
<section><h2>必备模型<a href="#必备模型"><span>#</span></a></h2><p>这是重点。要跑起来需要下载三个文件，缺一不可。</p><p><a href="https://civitai.red/models/2458426/anima?modelVersionId=2945208" target="_blank">Anima - base-v1.0 | Civitai</a></p><p></p><figure><img alt="Civitai 模型页面" loading="lazy" width="1669" height="1077" src="/_astro/anima-civitai-model-page.B-AvEnom_ZOdHHT.webp" /><figcaption>Civitai 模型页面</figcaption></figure><p></p><p>页面里有三个文件，<strong>三个都要下</strong>，放的位置不一样：</p><ul>
<li>第一个主模型 → <code>ComfyUI\diffusion_models</code> 或 <code>ComfyUI\Unet</code></li>
<li>第二个 text 模型 → <code>ComfyUI\text_encoders</code> 或 <code>ComfyUI\clip</code></li>
<li>第三个 VAE → <code>ComfyUI\vae</code></li>
</ul><hr /></section>
<section><h2>可选：官方加速 Lora<a href="#可选官方加速-lora"><span>#</span></a></h2><p>这个我挺推荐的。官方出的 turbo lora，能把步数压到 6～8 步快速出图，质量损失极低。</p><p><a href="https://civitai.red/models/2458426/anima?modelVersionId=3108589" target="_blank">Anima - turbo-v1.0 | Civitai</a></p><p>用法上有点区别：</p><ul>
<li><strong>不用 turbo lora</strong>：采样器步数设 30，CFG 5～8</li>
<li><strong>用 turbo lora</strong>：步数改成 8，CFG 改成 1</li>
</ul><p>速度差挺多的，日常出图我基本就用 turbo 了。</p><hr /></section>
<section><h2>提示词怎么写<a href="#提示词怎么写"><span>#</span></a></h2><p>负面提示词基本不用动，直接写正面提示词就行。</p><p></p><figure><img alt="工作流提示词设置" loading="lazy" width="2048" height="974" src="/_astro/anima-workflow-prompt-settings.BkwOigWh_Z1JUnXy.webp" /><figcaption>工作流提示词设置</figcaption></figure><p></p><p>如果要生成特定动漫角色，可以去这个网站查触发词：</p><p><a href="https://animadex.net/" target="_blank">ANIMADEX — Anime character &amp; artist catalogue for ANIMA</a></p><p>里面有角色和画师的触发词，找到对应的复制进提示词就行。</p><hr /><p>折腾完感觉这套工作流比我之前用的顺多了，尤其是 turbo lora 那块，8 步出图速度很快，质量我觉得够用。放大的部分单独发了一篇。另外用 Anima 做局部重绘的工作流我也整理了一份：<a href="/posts/ai/comfyui-wrokflow/anima-inpainting-workflow/">Anima 局部重绘工作流分享</a>，换装、修手指脚趾都挺好使。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/blog/configsite/</id>
      <title type="text">Firefly 博客配置指南：从默认到属于你自己的博客</title>
      <published>2026-07-26T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/blog/configsite/"/>
      <summary type="text">详细讲解 Firefly 博客的配置文件，包括站点标题、主题色、页面开关、文章列表等，让你的博客从默认模板变成自己的风格</summary>
      <content type="html"><![CDATA[<p>上一篇我讲了<a href="/posts/deploy/deployfirefly/">怎么从零搭建 Firefly 博客</a>，从 Fork 仓库到 Vercel 部署，最后博客上线了。</p>
<p>但上线只是开始。</p>
<p>默认的博客标题是”Firefly”，副标题是”Demo site”，主题色是绿色，页面宽度、卡片样式全都是默认值。跟别人的博客长得一模一样，没啥意思。</p>
<p>所以今天这篇文章，我就来聊聊怎么改配置，让博客变成你自己的风格。</p>

<p></p><figure><img alt="Firefly 博客配置界面" loading="lazy" width="2048" height="990" src="/_astro/cover.QVQ2E4tm_26sNsz.webp" /><figcaption>Firefly 博客配置界面</figcaption></figure><p></p>
<section><h2>配置文件都在哪<a href="#配置文件都在哪"><span>#</span></a></h2><p>Firefly 的所有配置文件都在 <code>src/config/</code> 目录下。打开你的项目，找到这个文件夹，里面一堆 <code>.ts</code> 文件，每个文件管一块功能。</p><p>主要的配置文件有这些：</p><ul>
<li><code>siteConfig.ts</code> — 站点基础配置（标题、描述、主题色、页面宽度等）</li>
<li><code>profileConfig.ts</code> — 个人资料（头像、昵称、简介、社交链接）</li>
<li><code>navBarConfig.ts</code> — 顶部导航栏</li>
<li><code>sidebarConfig.ts</code> — 侧边栏布局</li>
<li><code>commentConfig.ts</code> — 评论系统</li>
<li><code>fontConfig.ts</code> — 字体配置</li>
<li><code>musicConfig.ts</code> — 音乐播放器</li>
</ul><p>看起来挺多的，其实不用一次全改。先把 <code>siteConfig.ts</code> 搞定，其他的以后慢慢调。</p></section>
<section><h2>siteConfig.ts：最核心的配置文件<a href="#siteconfigts最核心的配置文件"><span>#</span></a></h2><p>这个文件管着整个站点的基本信息。打开 <code>src/config/siteConfig.ts</code>，你会看到类似这样的结构：</p><p></p><figure><img alt="siteConfig.ts 配置文件" loading="lazy" width="1405" height="1044" src="/_astro/vscode.C2eTSRrM_Z7WdCh.webp" /><figcaption>siteConfig.ts 配置文件</figcaption></figure><p></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>siteConfig</span><span>:</span><span> </span><span>SiteConfig</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>title</span><span>:</span><span> </span><span>"折腾进行时"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>subtitle</span><span>:</span><span> </span><span>"你的域名"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>site_url</span><span>:</span><span> </span><span>"https://你的域名</span><span>,</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>description</span><span>: </span><span>"生命不息，折腾不止！一个技术博客？"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>  </span><span>// ...</span></div></div><div><div><div>7</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><section><h3>站点标题和副标题<a href="#站点标题和副标题"><span>#</span></a></h3><p><code>title</code> 是浏览器标签页上显示的名字，也是搜索引擎抓取的标题。<code>subtitle</code> 一般显示在首页或者导航栏上。</p><p>这两个改成你自己的就行。比如：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>title</span><span>: </span></span><span>"我的小破站"</span><span>,</span></div></div><div><div><div>2</div></div><div><span><span>subtitle</span><span>: </span></span><span>"随便写写"</span><span>,</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>站点 URL<a href="#站点-url"><span>#</span></a></h3><p><code>site_url</code> 这个一定要改。它影响 sitemap 和 RSS 生成的地址。如果你的域名是 <code>blog.example.com</code>，就填 <code>https://blog.example.com</code>。</p><p>别忘了加 <code>https://</code>，也别加末尾的 <code>/</code>。</p></section><section><h3>主题色<a href="#主题色"><span>#</span></a></h3><p>Firefly 支持自定义主题色。<code>themeColor.hue</code> 是色相值，范围 0-360。</p><p>几个常用的：</p><ul>
<li>红色：0</li>
<li>绿色：120</li>
<li>青色：200</li>
<li>蓝绿色：250</li>
<li>粉色：345</li>
</ul><p>我之前试过 165，是个不错的青绿色。现在改成 65，偏黄一点，看起来更温暖。</p><p><code>defaultMode</code> 控制默认是亮色还是暗色模式：</p><ul>
<li><code>"light"</code> — 亮色</li>
<li><code>"dark"</code> — 暗色</li>
<li><code>"system"</code> — 跟随系统</li>
</ul><p>我选了 <code>"system"</code>，让浏览器自动决定。</p></section><section><h3>页面宽度<a href="#页面宽度"><span>#</span></a></h3><p><code>pageWidth</code> 控制整个页面的最大宽度，单位是 <code>rem</code>。默认 100，数值越大页面越宽。</p><p>如果你觉得内容区域太窄，可以调大一点，比如 120。但太宽了反而不好看，特别是单栏布局的时候。</p></section><section><h3>卡片样式<a href="#卡片样式"><span>#</span></a></h3><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>card</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>border</span><span>: </span><span>true</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>followTheme</span><span>: </span><span>true</span><span>,</span></div></div><div><div><div>4</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>border</code> 控制卡片有没有边框和阴影。开了之后文章卡片会有点立体感。</p><p><code>followTheme</code> 让卡片背景在浅色模式下跟随主题色。这个看你喜好，我开了之后觉得还挺协调的。</p></section></section>
<section><h2>页面开关<a href="#页面开关"><span>#</span></a></h2><p>Firefly 有很多页面功能，比如友链、打赏、相册这些。在 <code>siteConfig.ts</code> 的 <code>pages</code> 对象里可以控制开关：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>pages</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>friends</span><span>: </span><span>true</span><span>,    </span><span>// 友链页面</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>sponsor</span><span>: </span><span>true</span><span>,    </span><span>// 打赏页面</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>guestbook</span><span>: </span><span>true</span><span>,  </span><span>// 留言板</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>bangumi</span><span>: </span><span>true</span><span>,    </span><span>// 番组计划（追番）</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>gallery</span><span>: </span><span>true</span><span>,    </span><span>// 相册</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>dynamic</span><span>: </span><span>true</span><span>,    </span><span>// 动态页面</span></div></div><div><div><div>8</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>设成 <code>false</code> 就会隐藏对应的导航入口，访问那个页面会 404。</p><p>我一开始把 <code>guestbook</code> 关了，因为还没配评论系统。后来配好 Waline 之后又打开了。</p></section>
<section><h2>文章列表布局<a href="#文章列表布局"><span>#</span></a></h2><p>这个挺有意思的。<code>postListLayout</code> 控制文章列表怎么显示：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>postListLayout</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>defaultMode</span><span>: </span><span>"list"</span><span>,      </span><span>// 默认列表模式</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>mobileDefaultMode</span><span>: </span><span>"grid"</span><span>, </span><span>// 移动端用网格模式</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>descriptionLines</span><span>: </span><span>2</span><span>,       </span><span>// 摘要显示2行</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>tagsPosition</span><span>: </span><span>"bottom"</span><span>,    </span><span>// 标签显示在底部</span></div></div><div><div><div>6</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>defaultMode</code> 有两个选择：<code>"list"</code> 是单列列表，<code>"grid"</code> 是多列网格。我用了 <code>"list"</code>，在桌面端看比较舒服。</p><p><code>mobileDefaultMode</code> 是移动端的默认布局。我设了 <code>"grid"</code>，手机上看起来更紧凑。</p><p><code>tagsPosition</code> 控制标签显示位置。设成 <code>"bottom"</code> 的话标签会显示在卡片底部，替代统计数据的位置。</p><section><h3>文章卡片信息控制<a href="#文章卡片信息控制"><span>#</span></a></h3><p>下面还有 <code>meta</code> 和 <code>stats</code> 两个对象，控制文章卡片上显示哪些信息：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>meta</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>showPublished</span><span>: </span><span>true</span><span>,  </span><span>// 发布日期</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>showCategory</span><span>: </span><span>true</span><span>,   </span><span>// 分类</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>showTags</span><span>: </span><span>true</span><span>,       </span><span>// 标签</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>showWords</span><span>: </span><span>true</span><span>,      </span><span>// 字数</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>showReadingTime</span><span>: </span><span>false</span><span>, </span><span>// 阅读时间</span></div></div><div><div><div>7</div></div><div><span>},</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>我关掉了 <code>showReadingTime</code>，因为觉得字数已经够了，再显示阅读时间有点冗余。</p></section></section>
<section><h2>其他重要配置<a href="#其他重要配置"><span>#</span></a></h2><section><h3>profileConfig.ts：个人资料<a href="#profileconfigts个人资料"><span>#</span></a></h3><p>这个文件管你的头像、昵称、简介和社交链接。打开 <code>src/config/profileConfig.ts</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>profileConfig</span><span>:</span><span> </span><span>ProfileConfig</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>avatar</span><span>:</span><span> </span><span>"./avatar.webp"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>name</span><span>:</span><span> </span><span>"你的名字"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>bio</span><span>:</span><span> </span><span>"你的简介"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>socialLinks</span><span>:</span><span> [</span></div></div><div><div><div>6</div></div><div><span>    </span><span>// 社交链接配置</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>],</span></div></div><div><div><div>8</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>avatar</code> 放你的头像图片路径。图片可以放在 <code>public/</code> 目录下，也可以放在 <code>src/assets/</code> 下（后者会自动优化）。</p></section><section><h3>navBarConfig.ts：导航栏<a href="#navbarconfigts导航栏"><span>#</span></a></h3><p>导航栏的配置在这个文件里。可以设置导航链接、Logo、是否全宽等。</p><p>我比较喜欢全宽导航栏，所以设了 <code>widthFull: true</code>。</p></section><section><h3>fontConfig.ts：字体<a href="#fontconfigts字体"><span>#</span></a></h3><p>Firefly 支持自定义字体。你可以用 Google Fonts、Fontsource，或者本地字体。</p><p>但说实话，默认字体已经挺好看了，我暂时没改。</p></section><section><h3>commentConfig.ts：评论系统<a href="#commentconfigts评论系统"><span>#</span></a></h3><p>这个文件管评论系统的配置。Firefly 支持 Twikoo、Waline、Giscus、Artalk 等。</p><p>我之前写过一篇 <a href="/posts/deploy/deploywaline/">搭建 Waline 评论系统</a> 的文章，里面有详细配置。</p></section><section><h3>musicConfig.ts：音乐播放器<a href="#musicconfigts音乐播放器"><span>#</span></a></h3><p>如果你想在博客里加背景音乐，可以配置这个文件。支持 Meting API（在线音乐）和本地音乐。</p><p>我暂时没开，怕影响加载速度。</p></section></section>
<section><h2>怎么测试配置效果<a href="#怎么测试配置效果"><span>#</span></a></h2><p>改完配置后，本地跑一下就能看到效果：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm</span><span> </span><span>dev</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>浏览器打开 <code>http://localhost:4321</code>，刷新就能看到新的配置。</p><p>改主题色、页面宽度这些，刷新就能看到。改导航栏、侧边栏这些，可能需要重启一下开发服务器。</p><p>我一般改完配置会跑一下 <code>pnpm build</code>，确保没有语法错误。如果构建失败了，报错信息会告诉你哪个文件哪一行有问题。</p></section>
<section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><section><h3>site_url 没改导致的问题<a href="#site_url-没改导致的问题"><span>#</span></a></h3><p>我一开始忘了改 <code>site_url</code>，结果 sitemap 生成的地址是默认的 <code>https://firefly.cuteleaf.cn</code>。提交给 Google Search Console 的时候，验证一直不通过。</p><p>后来才发现是这个配置没改。改完重新构建就好了。</p></section><section><h3>图片路径搞混<a href="#图片路径搞混"><span>#</span></a></h3><p>Firefly 支持两种图片路径：<code>public/</code> 目录下的和 <code>src/</code> 目录下的。</p><p><code>public/</code> 下的图片不经过优化，直接复制到构建输出。<code>src/</code> 下的图片会自动压缩、转换格式。</p><p>我一开始把头像放在 <code>public/</code> 下，结果加载比较慢。后来移到 <code>src/assets/</code> 下，构建时自动压缩了，加载快了不少。</p></section><section><h3>主题色改了但没生效<a href="#主题色改了但没生效"><span>#</span></a></h3><p>有次改了 <code>themeColor.hue</code>，刷新页面发现没变化。后来发现是浏览器缓存的问题。清一下缓存或者用无痕模式就能看到。</p></section></section>
<section><h2>配置参考<a href="#配置参考"><span>#</span></a></h2><p>配置文件挺多的，不可能一次全记住。官方文档写得比较全：<a href="https://docs-firefly.cuteleaf.cn/zh/guide/site.html" target="_blank">Firefly 站点配置</a></p><p>我建议先把 <code>siteConfig.ts</code> 搞定，其他的用到了再去文档里查。每个配置项都有注释，看着改就行。</p></section>
<section><h2>写在后面<a href="#写在后面"><span>#</span></a></h2><p>配置这个东西，其实不用一次全改完。</p><p>先把基本的改了，标题、副标题、主题色这些。其他的用到了再慢慢调。反正改配置又不花钱，多试几次就知道什么好看了。</p><p>说实话，折腾配置本身也是种乐趣。看着自己的博客一点点变成想要的样子，还挺有成就感的。</p><p>后面我可能会写一篇关于自定义 CSS 样式的文章，让博客变得更不一样。如果你也想折腾，可以关注一下。</p><p>有问题的话直接在评论区问，我看到了会回复。</p><hr /><p><em>写于 2026 年 7 月，折腾 Firefly 博客配置的记录</em></p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/recommend/rinne-mobile-recommendation/</id>
      <title type="text">手机上终于有个像样的 Galgame 管理器了</title>
      <published>2026-07-25T00:00:00.000Z</published>
      <updated>2026-08-26T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/recommend/rinne-mobile-recommendation/"/>
      <summary type="text">RinneMobile 是一款手机端 Galgame 管理器，支持 KRKR、ONScripter 等多种引擎，统一管理本地游戏和应用，还能通过 WebDAV 云同步数据。</summary>
      <content type="html"><![CDATA[<section><h1>手机上终于有个像样的 Galgame 管理器了<a href="#手机上终于有个像样的-galgame-管理器了"><span>#</span></a></h1><p>前几天刷 GitHub 的时候，无意间看到一个叫 RinneMobile 的项目。</p><p><a href="https://github.com/Weiss-UltimateSavior/RinneMobile" target="_blank">https://github.com/Weiss-UltimateSavior/RinneMobile</a></p><p></p><figure><img alt="RinneMobile 界面" loading="lazy" width="920" height="2048" src="/_astro/theme1.ChPmSxLG_1aT50a.webp" /><figcaption>RinneMobile 界面</figcaption></figure><p></p><section><h2>为什么觉得它有意思<a href="#为什么觉得它有意思"><span>#</span></a></h2><p>我手机里存了不少 Galgame，但一直没找到好用的管理方式。</p><p>以前试过直接用文件管理器找游戏目录，每次想玩的时候都要翻半天。文件管理器这东西吧，找文档还行，找游戏是真的痛苦——游戏目录名乱七八糟，有的是日文，有的是拼音缩写，根本不知道哪个是哪个。</p><p>也试过几个启动器。有的只能启动安卓应用，KRKR 引擎的游戏完全不支持。有的倒是能启动，但是每次都要手动选目录，麻烦得要死。</p><p>还试过把所有游戏 shortcut 放到一个文件夹里，结果越攒越多，找起来更费劲了。</p><p>RinneMobile 说白了就是个”游戏库”——把你的本地游戏、安卓应用、模拟器入口、外部程序快捷方式全放到一个地方管理。</p><p>听起来简单，但市面上能做到这点的安卓工具还真不多。</p></section><section><h2>它能干什么<a href="#它能干什么"><span>#</span></a></h2><p>我看了下 README，功能还挺全的。</p><section><h3>统一管理<a href="#统一管理"><span>#</span></a></h3><p>本地游戏、安卓应用、外部启动入口都能加进去。</p><p>这点太重要了。我手机里不光有 KRKR 引擎的游戏，还有一些安卓原生的 Galgame，还有几个通过 Winlator 跑的 PC 游戏。以前要分别用不同的方式启动，现在全放一个地方，方便多了。</p><p></p><figure><img alt="游戏库页面" loading="lazy" width="920" height="2048" src="/_astro/game-page.Cr6LM1Xp_29Vcb9.webp" /><figcaption>游戏库页面</figcaption></figure><p></p></section><section><h3>自动扫描<a href="#自动扫描"><span>#</span></a></h3><p>选个目录，它会自动识别 KRKR、ONScripter、Tyrano、Artemis 这些引擎的游戏。</p><p>不用一个个手动添加，这点很省事。它会根据文件特征来判断引擎类型：</p><ul>
<li>KRKR：看有没有 <code>.xp3</code>、<code>startup.tjs</code> 这些文件</li>
<li>ONScripter：看 <code>0.txt</code>、<code>nscript.dat</code> 这些</li>
<li>Tyrano：看 <code>index.html</code> 和目录特征</li>
<li>Artemis：看 <code>system.ini</code> 这些</li>
</ul><p>识别出来之后，启动的时候会自动用对应的引擎打开。</p></section><section><h3>VNDB/Bangumi 数据源<a href="#vndbbangumi-数据源"><span>#</span></a></h3><p>可以给游戏补充封面和信息。</p><p>这个功能挺贴心的。添加游戏之后，可以关联 VNDB 或者 Bangumi 的数据，自动拉取封面图和游戏简介。这样游戏库看起来就整齐多了，不用自己一张张找封面。</p></section><section><h3>数据同步<a href="#数据同步"><span>#</span></a></h3><p>支持 WebDAV 云同步，换设备不怕丢数据。</p><p>我之前最怕的就是换手机。每次换手机，游戏存档、游玩记录全没了。RinneMobile 支持把数据同步到 WebDAV，换设备的时候导进去就行。</p><p>它同步的时候会根据 root 路径、本地 ID、游戏标题来匹配，空目录条目会优先按标题匹配。虽然不是 100% 能对上，但大部分情况够用了。</p></section><section><h3>游戏时长记录<a href="#游戏时长记录"><span>#</span></a></h3><p>启动游戏后自动计时，切回来就停止。</p><p>这个功能对我来说还挺有用的。我之前一直想统计自己玩 Galgame 花了多少时间，但没有趁手的工具。RinneMobile 自动帮我记，虽然不是特别精确（中途切出去再切回来就不续计了），但大致有个数。</p></section><section><h3>AI 智能体<a href="#ai-智能体"><span>#</span></a></h3><p>最让我意外的是它内置了一个 AI 智能体，可以连自己的 OpenAI 兼容 API。</p><p>比如查询游戏库、搜索文件之类的。这个功能我还没深入试，但听起来挺有意思的——以后说不定可以让 AI 帮我找某个台词在哪个游戏里出现过。</p></section></section><section><h2>适合什么人<a href="#适合什么人"><span>#</span></a></h2><p>如果你是这类用户，可以看看：</p><ul>
<li>手机里存了一堆 Galgame，想统一管理</li>
<li>用 KRKR、ONScripter 这些引擎的游戏比较多</li>
<li>想在多设备之间同步游戏数据</li>
<li>喜欢折腾，愿意自己配置</li>
</ul><p>如果你只是偶尔玩一两个安卓版 Galgame，可能用不上这么重的工具。</p><p></p><figure><img alt="设置页面" loading="lazy" width="920" height="2048" src="/_astro/settings-page.CH4cKgVH_Z2j5iEK.webp" /><figcaption>设置页面</figcaption></figure><p></p></section><section><h2>几个细节<a href="#几个细节"><span>#</span></a></h2><section><h3>项目背景<a href="#项目背景"><span>#</span></a></h3><p>这是基于 YukiHub 二次开发的分支版本，不是从零开始的，站在前人肩膀上。</p><p>作者叫 Weiss-UltimateSavior，GitHub 上 97 个 Star，不算多，但说明有人在用。有 QQ 交流群，遇到问题可以问。</p></section><section><h3>协议和兼容性<a href="#协议和兼容性"><span>#</span></a></h3><p>GPL-3.0 协议，开源，可以自己改。</p><p>需要 Android 8.0 以上，老设备可能跑不了。我看了下编译信息，Min SDK 是 26，Target SDK 是 33，用的 Java 17。</p></section><section><h3>已知问题<a href="#已知问题"><span>#</span></a></h3><p>README 里提到了几个已知问题：</p><ul>
<li>KRKR 游戏里有文字输入弹窗的功能不能用，会导致闪退</li>
<li>TF 卡里的 KRKR 游戏要通过镜像目录启动</li>
<li>华为设备可能有存储访问兼容性问题，可以试试开启”外部私有存档”</li>
</ul><p>这些坑我还没踩到，但提前知道比较好。</p></section><section><h3>界面<a href="#界面"><span>#</span></a></h3><p>支持深色模式和横屏，这点挺加分的。</p><p>我挺喜欢它的界面设计，有好几个主题可以切换。竖屏横屏都能用，躺在床上玩的时候横屏模式挺舒服的。</p><p></p><figure><img alt="深色模式" loading="lazy" width="920" height="2048" src="/_astro/dark-mode.DchdM73K_UhER2.webp" /><figcaption>深色模式</figcaption></figure><p></p><p></p><figure><img alt="横屏模式" loading="lazy" width="2048" height="920" src="/_astro/landscape-home.B4h9RdX0_Z1ijpnd.webp" /><figcaption>横屏模式</figcaption></figure><p></p></section></section><section><h2>和其他工具的对比<a href="#和其他工具的对比"><span>#</span></a></h2><p>安卓上能启动 Galgame 的工具其实有不少，但大多数只管”启动”，不管”管理”。</p><p>比如 Tyranor，能启动 KRKR 和 Tyrano 引擎的游戏，但没有统一的游戏库，也没有数据同步。每次想玩都要翻文件夹。</p><p>再比如一些通用的游戏启动器，倒是能管理，但对 Galgame 引擎的支持不行，KRKR、ONScripter 这些都认不出来。</p><p>RinneMobile 的优势就在于”管理+启动+同步”三合一。虽然每个方面都不是最强的，但凑在一起，体验就完整多了。</p></section><section><h2>我的判断<a href="#我的判断"><span>#</span></a></h2><p>说实话，这个项目解决的问题确实存在——安卓上缺少好用的 Galgame 管理工具。</p><p>如果你有这个需求，值得试试。</p><p>它不是那种开箱即用的工具，需要自己配置一下。但折腾本来就是乐趣的一部分，对吧？</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/life/internship/internship/</id>
      <title type="text">站长实习经历</title>
      <published>2026-07-24T00:00:00.000Z</published>
      <updated>2026-08-26T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/life/internship/internship/"/>
      <summary type="text">释怀的似了</summary>
      <content type="html"><![CDATA[<hr />
<p>去年 12 月中旬，我随便找了一家中介，进入了一家做自动化视觉的小公司，成为了一名视觉调试实习生。</p>
<p>刚开始的时候，我的想法很简单：</p>
<p>实习嘛，能学点东西，最后拿到一份实习证明，就已经不错了。</p>
<p>但没想到，这五个月里，我几乎把一个新人能经历的事情都经历了一遍。</p>
<p>从培训结束后直接被派到客户现场，到项目负责人调走、师傅辞职，最后只剩下我这个刚入行不到一个月的实习生，一个人负责整个项目。</p>
<p>从每天 12 小时以上的工作，到 24 小时待机。</p>
<p>从每天被客户吊，到后来能够独立解决问题。</p>
<p>最后，我还是离开了。</p>
<p>不是因为我做不下去，而是我终于发现：</p>
<p><strong>比起高工资，我其实更喜欢稳定一点的生活。</strong></p>
<hr />
<section><h2>先说说这家公司<a href="#先说说这家公司"><span>#</span></a></h2><p>这是一家专门做自动化视觉的小公司，主要负责视觉软件开发和现场调试。</p><p>按我的理解，应该算是二级供应商。</p><p>其他自动化公司承接整机项目后，会把视觉部分外包给我们。</p><p>公司规模不算大，实缴社保人数估计也就二十多人，但长期都在招人。</p><p>原因也很简单：</p><p><strong>人员流动率实在太高了。</strong></p><p>我在职期间，陆陆续续来了几十个新人，真正留下来的估计只有几个。</p><p>为什么？</p><p>因为太累了。</p><p>工作时间长，长期出差，12 小时工作制基本是常态。</p><p>有时候，所谓的“下班”也只是从现场离开而已。</p><p>客户一个电话打过来，还是得继续处理问题。</p><hr /></section>
<section><h2>培训结束，直接进入现场<a href="#培训结束直接进入现场"><span>#</span></a></h2><p>刚入职的时候，公司先安排了几天培训，主要学习他们基于 VisionMaster 开发的二开软件。</p><p>刚开始确实觉得有点陌生，不过学了三四天之后，基本就能上手了。</p><p>培训结束后，我休息了几天，很快就被派到了客户现场。</p><p>刚到现场的时候，其实还算挺舒服。</p><p>虽然我很紧张，担心自己能不能做好，但至少当时还有师傅带着。</p><p>我跟着师傅看了两三天流程，结果客户突然没料生产了。</p><p>于是接下来接近两个星期，我每天的主要工作就是跟着师傅玩手机。</p><p>现在回头看，那段时间是真的爽。</p><p>但问题也正是在这里产生的。</p><p>真正接触流程的时间只有那几天，后面天天摸鱼，反而导致很多东西慢慢忘了。</p><p>然后，真正大的问题来了。</p><p><strong>师傅辞职了。</strong></p><p>更离谱的是，项目负责人在师傅辞职前三四天，又被调去了其他项目。</p><p>原本整个项目总共就三个人：</p><ul>
<li>项目负责人：调走了</li>
<li>师傅：辞职了</li>
<li>我：0 经验实习生</li>
</ul><p>最后，真正留在现场的只有我一个。</p><p>当时的感觉非常简单：</p><p><strong>天黑了。</strong></p><hr /></section>
<section><h2>一个人硬扛整个项目<a href="#一个人硬扛整个项目"><span>#</span></a></h2><p>1 月初，客户开始正式来料生产。</p><p>没有师傅。</p><p>没有负责人。</p><p>只能靠自己。</p><p>什么问题都得自己解决。</p><p>一些小问题还能靠培训时学到的东西，再结合自己的理解，一点一点试出来。</p><p>虽然慢，而且天天被客户吊，但至少还能解决。</p><p>真正不会的问题，只能远程找公司开发大哥。</p><p>但他也不是随叫随到。</p><p>有时候在他看来比较简单的问题，甚至会直接装死不回复。</p><p>于是，我基本上变成了一个人在现场孤立无援。</p><p>白天解决问题，晚上继续研究。</p><p>这一行本来 12 小时工作制就是常态，而我那段时间经常是 <strong>12 小时以上</strong>。</p><p>更离谱的是，下班以后也不算真正结束。</p><p>客户随时可能打电话过来。</p><p>远程解决现场问题，逐渐变成了日常。</p><p>基本属于：</p><p><strong>24 小时待机。</strong></p><section><h3>客户甚至开始让我教人<a href="#客户甚至开始让我教人"><span>#</span></a></h3><p>客户现场还有几个机修。</p><p>他们后来直接让我去教他们怎么使用我们公司的软件。</p><p>问题是……</p><p>我自己都还是个新人。</p><p>不过也是在这段时间里，我认识了一个比我大两岁的机修。</p><p>他主要负责跟这台设备，也是我在现场关系最好的人。</p><p>他很多东西都是我后来一点一点教会的。</p><p>慢慢熟悉之后，我们基本什么都聊。</p><p>他也玩《部落冲突》。</p><p>直到现在，他还是我部落里的副首领。</p></section><section><h3>PLC 的工程师看不下去了<a href="#plc-的工程师看不下去了"><span>#</span></a></h3><p>现场负责 PLC 的工程师，看着我这个样子都绷不住了。</p><p>他天天吐槽我们公司：</p><blockquote><p>怎么会让一个 0 经验的人，一个人负责整个项目？</p></blockquote><p>其实我也觉得。</p><p>如果公司稍微有点实力，应该也不会发生这种事。</p><p>刚开始和他沟通的时候，其实特别痛苦。</p><p>很多东西我根本听不懂，他有时候也会不耐烦。</p><p>不过随着经验慢慢积累，后来交流就顺畅多了。</p><p>甚至后来经常一起提前下班，然后去网吧。</p></section><section><h3>客户其实没有想象中那么可怕<a href="#客户其实没有想象中那么可怕"><span>#</span></a></h3><p>现在回头看，客户现场的大部分人其实都挺好的。</p><p>他们不会轻易吊人。</p><p>真正喜欢找茬的，只有他们其中一个领导。</p><p>属于那种：</p><p><strong>你没问题，他也能给你找点问题出来吊一顿。</strong></p><hr /></section></section>
<section><h2>我为什么一直没有辞职？<a href="#我为什么一直没有辞职"><span>#</span></a></h2><p>其实那段时间，我想过很多次辞职。</p><p>但每次想到这里，又<strong>特别不服</strong>。</p><p>我一直觉得：</p><p>这些问题看起来并没有难到完全解决不了。</p><p>我总觉得自己应该把它啃下来。</p><p>至于实习证明，反而不是主要原因。</p><p>我有几个朋友家里都有公司，如果只是为了盖一个实习章，其实完全不是问题。</p><p>也许可能还有所谓的沉没成本？</p><p>也可能就是年轻，不服输。</p><blockquote><p>那段时间我还喜欢玩 Galgame，也补完了《凉宫春日》。</p></blockquote><blockquote><p>但每次看到一半，电话突然响起来。</p></blockquote><blockquote><p>真的会心脏骤停。</p></blockquote><hr /></section>
<section><h2>一月和二月：最难熬的时候<a href="#一月和二月最难熬的时候"><span>#</span></a></h2><p>整个一月和二月，客户的生产计划都非常赶。</p><p>每天的生活几乎都在重复：</p><p>睁眼，干活，被吊，解决问题，然后继续被吊。</p><p>晚上躺床上睡觉，第二天继续。</p><p>当时我的经验实在太少，很多问题只能一点一点试。</p><p>公司中途也派过两三次人过来支援，客户甚至已经投诉到了大老板那里。</p><p>但每次负责人过来也只待一两天。</p><p>最后，还是只能靠自己。</p><p>现在回头看，其实很多问题并没有多复杂。</p><p>只是对于当时那个刚接触自动化不到一个月的我来说，几乎什么都是第一次。</p><hr /></section>
<section><h2>三月：世界突然亮了<a href="#三月世界突然亮了"><span>#</span></a></h2><p>到了三月，经验终于慢慢积累起来。</p><p>我已经能够独立解决不少问题，也能帮客户修改一些简单功能。</p><p>和 PLC 工程师的沟通也越来越顺畅。</p><p>现场终于没有以前那么压抑了。</p><p>后来甚至经常和 PLC 哥们提前下班，一起去网吧。</p><p>还有一个很离谱的事情。</p><p>客户是大厂，供应商需要定期续签通行证。</p><p>偏偏我们公司经常忘记。</p><p>于是一个月里经常有一两天，甚至三四天进不了厂。</p><p>这种时候怎么办？</p><p>不用上班。</p><p>直接和 PLC 去网吧。</p><section><h3>客户突然没料了<a href="#客户突然没料了"><span>#</span></a></h3><p>三月中旬开始，客户突然没料了。</p><p>整个项目一下子闲了下来。</p><p>不用进车间，每天就在楼下玩手机。</p><p>晚上也不用加班，下午五点准时下班。</p><p>我第一次觉得：</p><p><strong>原来世界还能这么美好。</strong></p><p>月底虽然又恢复生产了几天，但设备问题已经不多了。</p><p>只是每次收到“恢复生产”的消息时，我心里还是会咯噔一下。</p><p>因为我真的有点害怕重新回到一月、二月那种生活。</p><hr /></section></section>
<section><h2>四月：正式进入养老模式<a href="#四月正式进入养老模式"><span>#</span></a></h2><p>整个四月，我几乎没什么印象。</p><p>因为真的太轻松了。</p><p>设备几乎不开。</p><p>公司一直催客户验收，客户一直拖，公司也一直拿不到尾款。</p><p>这种状态一直持续到我离职。</p><p>设备都没怎么重新开起来。</p><hr /></section>
<section><h2>五月：我决定离开<a href="#五月我决定离开"><span>#</span></a></h2><p>5 月 17 日，我正式辞职。</p><p>我离职是因为太累？太辛苦？</p><p>如果只是怕辛苦，我一月、二月的时候就已经跑路了。</p><p>真正让我决定离开的，是长期出差。</p><p>入行之前，我知道这一行一年可能有三百天都在外面。</p><p>当时我觉得：</p><p><strong>在哪住不是住？无所谓。</strong></p><p>真正干了几个月之后，我才发现完全不是这样。</p><p>一直住公司租的破房子。</p><p>房间很破。</p><p>下班以后，连一个舒服一点的环境都没有。</p><p>我只是想拥有一个属于自己的房间。</p><p>一台高配电脑。</p><p>一个好一点的显示器。</p><p>一套舒服的外设。</p><p>下班以后打一会儿游戏，看看番，敲敲代码。</p><p>仅此而已。</p><p>但长期漂泊的生活，很难让我拥有这些。</p><p>因为你永远不知道：</p><p>什么时候换项目。</p><p>什么时候换城市。</p><p>下一个住宿环境怎么样。</p><p>下一个项目的工作量会不会更离谱。</p><p>后来我才发现。</p><p><strong>比起高工资，我其实更喜欢稳定。</strong></p><hr /></section>
<section><h1>实习期间印象最深的几件事<a href="#实习期间印象最深的几件事"><span>#</span></a></h1><section><h2>相机掉线<a href="#相机掉线"><span>#</span></a></h2><p>有几个工位的相机特别邪门。</p><p>只要设备停机一两天以上，再重新开机，就必定掉线。</p><p>解决办法也很原始：</p><p><strong>拔一下线，再插回去。</strong></p><p>然后就好了。</p><p>但问题是：</p><p><strong>为什么会这样，没人知道。</strong></p><p>软件 Log 全部正常。</p><p>线材看起来也不像坏的。</p><p>设备如果一直连续生产，又完全不会出问题。</p><p>我一开始还以为这是我接手之后才出现的问题。</p><p>后来听客户和 PLC 工程师聊天才知道，这个问题在我来之前就已经存在了。</p><p>一直没人查出来。</p><p>直到我 7 月退客户群的时候，这个问题都还在。</p><hr /></section><section><h2>漏检劣质品<a href="#漏检劣质品"><span>#</span></a></h2><p>客户前段工艺会先检测一遍产品。</p><p>有问题的产品会被贴上黄色标签，但不会直接剔除。</p><p>真正的剔除工作是在我们这台设备完成的。</p><p>视觉检测到以后，再通知 PLC 进行剔除。</p><p>理论上来说，这一步不应该漏。</p><p>但实际上，偶尔真的会漏。</p><p>我第一次遇到漏检的时候，影响不算太大。</p><p>客户只是让我们排查原因。</p><p>后来听说是客户的一个机修，直接跟质检说问题已经解决了。</p><p>这件事也就糊弄过去了。</p><p>结果过了一段时间，又漏了一片。</p><p>这次直接天塌了。</p><p>客户立刻拉会议，开始排查所有风险。</p><p>已经发货的产品都在讨论要不要半路拦截，甚至考虑整批报废。</p><p>当天晚上一直加班到 12 点排查。</p><p>但最后，其实也没有找到真正的原因。</p><section><h3>解决问题<a href="#解决问题"><span>#</span></a></h3><p>后来客户决定临时启用两台一直没开的相机，再增加一道检测。</p><p>整个检测流程基本都是我自己设计、自己调试、自己测试出来的。</p><p>虽然因为是临时改造，打光条件其实很差。</p><p>但至少能够正常跑起来。</p><p>当时我还挺开心。</p><p>因为这是我第一次独立完成一个新的检测流程。</p><p>后来我才知道。</p><p>这个漏检问题，其实在我来之前就已经存在了。</p><hr /></section></section><section><h2>第一次完全靠自己解决问题<a href="#第一次完全靠自己解决问题"><span>#</span></a></h2><p>有一次，设备一直报超时。</p><p>各种数据看起来都正常。</p><p>但软件就是特别卡。</p><p>一旦卡住，所有数据都会开始乱。</p><p>而且特别玄学。</p><p>一会儿正常，一会儿又卡。</p><p>我只能一直盯着任务管理器。</p><p>后来我发现，每次软件卡住的时候，D 盘都会被占满。</p><p>奇怪的是，带宽只有十几 MB/s。</p><p>看起来根本不像磁盘跑满。</p><p>于是我又打开资源监视器。</p><p>发现有一个 Windows 系统进程一直在疯狂读取机械硬盘。</p><p>后来查资料才知道，它应该是在建立或者更新文件索引。</p><p>顺序读写问题不大。</p><p>但随机读写对机械硬盘来说，就完全是另一回事了。</p><p>于是我去找客户沟通。</p><p>客户又联系了他们公司的 IT。</p><p>IT 第一反应还挺无语：</p><blockquote><p>就这点带宽，也能把软件卡死？</p></blockquote><p>我只能解释：</p><p><strong>这是机械硬盘，不是固态硬盘。随机读写根本不能只看带宽。</strong></p><p>后来他问我：</p><blockquote><p>那你觉得限速多少合适？</p></blockquote><p>我还认真算了一下。</p><p>按照一天产生的图片数量，再加上一点冗余，最后建议限制到 <strong>2 MB/s</strong>。</p><p>不过后来发现，只要索引过程跑完，软件就会恢复正常。</p><p>最后，我直接把软件迁移到了 C 盘 SSD。</p><p>问题立刻缓解了很多。</p><p>那时候其实挺开心的。</p><p>因为这是整个实习期间，第一个完全靠我自己定位、分析，并最终解决的问题。</p><p>其实还有一次。</p><p>我排查硬件的时候，发现一块机械硬盘已经出现了坏道。</p><p>我把情况反馈给公司。</p><p>公司选择不管。</p><p>当时已经被各种问题整红温了。</p><p>哈哈哈。</p><p>这件事最后也只有我和公司知道。</p><hr /></section><section><h2>凌晨一点的批斗大会<a href="#凌晨一点的批斗大会"><span>#</span></a></h2><p>应该是二月下旬。</p><p>晚上十一点，经理突然打电话，让我立刻去现场。</p><p>客户已经投诉到大老板那里了。</p><p>平时公司连打车都不给报。</p><p>我每天都是走路往返一公里多。</p><p>但这次，经理直接让我打车过去。</p><p>我到客户公司时已经十一点二十。</p><p>因为客户流程特别严格。</p><p>再着急，也必须等客户的人来接才能进去。</p><p>真正到车间的时候，已经快十一点四十了。</p><p>经理和负责人在电话里告诉我：</p><p><strong>十二点要和客户开会。</strong></p><p>让我赶紧找一些证据，证明问题不一定是视觉导致的。</p><p>现在回头想，他们更多只是想先找一点理由，为后面的沟通争取时间。</p><p>后来我找了一些图片和现象，说明视觉只是检测出了问题。</p><p>真正导致问题的，未必是视觉。</p><p>也可能是机构或者其他环节。</p><p>经理后来有一句经典名言，我到现在还记得：</p><blockquote><p><strong>我们视觉只能做检测优劣，不能直接参与产品的好坏。</strong></p></blockquote><p>虽然很多时候他说这句话只是为了拉扯。</p><p>但确实挺经典。</p><p>会议结束已经一点多。</p><p>然后继续和开发远程查问题。</p><p>一直折腾到凌晨四点多。</p><p>我回到出租屋的时候，已经五点了。</p><p>睡了一会儿。</p><p>中午十二点继续上班。</p><hr /></section><section><h2>…</h2><p>其实整个实习期间，我一直觉得自己的压力没那么大。</p><p>因为我知道。</p><p>真干不下去的话，可以回家。</p><p>也可以换工作。</p><p>家里没有什么经济压力。</p><p>我是单亲家庭，父亲工作稳定，以后退休金可能都比我工资高。</p><p>只要自己别过度内耗，其实生活不会太差。</p><p>可是就在那天凌晨。</p><p>我走在回出租屋的那条特别窄的小巷里。</p><p>突然哭了。</p><p>没有任何征兆。</p><p>也没有任何情绪铺垫。</p><p>眼泪就是一下子流了下来。</p><p>连我自己都愣住了。</p><hr /></section><section><h2>一次倒班乌龙<a href="#一次倒班乌龙"><span>#</span></a></h2><p>有一次，客户工程师早上给我打电话。</p><p>我还以为设备坏了。</p><p>结果他说：</p><p>“今天不用上早班，直接倒夜班。”</p><p>因为夜班机修太少，希望我过去帮忙。</p><p>我觉得挺合理。</p><p>于是下午特地开了免打扰，睡了一觉。</p><p>睡到三点多醒来。</p><p>发现手机里全是负责人打来的未接电话。</p><p>我赶紧回过去。</p><p>结果第一句话就是：</p><p><strong>“你怎么没来上班？”</strong></p><p>我一脸懵。</p><p>反问：</p><p><strong>“客户没通知你吗？他说让我上夜班。”</strong></p><p>负责人听完直接无语。</p><p>让我马上过去正常上班。</p><p>后来想想也正常。</p><p>他的新项目特别忙，根本没有时间替我远程值班。</p><p>于是我白白折腾了一下午。</p><p>最后还是正常去上班。</p><hr /></section><section><h2>一次失败的闪电调岗<a href="#一次失败的闪电调岗"><span>#</span></a></h2><p>四月底，公司准备把我调去福建支援新项目。</p><p>经理在下班路上给我打电话，让我立刻买后天的票。</p><p>我也买了。</p><p>后来我和客户说了一下。</p><p>客户直接震惊。</p><p>然后特别自信地说：</p><blockquote><p>你放心。</p><p>你走不了。</p><p>这台设备还有这么多问题。</p><p>我待会就给你经理打电话。</p></blockquote><p>其实我当时也觉得，大概率走不了。</p><p>结果第二天。</p><p>老板亲自给我打电话。</p><p>叹了口气。</p><p>让我把票退了。</p><p>手续费公司报销。</p><p>最后，福建没去成。</p><p>我继续留在原来的项目。</p><hr /></section></section>
<section><h1>写在最后<a href="#写在最后"><span>#</span></a></h1><p>这份实习只有五个月。</p><p>但它也是我真正意义上的第一份工作。</p><p>也是我第一次真正接触自动化、客户现场和项目。</p><p>如果现在再问我：</p><p><strong>还会不会去做这份实习？</strong></p><p>我想，我还是会。</p><p>不是因为它有多好。</p><p>而是很多东西，只有自己一个人在现场扛过之后，才能真正学会。</p><p>只是如果让我继续做这一行。</p><p>我应该不会了。</p><p>我还是更喜欢稳定一点的生活。</p><p>下班以后，有自己的房间。</p><p>有自己的电脑。</p><p>有一点属于自己的时间。</p><p>对现在的我来说。</p><p><strong>这就够了。</strong></p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/recommend/sillytavern/</id>
      <title type="text">SillyTavern（酒馆）无限制 AI 角色对话部署指南</title>
      <published>2026-07-24T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/recommend/sillytavern/"/>
      <summary type="text">开源 AI 角色对话前端 SillyTavern 部署教程，支持 OpenAI、Claude、Gemini 等多种模型，本地运行数据自主，角色扮演和写作辅助都能玩。</summary>
      <content type="html"><![CDATA[<p>之前一直用各种在线平台跑 AI 角色对话，说实话大部分都差点意思——要么限制多，要么数据不在自己手里，折腾起来也不方便。后来在群里看到有人提”酒馆”，说是开源的，自己部署，想怎么玩就怎么玩。我一想，反正也不难搞，就试试呗。</p>
<p>折腾了一番，把部署过程和踩过的坑记下来，顺便也介绍一下这个东西。</p>
<section><h2>酒馆是个啥<a href="#酒馆是个啥"><span>#</span></a></h2><p>SillyTavern，社区里一般叫”酒馆”，是个开源的网页应用。简单来说，它就是一个前端界面，帮你对接各种 AI 模型（OpenAI、Claude、Gemini、本地模型等等），然后你通过浏览器跟 AI 聊天。</p><p>它本身不提供 AI 模型，你需要自己有 API Key 或者跑本地模型。但它的好处是：</p><ul>
<li>数据全在本地，不经过第三方</li>
<li>可以自由调整各种参数和提示词</li>
<li>支持角色扮演、写作辅助等多种玩法</li>
<li>社区活跃，功能一直在更新</li>
</ul><p>酒馆跑起来之后，浏览器打开 <code>127.0.0.1:8000</code> 就能用。</p><section><h3>它能干什么<a href="#它能干什么"><span>#</span></a></h3><p>除了基本的聊天，酒馆还能玩不少花样：</p><ul>
<li><strong>角色卡片</strong>：导入别人做好的角色卡，或者自己写一个，AI 就会扮演那个角色跟你对话</li>
<li><strong>预设和破限</strong>：通过预设文件控制 AI 的回复风格，比如更像小说、更口语化，或者突破一些限制</li>
<li><strong>世界书</strong>：给 AI 补充背景知识，比如角色的设定、世界观什么的</li>
<li><strong>群聊</strong>：多个角色一起聊天，有点像拉了个群</li>
<li><strong>正则替换</strong>：自定义消息的处理规则，比如隐藏 AI 的思考过程</li>
<li><strong>扩展功能</strong>：支持画图、语音合成、翻译等扩展，社区一直在加新东西</li>
</ul><p>不过这些进阶功能先不管，部署好能聊天才是第一步。</p></section></section>
<section><h2>Windows 部署<a href="#windows-部署"><span>#</span></a></h2><p>我在 Windows 11 上部署的，过程其实不复杂。</p><section><h3>前提准备<a href="#前提准备"><span>#</span></a></h3><p>先装两个东西：</p><p><strong>Node.js</strong>：去 nodejs.org 下最新的 LTS 版本装上就行。这是酒馆运行的底层依赖，必须有。注意 Windows 7 不支持，因为跑不了 Node.js 20。</p><p><strong>Git</strong>：用来下载酒馆代码。可以装 Git for Windows，也可以用 GitHub Desktop。</p><p>装好之后可以验证一下，在命令行里输入：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>node</span><span> </span><span>-v</span></div></div><div><div><div>2</div></div><div><span>git</span><span> </span><span>--version</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>能看到版本号就说明装好了。</p></section><section><h3>安装路径注意<a href="#安装路径注意"><span>#</span></a></h3><p>这个很重要：<strong>不要装到 Program Files 或者 System32 这种系统文件夹里</strong>。Windows 对这些路径有权限限制，跑起来会出问题。</p><p>就找个普通路径放着，比如 <code>D:\SillyTavern</code> 或者 <code>C:\Tools\SillyTavern</code>。</p></section><section><h3>安装与启动<a href="#安装与启动"><span>#</span></a></h3><p>打开文件管理器，导航到你想放启动器的文件夹，在地址栏输入 <code>cmd</code> 回车，然后执行：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>git</span><span> </span><span>clone</span><span> </span><span>https://github.com/SillyTavern/SillyTavern-Launcher.git</span><span> &amp;&amp; </span><span>cd</span><span> </span><span>SillyTavern-Launcher</span><span> &amp;&amp; </span><span>start</span><span> </span><span>installer.bat</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>双击 <code>Launcher.bat</code></strong>，打开输入<code>1</code>它会自动下载需要的依赖包，然后启动服务。</p><p>第一次跑会慢一点，要下载一堆依赖。等命令行窗口里出现类似 <code>SillyTavern is listening on port 8000</code> 的提示，就说明启动成功了。</p><p>启动成功后，浏览器会自动打开酒馆界面。如果没有自动打开，手动访问 <code>http://127.0.0.1:8000</code> 就行。</p></section></section>
<section><h2>API 连接和配置<a href="#api-连接和配置"><span>#</span></a></h2><p>酒馆装好了，下一步就是连上 AI 模型。</p><p>酒馆支持好几种模型后端：</p><ul>
<li><strong>OpenAI</strong>（GPT-4、GPT-3.5 等）</li>
<li><strong>Claude</strong>（Anthropic 的模型）</li>
<li><strong>Gemini</strong>（Google 的模型）</li>
<li><strong>本地模型</strong>（通过 Ollama，LMstudio 等方式运行——本地模型怎么跑，可以看我那篇 <a href="/posts/ai/qwen35/">Qwen3.5 本地部署指南</a>）</li>
</ul><p>在酒馆界面的设置里填上你的 API Key 就能连上，操作不复杂。</p><section><h3>连接设置<a href="#连接设置"><span>#</span></a></h3><p>打开酒馆后，点上面的”插头”图标（就是那个像电源插头的），进入 API 连接设置。</p><p>这里有几个地方要填：</p><ol>
<li><strong>API 类型</strong>：选你用的模型，比如 OpenAI、Claude、Gemini</li>
<li><strong>API URL</strong>：填对应的 API 地址。如果是官方 API，一般有默认值；如果用反代或者第三方服务，填对应的地址</li>
<li><strong>API Key</strong>：填你的密钥</li>
</ol><p>填完之后点”连接”测试一下，能看到绿色的提示就说明连上了。</p></section></section>
<section><h2>注意事项<a href="#注意事项"><span>#</span></a></h2><p>这些是我折腾过程中遇到的问题，记下来防止以后忘。</p><section><h3>对接 LM 测试成功但发消息发不出去<a href="#对接-lm-测试成功但发消息发不出去"><span>#</span></a></h3><p>这个问题挺常见的。如果你测试连接没问题，但实际发消息的时候报错，很可能是”提示词后处理”没选对。</p><p></p><figure><img alt="消息顺序报错" loading="lazy" width="447" height="409" src="/_astro/sillytavern-parameters.Dweq0_5R_Z1p2mpF.webp" /><figcaption>消息顺序报错</figcaption></figure><p></p><p>特别是用 Qwen 之类的模型时，它严格要求消息顺序必须是系统提示词在最前面，然后用户和角色交替发言。如果顺序不对，就会报这种错：</p><p></p><figure><img alt="连接设置与提示词后处理选项" loading="lazy" width="1311" height="1110" src="/_astro/sillytavern-error.B8z7FIt9_1HU8Jb.webp" /><figcaption>连接设置与提示词后处理选项</figcaption></figure><p></p><p>解决办法就是在”提示词后处理”那里选”严格（强制对话角色交替、用户最先）”。</p></section><section><h3>参数设置<a href="#参数设置"><span>#</span></a></h3><p>连上之后，建议把参数调一下。上下文长度、最大回复长度、温度这些，直接影响聊天体验。</p><p></p><figure><img alt="参数设置面板" loading="lazy" width="751" height="1259" src="/_astro/sillytavern-connection.BTgtlMTO_Z2cy23L.webp" /><figcaption>参数设置面板</figcaption></figure><p></p><ul>
<li><strong>上下文长度</strong>：AI 能”记住”多少之前的内容。太短的话 AI 容易忘事，太长的话消耗的 token 多</li>
<li><strong>最大回复长度</strong>：AI 一次回复最多写多少字。设太小的话回复容易被截断，图里这个 8192 对大多数场景够用了</li>
<li><strong>温度</strong>：控制回复的随机性。0.70 左右比较平衡，想要更稳定可以调低，想要更有创意可以调高</li>
</ul><p>这些参数不用一开始就调到完美，先用默认值跑起来，后面再根据实际体验慢慢改。</p></section><section><h3>端口被占用<a href="#端口被占用"><span>#</span></a></h3><p>如果你之前跑过酒馆，或者有其他程序占用了 8000 端口，启动的时候会报错。解决办法：</p><ol>
<li>关掉之前那个酒馆窗口</li>
<li>或者改端口号，在启动参数里指定一个别的端口</li>
</ol></section><section><h3>API Key 的成本<a href="#api-key-的成本"><span>#</span></a></h3><p>这个得说一下。酒馆本身免费，但 API 调用是收费的。不同的模型价格不一样，用之前最好了解一下计费方式，免得月底看到账单吓一跳。</p><p>如果预算有限，可以考虑用一些提供免费额度的服务，或者跑本地模型（不过本地模型对显卡有要求）。</p><p>另外，要是你和我一样手上攒了一堆不同家的 key，可以自己搭一个 <a href="/posts/deploy/deploynewapi/">NewApi 中转站</a>统一收口，酒馆里只填一个地址一个 key 就行，换模型也不用到处改配置。</p></section></section>
<section><h2>最后说两句<a href="#最后说两句"><span>#</span></a></h2><p>酒馆这个东西怎么说呢，上手难度不算高，但需要你有一定的动手能力。如果你之前没怎么用过命令行，可能会觉得有点麻烦，但跟着步骤走其实也还好。</p><p>部署好之后的体验还是不错的，可以自由调整各种参数，玩起来比在线平台灵活多了。缺点就是需要自己有 API Key，这块是有成本的。</p><p>如果你也想搞一个自己的 AI 聊天环境，可以试试。有问题的话欢迎交流。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/windows/opencode-shortcut/</id>
      <title type="text">一个字母的事：让 opencode 像 mimo 一样在资源管理器地址栏启动</title>
      <published>2026-07-24T00:00:00.000Z</published>
      <updated>2026-08-26T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/windows/opencode-shortcut/"/>
      <summary type="text">想在文件资源管理器地址栏输入 oc 就启动 opencode？复制 .cmd 不行，得复制 .exe</summary>
      <content type="html"><![CDATA[<p>我平时用 opencode 每次都要开命令行输入 <code>opencode</code>，挺烦的。</p>
<p>mimo 就方便很多，在文件资源管理器地址栏直接输入 <code>mimo</code> 就能打开终端。我就想着能不能也给 opencode 搞一个短命令。</p>
<p>最简单的想法：复制一个 <code>oc.cmd</code> 出来不就行了？</p>
<div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>Copy-Item</span><span> </span><span>"C:\Users\XOS\AppData\Roaming\npm\opencode.cmd"</span><span> </span><span>"C:\Users\XOS\AppData\Roaming\npm\oc.cmd"</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p>命令行里输入 <code>oc</code> 确实能用。但是文件资源管理器地址栏里输入 <code>oc</code>，直接跳浏览器了。</p>
<p>搞了半天才发现，Windows 地址栏只认 <code>.exe</code>，不认 <code>.cmd</code>。输入 <code>oc</code> 找不到对应的 <code>.exe</code>，就当成网址去搜索了。</p>
<p>mimo 能用是因为它本身就是个 <code>.exe</code> 文件，放在 PATH 目录里，地址栏直接能找到。</p>
<p>所以正确的做法是复制 <code>.exe</code>：</p>
<div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>Copy-Item</span><span> </span><span>"C:\Users\XOS\AppData\Roaming\npm\node_modules\opencode-ai\bin\opencode.exe"</span><span> </span><span>"C:\Users\XOS\AppData\Roaming\npm\oc.exe"</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p>这下命令行和地址栏都能用了，输入 <code>oc</code> 直接打开，跟 mimo 一样。</p>
<p>说实话这个坑挺小的，但不查真不知道。我一直以为 <code>.cmd</code> 和 <code>.exe</code> 在地址栏里效果一样。</p>
<hr />
<p><em>折腾 opencode 快捷启动的小记录</em></p>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/windows/ultimategameperformance/</id>
      <title type="text">我的游戏性能优化方案：从系统到硬件折腾了一遍</title>
      <published>2026-07-22T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/windows/ultimategameperformance/"/>
      <summary type="text">把 Windows 系统设置、CPU 调优、GPU 超频、鼠标延迟都折腾了一遍，记录一下目前在用的配置</summary>
      <content type="html"><![CDATA[<p>最近 CS2 打得比较多，总觉得帧数不够稳、鼠标有点拖。从系统设置到硬件超频折腾了一遍，现在舒服多了。弄之前平均帧大概 580-600，low 只有 200-210；弄完后平均帧 657，low 250。</p>
<p>先说我的配置：</p>
<ul>
<li>CPU：9955HX</li>
<li>GPU：5070Ti 12G Laptop</li>
<li>内存：16X2=32G( 镁光M8D1 )</li>
<li>系统：XOS11 25H2 V15</li>
</ul>
<p></p><figure><img alt="CS2设置" loading="lazy" width="2048" height="1536" src="/_astro/cs2config.Ch_cPZfl_Z1YJ1Lu.webp" /><figcaption>CS2设置</figcaption></figure><p></p>
<p>下面按折腾顺序来，从 BIOS 到系统设置都讲一遍。</p>
<hr />
<section><h2>BIOS 设置<a href="#bios-设置"><span>#</span></a></h2><p>先把硬件层面的搞定。</p><section><h3>CPU<a href="#cpu"><span>#</span></a></h3><ul>
<li><strong>UMAF 分核负压设置</strong>：如下图所示,<a href="https://github.com/hy4962/Share/blob/main/Windows/ZIP/UMAF_BETA.zip" target="_blank">Github/UMAF_BETA.zip </a></li>
<li><strong>关闭 VBS + 内存虚拟化</strong>：使用<a href="https://github.com/hy4962/Share/blob/main/Windows/ZIP/%E5%85%B3%E9%97%ADDG%E5%92%8CVBS%E8%87%AA%E5%8A%A8%E5%8C%85.zip" target="_blank">Github/关闭DG和VBS自动包</a>执行重启后疯狂按F3开机即可</li>
<li><strong>频率</strong>： 4.8 G</li>
</ul><p></p><figure><img alt="CPU 负压设置" loading="lazy" width="1536" height="2048" src="/_astro/cpu-negative-pressure.BKWi3ARZ_pmWM7.webp" /><figcaption>CPU 负压设置</figcaption></figure><p></p><blockquote><p>尝试禁用CCD1的方案然后跑5.2 G和5.0G，但是温度反而100°C，93°C了，双CCD4.8G倒是最多85°C，都是用同一套负压。奇怪…</p></blockquote></section><section><h3>内存条<a href="#内存条"><span>#</span></a></h3><p>本人是<strong>镁光 M8D1</strong>，原生 5600c46，101ns，超到 6000c40，85ns到88ns。我的作业貌似非常保守，这个也是超别人的</p><p></p><figure><img alt="内存超频参数" loading="lazy" width="522" height="766" src="/_astro/memory-overclocking.CaE8UyHH_1OaNGS.webp" /><figcaption>内存超频参数</figcaption></figure><p></p><p>这里我把<strong>第一个</strong>26<strong>改成了28</strong>，<strong>第五个</strong>4E<strong>改成了52</strong></p><p></p><figure><img alt="UMAF_SPD" loading="lazy" width="2048" height="1536" src="/_astro/umaf_spd.k6VeApay_1g1uH6.webp" /><figcaption>UMAF_SPD</figcaption></figure><p></p><hr /></section></section>
<section><h2>GPU 超频<a href="#gpu-超频"><span>#</span></a></h2><p>硬件搞定了，再来折腾 N 卡。</p><p>安装厂商官方驱动包，但没安装核显、N 卡驱动和 AMD 安全启动。厂商控制台 CPU 全拉满，然后对 N 卡进行超频：</p><p></p><figure><img alt="N卡控制面板1" loading="lazy" width="856" height="612" src="/_astro/gpu-settings.CM0VnNkI_12LjwQ.webp" /><figcaption>N卡控制面板1</figcaption></figure>
<figure><img alt="N卡控制面板2" loading="lazy" width="842" height="747" src="/_astro/gpu-settings2.DZ4dc0Gq_Z1s11uj.webp" /><figcaption>N卡控制面板2</figcaption></figure><p></p><ul>
<li><strong>核心频率</strong>：+250 MHz</li>
<li><strong>显存频率</strong>：+300 MHz</li>
</ul><p></p><figure><img alt="GPU 超频设置 1" loading="lazy" width="2030" height="1094" src="/_astro/gpu-overclock-settings-new.p5RbyZ0y_ZOba0h.webp" /><figcaption>GPU 超频设置 1</figcaption></figure><p></p><p></p><figure><img alt="GPU 超频设置 2" loading="lazy" width="1482" height="915" src="/_astro/gpu-overclock-metrics.OTPMgFAY_ZRBPEE.webp" /><figcaption>GPU 超频设置 2</figcaption></figure><p></p><blockquote><p>N 卡驱动我自己用 596.36-notebook-win10-win11-64bit-international-dch-whql</p></blockquote><hr /></section>
<section><h2>系统设置<a href="#系统设置"><span>#</span></a></h2><p>硬件调完了，软件层面也得搞一下。</p><section><h3>Process Lasso Pro 配置<a href="#process-lasso-pro-配置"><span>#</span></a></h3><p>规则添加游戏进程就完事了：禁用 CPU0 + 提升进程优先级。</p><p></p><figure><img alt="Process Lasso 设置" loading="lazy" width="916" height="308" src="/_astro/process-lasso-cs2.B4HC7PB6_Z1CAIWn.webp" /><figcaption>Process Lasso 设置</figcaption></figure><p></p><section><h4>金银核与禁用 CPU0 的原理<a href="#金银核与禁用-cpu0-的原理"><span>#</span></a></h4><p><code>perf#1</code> 代表系统会优先将任务分配到该核心上。我的金银核是 Core 0 和 Core 4。</p><p>简单来说：如果你的金银核中有一个正好在 CPU0 上（比如我的情况），游戏禁用 CPU0 的收益最高。因为在鼠标、键盘高轮询率的情况下，系统会优先用金银核处理输入事件。禁用 CPU0 后，游戏反而会更流畅——因为输入处理不再抢占游戏核心的资源。</p><p></p><figure><img alt="金银核示意" loading="lazy" width="865" height="829" src="/_astro/cpu-golden-core.Db1IiIOy_1LsPPH.webp" /><figcaption>金银核示意</figcaption></figure><p></p></section></section><section><h3>快速配置<a href="#快速配置"><span>#</span></a></h3><p>使用 Booster 快速设置最佳配置，然后手动恢复 WiFi 和蓝牙为正常打开状态。</p><blockquote><p>提示：这个系统需要在设备管理器里单独手动安装 WiFi 驱动。</p></blockquote><p></p><figure><img alt="Booster WiFi 驱动安装" loading="lazy" width="1340" height="943" src="/_astro/booster-wifi-driver.6i6cvaar_6F0pq.webp" /><figcaption>Booster WiFi 驱动安装</figcaption></figure><p></p><hr /></section></section>
<section><h2>鼠标延迟<a href="#鼠标延迟"><span>#</span></a></h2><p>最后处理鼠标问题，这一套搞完鼠标就不拖了。鼠标跟手性这块我单独深挖过一篇，BoosterX 解锁 USB 选项那套折腾更细：<a href="/posts/windows/mouseoptimization/">优化鼠标跟手性，顺便解决了完美死斗跳 ms 的问题</a>。</p><section><h3>1. 关闭鼠标加速<a href="#1-关闭鼠标加速"><span>#</span></a></h3><p><strong>默认状态</strong>：开启</p><p><strong>操作步骤</strong>：<code>Win + R</code> → 输入 <code>main.cpl</code> → 确定 → 指针选项 → 取消勾选「提高指针精确度」→ 应用</p><p><strong>效果</strong>：修复鼠标拖不动</p></section><section><h3>2. 修改 Win32PrioritySeparation 为 42（十进制）<a href="#2-修改-win32priorityseparation-为-42十进制"><span>#</span></a></h3><p><strong>默认值</strong>：十进制 2</p><p><strong>操作步骤</strong>：<code>Win + R</code> → 输入 <code>regedit</code> → 确定 → 找到路径：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\Control\PriorityControl</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>双击 <code>Win32PrioritySeparation</code>，修改为十进制 <code>42</code>（需重启生效）</p><p><strong>效果</strong>：修复鼠标拖不动</p></section><section><h3>3. 关闭 USB 选择性暂停<a href="#3-关闭-usb-选择性暂停"><span>#</span></a></h3><p><strong>默认状态</strong>：已启用</p><p><strong>操作步骤</strong>：<code>Win + R</code> → 输入 <code>powercfg.cpl</code> → 确定 → 更改计划设置 → 更改高级电源设置 → USB 设置 → USB 选择性暂停设置 → 改成「已禁用」</p><p></p><figure><img alt="USB 选择性暂停" loading="lazy" width="519" height="592" src="/_astro/usbsetting.DUtBxaC5_1rHF96.webp" /><figcaption>USB 选择性暂停</figcaption></figure><p></p><p><strong>效果</strong>：修复鼠标拖不动</p><blockquote><p>提示：如果没有 USB 设置选项</p><p>则访问注册表<code>HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Power\PowerSettings\54533251-82be-4824-96c1-47b60b740d00</code></p><p>在右侧找到 <code>Attributes</code>，双击，将数值从 <code>1</code> 改为 <code>2</code></p><p>或者请参考下图操作：</p></blockquote><p></p><figure><img alt="USB 电源设置" loading="lazy" width="1340" height="943" src="/_astro/usb-power-settings.tpvfBnIt_ZlnfWD.webp" /><figcaption>USB 电源设置</figcaption></figure><p></p></section><section><h3>4. 关闭修复应用缩放（仅 Win10）<a href="#4-关闭修复应用缩放仅-win10"><span>#</span></a></h3><p><strong>默认状态</strong>：开启</p><p><strong>操作步骤</strong>：桌面右键 → 显示设置 → 高级缩放设置 → 修复应用缩放 → 关</p><p><strong>效果</strong>：修复鼠标掉帧</p><blockquote><p>此项仅限 Win10，Win11 不用理会。</p></blockquote></section><section><h3>5. 关闭「在打字时隐藏指针」<a href="#5-关闭在打字时隐藏指针"><span>#</span></a></h3><p><strong>默认状态</strong>：开启</p><p><strong>操作步骤</strong>：<code>Win + R</code> → 输入 <code>main.cpl</code> → 确定 → 指针选项 → 取消选中「在打字时隐藏指针」</p><p><strong>效果</strong>：修复鼠标掉帧（需重启生效，虽然抽象但确实影响鼠标表现）</p><hr /></section></section>
<section><h2>还有几个坑<a href="#还有几个坑"><span>#</span></a></h2><p>AMD最好禁用高精度处理器，特别在老AMD上，一两天会有一次非常剧烈的卡顿，甚至在桌面也有，禁用后即可解决，但其实在新AMD上几乎看不到，反正禁用没坏处…</p><p>设备管理器-系统设备-高精度事件计时器-右键禁用</p><p></p><figure><img alt="禁用高精度事件计时器" loading="lazy" width="1232" height="885" src="/_astro/disable-hpet.D8QrpTnb_1DjqDE.webp" /><figcaption>禁用高精度事件计时器</figcaption></figure><p></p><p>对于AMD纯大核心的设计，电源计划的调度必须选择全部和异类策略4，否则会出现分配不均的情况，因为默认的是英特尔大小核的调度。</p><p>但是如果你说双CCD延迟高，你用上面说到的Process Lasso Pro改需要延迟低的程序，绑定一个CCD亲和性就就行了</p><blockquote><p>（很多笔记本默认隐藏了这2个选项，要调整需要更改注册表。 点击”Windows键”+“R键”，输入regedit打开注册表编辑器，在”HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Power\PowerSettings\54533251-82be-4824-96c1-47b60b740d00”下找到”93b8b6dc-0698-4d1c-9ee4-0644e900c85d”以及”bae08b81-2d5e-4688-ad6a-13243356654b”，进去后将2个路径下的”Attributes”值由默认0更改为2，重新打开电源计划即可更改异类线程调度策略和异类短运行线程调度策略。）</p></blockquote><p></p><figure><img alt="电源计划设置1" loading="lazy" width="519" height="592" src="/_astro/power-plan-settings.DOK7vgB__ZORkag.webp" /><figcaption>电源计划设置1</figcaption></figure><p></p><p></p><figure><img alt="电源计划设置2" loading="lazy" width="519" height="592" src="/_astro/power-plan-settings-2.CDX5KC3S_ZpOoyU.webp" /><figcaption>电源计划设置2</figcaption></figure><p></p><hr /></section>
<section><h1>总结<a href="#总结"><span>#</span></a></h1><p>折腾完之后 CS2 帧数稳了不少，鼠标也不拖了。说实话挺烦的，但搞完确实舒服。</p><p>核心配置：</p><ul>
<li><strong>CPU</strong>：4.8G + 全核分核负压</li>
<li><strong>GPU</strong>：核心频率 +250Mhz，显存频率 +300Mhz</li>
<li><strong>内存</strong>：5600C46 → 6000C40</li>
<li><strong>系统</strong>：XOS11 25H2 + Booster 快速最佳设置 + 鼠标设置</li>
<li><strong>电源</strong>：解锁电源计划隐藏选项，使用高性能电源计划把异类策略改为4，异类调度改为全部并取消 USB 节能</li>
<li><strong>设备管理器</strong>：取消所有 USB 设备”允许计算机关闭此设备以节约电源”</li>
</ul><p>接下来打算试试内存能不能再压一下时序，看看有没有提升。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/anime/oreimo/oreimo/</id>
      <title type="text">《俺妹》观后感</title>
      <published>2026-07-21T00:00:00.000Z</published>
      <updated>2026-09-26T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/anime/oreimo/oreimo/"/>
      <summary type="text">《俺の妹がこんなに可愛いわけがない》观后感，聊聊剧情节奏、角色塑造和结局争议。</summary>
      <content type="html"><![CDATA[<section><h1>看完《俺妹》后的一些个人感想<a href="#看完俺妹后的一些个人感想"><span>#</span></a></h1><blockquote><p>横线以上基本无剧透，没看过的可以放心看。<br />
下面只是个人观点，也算留给未来自己的一个记录</p></blockquote><p></p><figure><img alt="《俺妹》观后感旧版封面" loading="lazy" width="1672" height="941" src="/_astro/image-001.BVcb8uOY_Z2nzJkl.webp" /><figcaption>《俺妹》观后感旧版封面</figcaption></figure><p></p><p>看完《俺妹》之后，基本也把相关游戏补了一遍。</p><p>回过头来看动画，突然发现了一些第一次观看时没有注意到的问题。</p><p>第一次看的时候，其实完全没有这种感觉。</p><p>当时就是被剧情推着走，只想知道接下来会发生什么，根本没有去想节奏、铺垫这些东西。</p><p>但是看完游戏之后，再回头想动画，就感觉这部作品的问题其实挺明显。</p><p>最大的问题就是：节奏真的太赶了。</p><p>很多时候一集就是一个小故事，甚至直接推进一个阶段，中间几乎没有什么日常过渡。</p><p>角色之间很多感情变化，其实应该有更多时间去描写，但动画篇幅有限，导致很多地方刚建立起感觉，就马上进入下一个剧情。</p><p>说白了就是：</p><p>内容太多，但是两季真的装不下。</p><p>两季加起来的篇幅并不算长，但它想讲的东西实在太多了。</p><p>主要人物基本全部涉及，各种事件也全部推进。</p><p>如果能在两季之间增加一些零零散散的日常篇，让角色们多相处十几集，整个作品的观感应该会好很多。</p><p>可惜了。</p><hr /><p>说到桐乃。</p><p>她这个角色真的属于争议拉满。</p><p>有些操作是真的难绷。</p><p>尤其如果站在京介视角来看，她对哥哥的态度有时候已经不是普通傲娇了，而是接近“你到底想干什么”的程度。</p><p>特别是青梅来家里的那段。</p><p>如果我是她哥，估计早在第一季前几集就已经忍不了了，早他妈给她几巴掌扇老实了，青梅过来就这个态度。。。。。</p><p>不过看完整个故事之后，也能理解，这种极端某种程度上就是桐乃这个角色的核心。</p><p>她就是这么强势、任性、不服输。</p><p>换个人可能还真演不出这个感觉。</p><p>但不得不承认，她有一点是真的厉害。</p><p>能够保持自己的兴趣爱好，同时还能把学习、工作、生活全部做好。</p><p>这种能力和自律，其实非常夸张。</p><hr /><p>黑猫和青梅两个角色是真的顶。</p><p>两个人都属于比较踏实的类型。</p><p>普通、勤俭、会做家务，也比较懂得照顾别人。</p><p>这种角色其实越看越舒服。</p><p>尤其黑猫。</p><p>如果单纯从理想型来看，我觉得黑猫可能会更符合一点。</p><p>她不是那种第一眼特别强烈的角色，但越了解越容易喜欢。</p><hr /><section><h2>以下开始剧透<a href="#以下开始剧透"><span>#</span></a></h2><p>没看完真的别往下翻。</p><p>《俺妹》这种作品，第一次观看体验其实很重要，一点点关键剧情都会影响感受。[笑哭]</p><p>下面这段关于结局的讨论我折叠起来了，<strong>想看再点开</strong>。</p>
点击展开：关于结局的讨论（剧透）
<div><p>关于动画结局的争议。</p><p>以我个人来说，其实没有觉得特别接受不了。</p><p>首先，我是观众，不是京介本人。</p><p>从京介的角度来看，他最后确实拒绝了其他人。</p><p>所以严格来说，其他人的感情确实只能算是一段没有继续发展的关系。</p><p>但是站在观众角度，我觉得其实还好。</p><p>尤其黑猫这个角色。</p><p>她喜欢的东西其实一直没有改变。</p><p>某种程度上来说，就是“肉体可以改变，但灵魂不会变”。</p><p>自私一点想，京介以后也不缺喜欢他的人。</p><p>而且最后一集突然亲脸那一段，我个人感觉更像是——</p><p>表面上的恋情结束了，但真正的关系转为了地下。</p><p>所以这个结局我能接受。</p><p>如果真的接受不了，去玩游戏也是一个不错的选择。</p></div>
<hr /><p>总的来说，《俺妹》是一部非常值得看的作品。</p><p>虽然桐乃有时候的操作是真的让人血压升高，但她也是这部作品不可缺少的一部分。</p><p>在我看来，直到 “桐乃假男友事件” 之后，两个人的关系才真正开始像现实里的兄妹。</p><p>这也是这么多年来，第一次有一部动画让我专门去玩游戏、买周边。</p><p>它可能不是一部完美的作品。</p><p>但它确实留下了很深的印象。</p><p>顺便，之前写的<a href="/posts/anime/zero-no-tsukaima/zero-no-tsukaima/">《零之使魔》观后感</a>也是傲娇女主的异世界恋爱番，连“节奏太赶”这个毛病都如出一辙，感兴趣的可以一起看看。</p><p></p><figure><img alt="《俺妹》观后感题图" loading="lazy" width="2048" height="1152" src="/_astro/image-002.1hXap2BG_2avUvy.webp" /><figcaption>《俺妹》观后感题图</figcaption></figure><p></p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/deploywaline/</id>
      <title type="text">用 Vercel + TiDB 搭建 Waline 评论系统</title>
      <published>2026-07-21T00:00:00.000Z</published>
      <updated>2026-07-21T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/deploywaline/"/>
      <summary type="text">用 Vercel 免费部署 Waline 评论服务端，数据库选 TiDB Cloud（免费 5GB），半小时搞定，数据自己掌控</summary>
      <content type="html"><![CDATA[<p>有次在别人的博客上看到 Waline，去 GitHub 一查，发现它支持多数据库、能自己部署、还能统计浏览量，功能挺全的。关键是它开源，数据自己掌控，我就想着搞一个试试。</p>
<section><h2>为什么选 TiDB 而不是 Neon<a href="#为什么选-tidb-而不是-neon"><span>#</span></a></h2><p>Waline 官方的 Vercel 部署教程默认用 Neon（PostgreSQL），免费给 512MB。但我查了一下 TiDB Cloud，免费额度有 5GB，差了十倍。对于评论系统来说数据库不会太大，但 5GB 总比 512MB 舒服，以后万一要存点浏览量数据也不用担心。</p><p>TiDB 本身是兼容 MySQL 协议的 NewSQL 数据库，Waline 直接支持 TiDB 作为后端，不用额外折腾。所以我就决定用 Vercel + TiDB 这个组合。</p></section>
<section><h2>第一步：部署 Vercel 服务端<a href="#第一步部署-vercel-服务端"><span>#</span></a></h2><p>Waline 官方提供了一键部署的按钮，点一下就行。</p><p><a href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwalinejs%2Fwaline%2Ftree%2Fmain%2Fexample" target="_blank"></a></p><figure><a href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwalinejs%2Fwaline%2Ftree%2Fmain%2Fexample" target="_blank"><img src="https://vercel.com/button" alt="Vercel" /><figcaption>Vercel</figcaption></a></figure><p></p><p>点击上面的按钮，会跳转到 Vercel。如果你还没登录，它会让你用 GitHub 账号注册或登录。</p><p>登录之后，给你的项目起个名字，比如 <code>my-waline</code>，然后点 <code>Create</code>。</p><p></p><figure><img alt="给项目起名" loading="lazy" width="2048" height="1026" src="/_astro/vercel-1-D2seYf19.CmDcB8aB_2sh4Ro.webp" /><figcaption>给项目起名</figcaption></figure><p></p><p>Vercel 会基于 Waline 模板自动创建一个仓库，大概一两分钟就能部署好。</p><p></p><figure><img alt="自动创建仓库" loading="lazy" width="2048" height="1019" src="/_astro/vercel-2-BDSPJcTz.BdceJ-bs_Z1lj1Dz.webp" /><figcaption>自动创建仓库</figcaption></figure><p></p><p>部署成功后会有一屏烟花，点 <code>Go to Dashboard</code> 进入控制台。</p><p></p><figure><img alt="部署成功" loading="lazy" width="2048" height="1034" src="/_astro/vercel-3-MOiZtKqX.DhFcbgdG_Z2i8VsC.webp" /><figcaption>部署成功</figcaption></figure><p></p><p>到这里服务端代码就部署好了，但还没连数据库，所以先别急着用。</p></section>
<section><h2>第二步：创建 TiDB 数据库<a href="#第二步创建-tidb-数据库"><span>#</span></a></h2><p>这一步是整个过程中最核心的，也是跟官方教程不一样的地方。</p><section><h3>注册 TiDB Cloud<a href="#注册-tidb-cloud"><span>#</span></a></h3><p>去 <a href="https://tidbcloud.com" target="_blank">TiDB Cloud</a> 注册一个账号。注册完之后会自动创建一个 TiDB 实例，不用手动配置。</p></section><section><h3>创建表结构<a href="#创建表结构"><span>#</span></a></h3><p>登录后点进你的实例（默认叫 <code>cluster0</code>），左侧选 <code>SQL Editor</code>。</p><p></p><figure><img alt="进入实例" loading="lazy" width="2048" height="908" src="/_astro/tidb-1-mKqYDk6x.Cl29b7de_Z1dgumi.webp" /><figcaption>进入实例</figcaption></figure><p></p><p>接下来要执行建表语句。Waline 官方提供了一个 SQL 文件，但那个是 PostgreSQL 格式的。TiDB 用的是 MySQL 兼容语法，需要用 <a href="https://github.com/walinejs/waline/blob/main/assets/waline.tidb" target="_blank">waline.tidb</a> 这个文件。</p><p>SQL 文件的内容大概是这样的：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>CREATE</span><span> </span><span>DATABASE</span><span> `</span><span>waline</span><span>`;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>USE</span><span> waline;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>CREATE</span><span> </span><span>TABLE</span><span> `</span><span>wl_Comment</span><span>` (</span></div></div><div><div><div>6</div></div><div><span>  </span><span>`id`</span><span> </span><span>int</span><span> unsigned </span><span>NOT NULL</span><span> AUTO_INCREMENT,</span></div></div><div><div><div>7</div></div><div><span>  </span><span>`user_id`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>8</div></div><div><span>  </span><span>`comment`</span><span> </span><span>text</span><span>,</span></div></div><div><div><div>9</div></div><div><span>  </span><span>`insertedAt`</span><span> </span><span>timestamp</span><span> </span><span>NULL</span><span> </span><span>DEFAULT</span><span> CURRENT_TIMESTAMP,</span></div></div><div><div><div>10</div></div><div><span>  </span><span>`ip`</span><span> </span><span>varchar</span><span>(</span><span>100</span><span>) </span><span>DEFAULT</span><span> </span><span>''</span><span>,</span></div></div><div><div><div>11</div></div><div><span>  </span><span>`link`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>12</div></div><div><span>  </span><span>`mail`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>13</div></div><div><span>  </span><span>`nick`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>14</div></div><div><span>  </span><span>`pid`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>15</div></div><div><span>  </span><span>`rid`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>16</div></div><div><span>  </span><span>`sticky`</span><span> </span><span>boolean</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>17</div></div><div><span>  </span><span>`status`</span><span> </span><span>varchar</span><span>(</span><span>50</span><span>) </span><span>NOT NULL</span><span> </span><span>DEFAULT</span><span> </span><span>''</span><span>,</span></div></div><div><div><div>18</div></div><div><span>  </span><span>`like`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>19</div></div><div><span>  </span><span>`ua`</span><span> </span><span>text</span><span>,</span></div></div><div><div><div>20</div></div><div><span>  </span><span>`url`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>21</div></div><div><span>  </span><span>`createdAt`</span><span> </span><span>timestamp</span><span> </span><span>NULL</span><span> </span><span>DEFAULT</span><span> CURRENT_TIMESTAMP,</span></div></div><div><div><div>22</div></div><div><span>  </span><span>`updatedAt`</span><span> </span><span>timestamp</span><span> </span><span>NULL</span><span> </span><span>DEFAULT</span><span> CURRENT_TIMESTAMP,</span></div></div><div><div><div>23</div></div><div><span>  </span><span>PRIMARY KEY</span><span> (</span><span>`id`</span><span>)</span></div></div><div><div><div>24</div></div><div><span><span>) CHARSET</span><span>=</span><span>utf8mb4;</span></span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div><span>CREATE</span><span> </span><span>TABLE</span><span> `</span><span>wl_Counter</span><span>` (</span></div></div><div><div><div>27</div></div><div><span>  </span><span>`id`</span><span> </span><span>int</span><span> unsigned </span><span>NOT NULL</span><span> AUTO_INCREMENT,</span></div></div><div><div><div>28</div></div><div><span>  </span><span>`time`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>29</div></div><div><span>  </span><span>`reaction0`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>30</div></div><div><span>  </span><span>`reaction1`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>31</div></div><div><span>  </span><span>`reaction2`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>32</div></div><div><span>  </span><span>`reaction3`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>33</div></div><div><span>  </span><span>`reaction4`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>34</div></div><div><span>  </span><span>`reaction5`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>35</div></div><div><span>  </span><span>`reaction6`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>36</div></div><div><span>  </span><span>`reaction7`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>37</div></div><div><span>  </span><span>`reaction8`</span><span> </span><span>int</span><span> </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>38</div></div><div><span>  </span><span>`url`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>NOT NULL</span><span> </span><span>DEFAULT</span><span> </span><span>''</span><span>,</span></div></div><div><div><div>39</div></div><div><span>  </span><span>`createdAt`</span><span> </span><span>timestamp</span><span> </span><span>NULL</span><span> </span><span>DEFAULT</span><span> CURRENT_TIMESTAMP,</span></div></div><div><div><div>40</div></div><div><span>  </span><span>`updatedAt`</span><span> </span><span>timestamp</span><span> </span><span>NULL</span><span> </span><span>DEFAULT</span><span> CURRENT_TIMESTAMP,</span></div></div><div><div><div>41</div></div><div><span>  </span><span>PRIMARY KEY</span><span> (</span><span>`id`</span><span>)</span></div></div><div><div><div>42</div></div><div><span><span>) CHARSET</span><span>=</span><span>utf8mb4;</span></span></div></div><div><div><div>43</div></div><div>
</div></div><div><div><div>44</div></div><div><span>CREATE</span><span> </span><span>TABLE</span><span> `</span><span>wl_Users</span><span>` (</span></div></div><div><div><div>45</div></div><div><span>  </span><span>`id`</span><span> </span><span>int</span><span> unsigned </span><span>NOT NULL</span><span> AUTO_INCREMENT,</span></div></div><div><div><div>46</div></div><div><span>  </span><span>`display_name`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>NOT NULL</span><span> </span><span>DEFAULT</span><span> </span><span>''</span><span>,</span></div></div><div><div><div>47</div></div><div><span>  </span><span>`email`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>NOT NULL</span><span> </span><span>DEFAULT</span><span> </span><span>''</span><span>,</span></div></div><div><div><div>48</div></div><div><span>  </span><span>`password`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>NOT NULL</span><span> </span><span>DEFAULT</span><span> </span><span>''</span><span>,</span></div></div><div><div><div>49</div></div><div><span>  </span><span>`type`</span><span> </span><span>varchar</span><span>(</span><span>50</span><span>) </span><span>NOT NULL</span><span> </span><span>DEFAULT</span><span> </span><span>''</span><span>,</span></div></div><div><div><div>50</div></div><div><span>  </span><span>`label`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>51</div></div><div><span>  </span><span>`url`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>52</div></div><div><span>  </span><span>`avatar`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>53</div></div><div><span>  </span><span>`github`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>54</div></div><div><span>  </span><span>`twitter`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>55</div></div><div><span>  </span><span>`facebook`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>56</div></div><div><span>  </span><span>`google`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>57</div></div><div><span>  </span><span>`weibo`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>58</div></div><div><span>  </span><span>`qq`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>59</div></div><div><span>  </span><span>`oidc`</span><span> </span><span>varchar</span><span>(</span><span>255</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>60</div></div><div><span>  </span><span>`2fa`</span><span> </span><span>varchar</span><span>(</span><span>32</span><span>) </span><span>DEFAULT</span><span> </span><span>NULL</span><span>,</span></div></div><div><div><div>61</div></div><div><span>  </span><span>`createdAt`</span><span> </span><span>timestamp</span><span> </span><span>NULL</span><span> </span><span>DEFAULT</span><span> CURRENT_TIMESTAMP,</span></div></div><div><div><div>62</div></div><div><span>  </span><span>`updatedAt`</span><span> </span><span>timestamp</span><span> </span><span>NULL</span><span> </span><span>DEFAULT</span><span> CURRENT_TIMESTAMP,</span></div></div><div><div><div>63</div></div><div><span>  </span><span>PRIMARY KEY</span><span> (</span><span>`id`</span><span>)</span></div></div><div><div><div>64</div></div><div><span><span>) CHARSET</span><span>=</span><span>utf8mb4;</span></span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>注意一点：TiDB Cloud 的 SQL Editor 每次只能执行一条语句。所以你得按 <code>;</code> 分隔，一句一句贴进去执行。每贴一句，点右上角的蓝色运行按钮，或者按 <code>Ctrl+Enter</code>（Mac 上是 <code>Cmd+Enter</code>）。</p><p></p><figure><img alt="SQL Editor 执行" loading="lazy" width="2048" height="908" src="/_astro/tidb-2-B0iuNE4v.CJT9LfuB_1umdeq.webp" /><figcaption>SQL Editor 执行</figcaption></figure><p></p><p>三张表都创建好之后，数据库就准备完了。</p></section><section><h3>获取连接信息<a href="#获取连接信息"><span>#</span></a></h3><p>点左侧的 <code>Overview</code> 回到首页，右上角有个 <code>Connect</code> 按钮，点进去获取连接配置。</p><p>Connect with 选 <code>General</code>，然后点 <code>Reset password</code> 生成一个新密码。</p><p></p><figure><img alt="获取连接配置" loading="lazy" width="2048" height="1373" src="/_astro/tidb-7-XwRE4REJ.D7zeoLzk_YdAFA.webp" /><figcaption>获取连接配置</figcaption></figure><p></p><p>你需要记下这几个信息：</p><ul>
<li><strong>Host</strong>：类似 <code>xxx.ap-southeast-1.tidbcloud.com</code></li>
<li><strong>Port</strong>：默认 <code>4000</code></li>
<li><strong>User Name</strong>：一般是 <code>xxx.root</code></li>
<li><strong>Password</strong>：刚才生成的那个</li>
<li><strong>Database</strong>：<code>waline</code></li>
</ul></section></section>
<section><h2>第三步：给 Vercel 配置数据库<a href="#第三步给-vercel-配置数据库"><span>#</span></a></h2><p>回到 Vercel 的项目控制台，点顶部的 <code>Settings</code>，然后找到左侧的 <code>Environment Variables</code>。</p><p>点右上角的 <code>Add Environment Variable</code>，依次添加以下五个变量：</p>

<table><thead><tr><th>变量名</th><th>值</th><th>说明</th></tr></thead><tbody><tr><td><code>TIDB_HOST</code></td><td><code>xxx.ap-southeast-1.tidbcloud.com</code></td><td>TiDB 的 Host 地址</td></tr><tr><td><code>TIDB_PORT</code></td><td><code>4000</code></td><td>TiDB 端口</td></tr><tr><td><code>TIDB_DB</code></td><td><code>waline</code></td><td>数据库名</td></tr><tr><td><code>TIDB_USER</code></td><td><code>xxx.root</code></td><td>用户名</td></tr><tr><td><code>TIDB_PASSWORD</code></td><td><code>你生成的密码</code></td><td>密码</td></tr></tbody></table><p></p><figure><img alt="配置环境变量" loading="lazy" width="2048" height="1373" src="/_astro/tidb-8-AaYLfj0l.CHNM-VZ1_1Blbur.webp" /><figcaption>配置环境变量</figcaption></figure><p></p><p>填完之后，去 <code>Deployments</code> 页面，点最新一次部署右边的 <code>Redeploy</code> 按钮，让环境变量生效。</p><p></p><figure><img alt="重新部署" loading="lazy" width="2048" height="1029" src="/_astro/vercel-9-Dc-86IYm.WhEFgjvp_1hrTw9.webp" /><figcaption>重新部署</figcaption></figure><p></p><p>等部署状态变成 <code>Ready</code> 之后，点 <code>Visit</code> 就能看到你的 Waline 服务了。</p><p></p><figure><img alt="访问服务" loading="lazy" width="2048" height="1027" src="/_astro/vercel-10-BS4ALWuI.Y79QAIuw_Z1eQXvB.webp" /><figcaption>访问服务</figcaption></figure><p></p><p>这个地址就是你的服务端地址，后面客户端接入要用到。</p></section>
<section><h2>第四步：绑定自定义域名（可选）<a href="#第四步绑定自定义域名可选"><span>#</span></a></h2><p>如果你不想用 Vercel 默认的域名，可以绑自己的域名。</p><p>点 <code>Settings</code> → <code>Domains</code>，输入你想绑定的域名，点 <code>Add</code>。</p><p></p><figure><img alt="添加域名" loading="lazy" width="1026" height="369" src="/_astro/vercel-11-BDTeHH3e.BDTeHH3e_XkQNG.webp" /><figcaption>添加域名</figcaption></figure><p></p><p>然后去你的域名服务商那里，添加一条 CNAME 记录：</p>

<table><thead><tr><th>Type</th><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>CNAME</td><td>example</td><td>cname.vercel-dns.com</td></tr></tbody></table><p><code>example</code> 是你子域名的部分，比如你想用 <code>comment.yourdomain.com</code>，就填 <code>comment</code>。</p><p>等 DNS 生效后，你的评论系统地址就变成了 <code>comment.yourdomain.com</code>，管理后台在 <code>comment.yourdomain.com/ui</code>。</p><p></p><figure><img alt="绑定成功" loading="lazy" width="785" height="138" src="/_astro/vercel-12-Df5XGWYy.Df5XGWYy_8h4pD.webp" /><figcaption>绑定成功</figcaption></figure><p></p></section>
<section><h2>第五步：在网站里接入评论<a href="#第五步在网站里接入评论"><span>#</span></a></h2><p>服务端搞定了，最后一步是在你的网站里引入 Waline 客户端。</p><p>在 HTML 里加上这几行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>head</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>&lt;</span><span>link</span><span> </span><span>rel</span><span>=</span><span>"stylesheet"</span><span> </span><span>href</span><span>=</span><span>"https://unpkg.com/@waline/client@v3/dist/waline.css"</span><span> /&gt;</span></div></div><div><div><div>3</div></div><div><span>&lt;/</span><span>head</span><span>&gt;</span></div></div><div><div><div>4</div></div><div><span>&lt;</span><span>body</span><span>&gt;</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"waline"</span><span>&gt;&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>&lt;</span><span>script</span><span> </span><span>type</span><span>=</span><span>"module"</span><span>&gt;</span></div></div><div><div><div>7</div></div><div><span>    </span><span>import</span><span> { </span><span>init</span><span> } </span><span>from</span><span> </span><span>'https://unpkg.com/@waline/client@v3/dist/waline.js'</span><span>;</span></div></div><div><div><div>8</div></div><div><span>    </span><span>init</span><span>({</span></div></div><div><div><div>9</div></div><div><span>      </span><span>el</span><span>:</span><span> </span><span>'#waline'</span><span>,</span></div></div><div><div><div>10</div></div><div><span>      </span><span>serverURL</span><span>:</span><span> </span><span>'https://你的服务端地址'</span><span>,</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>});</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>&lt;/</span><span>script</span><span>&gt;</span></div></div><div><div><div>13</div></div><div><span>&lt;/</span><span>body</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>把 <code>serverURL</code> 替换成你 Vercel 部署后的地址就行。</p><p>如果你用的是 Astro、Hexo 这类静态博客框架，一般都有 Waline 插件，配置起来更简单。</p></section>
<section><h2>管理评论<a href="#管理评论"><span>#</span></a></h2><section><h3>自带管理后台，这是 Waline 的一大优势<a href="#自带管理后台这是-waline-的一大优势"><span>#</span></a></h3><p>说实话，很多评论系统（比如 Valine、Twikoo）都没有独立的管理后台，你只能去数据库里一条条翻评论，或者在前端一个一个删除。Waline 不一样，它自带一个完整的 Web 管理界面。</p><p>访问 <code>&lt;你的服务端地址&gt;/ui/register</code> 注册账号，第一个注册的人自动成为管理员。</p><p>登录后你能看到一个干净的管理界面，功能还挺全的：</p><ul>
<li><strong>评论审核</strong>：新评论默认需要审核，管理员通过后才会显示在前端。防止垃圾评论直接出现在你的博客上。</li>
<li><strong>批量操作</strong>：可以批量通过、拒绝或删除评论，不用一条条点。</li>
<li><strong>修改评论</strong>：管理员可以直接编辑用户提交的评论内容，比如修改敏感信息。</li>
<li><strong>标记垃圾</strong>：可疑的评论可以标记为 spam，系统会自动识别类似的评论。</li>
<li><strong>搜索过滤</strong>：可以按文章 URL、用户名、邮箱、时间范围筛选评论，找起来方便。</li>
<li><strong>用户管理</strong>：普通用户注册后会出现在管理后台的用户列表里，管理员可以调整用户角色。</li>
</ul><p>跟那些只能在前端删除评论的方案比起来，这个管理后台体验好太多了。你可以在手机浏览器上直接访问 <code>/ui</code> 管理评论，不用开电脑。</p><p>普通用户也可以通过评论框注册账号，登录后能看到自己的评论记录，修改自己的资料。</p></section></section>
<section><h2>写在最后<a href="#写在最后"><span>#</span></a></h2><p>整个过程其实挺顺利的，从开始折腾到跑通大概花了半小时。主要时间花在了搞清楚 TiDB Cloud 的 SQL Editor 怎么一句一句执行建表语句上。</p><p>Vercel 免费版对个人博客来说完全够用，TiDB Cloud 的 5GB 免费空间对于评论数据也绰绰有余。如果你跟我一样，不想把评论数据交给第三方，又不想自己维护服务器，这个组合挺合适的。</p><p>唯一要注意的是 TiDB Cloud 的免费版有连接数限制，如果你的博客流量特别大，可能需要考虑升级。不过对于大多数个人博客来说，应该不会碰到这个瓶颈。</p><hr /><p><em>写于 2026 年 7 月，折腾 Waline 评论系统的记录</em></p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/windows/mouseoptimization/</id>
      <title type="text">优化鼠标跟手性，顺便解决了完美死斗跳 ms 的问题</title>
      <published>2026-07-21T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/windows/mouseoptimization/"/>
      <summary type="text">用 BoosterX 解锁 USB 高级选项，调整电源计划和设备管理器设置，解决鼠标延迟高、不跟手的问题</summary>
      <content type="html"><![CDATA[<section><h1>优化鼠标跟手性，顺便解决了完美死斗跳 ms 的问题<a href="#优化鼠标跟手性顺便解决了完美死斗跳-ms-的问题"><span>#</span></a></h1><section><h2>起因<a href="#起因"><span>#</span></a></h2><p>最近在用的 ATKU2 老是感觉不跟手，延迟很高，开高 HZ 也没用。忍不住买了新鼠标还没到，想着趁这段时间控制变量，排查一下是不是电脑环境的问题。</p><p>无意中想起来 <a href="https://boosterx.org/zh/" target="_blank">BoosterX</a>，就是 simple 同款的那个优化软件。其实我心里不太喜欢这类软件，感觉细节没那么可控，但为了统一环境还是试了一下。</p></section><section><h2>发现电源计划的问题<a href="#发现电源计划的问题"><span>#</span></a></h2><p>用完 BoosterX 一键优化后，发现它自带了一个电源计划。试了一下，鼠标确实特别跟手了，但电脑帧数明显变低了。</p><p>于是我开始研究，发现是”完全体”电源计划的问题。</p></section><section><h2>操作步骤<a href="#操作步骤"><span>#</span></a></h2><section><h3>1. BoosterX 解锁高级选项<a href="#1-boosterx-解锁高级选项"><span>#</span></a></h3><p>先在 BoosterX 里把电源管理的高级选项解锁：</p><p></p><figure><img alt="BoosterX 解锁高级选项" loading="lazy" width="1340" height="943" src="/_astro/boosterx-unlock-advanced-options.C2URSJP__21JqiY.webp" /><figcaption>BoosterX 解锁高级选项</figcaption></figure><p></p></section><section><h3>2. 修改高性能电源计划的 USB 设置<a href="#2-修改高性能电源计划的-usb-设置"><span>#</span></a></h3><p>找到高性能电源计划，把 USB 设置里的三项全部改成 Off：</p><blockquote><p>我用的高性能是 Windows 自带的，执行 <code>powercfg -duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61</code> 就行。因为 BoosterX 自带的电源计划帧数远不如我的。</p></blockquote><p></p><figure><img alt="电源计划 USB 设置全部 Off" loading="lazy" width="519" height="592" src="/_astro/power-plan-usb-settings-off.DApCNOFu_1x6pjH.webp" /><figcaption>电源计划 USB 设置全部 Off</figcaption></figure><p></p></section><section><h3>3. 设备管理器关闭 USB 节电<a href="#3-设备管理器关闭-usb-节电"><span>#</span></a></h3><p>进入设备管理器 → 通用串行总线控制器，把所有能关的”允许计算机关闭此设备以节约电源”都关掉：</p><p></p><figure><img alt="设备管理器通用串行总线控制器" loading="lazy" width="1232" height="885" src="/_astro/device-manager-usb-controllers.5QUNRkCP_Z1JfLfA.webp" /><figcaption>设备管理器通用串行总线控制器</figcaption></figure><p></p><p></p><figure><img alt="USB 集线器电源管理取消勾选" loading="lazy" width="581" height="687" src="/_astro/usb-hub-power-management-uncheck.D342NZaI_Z2WSTB.webp" /><figcaption>USB 集线器电源管理取消勾选</figcaption></figure><p></p></section><section><h3>4. （可选）关闭 CS 全屏优化<a href="#4-可选关闭-cs-全屏优化"><span>#</span></a></h3><p>CS 记得关闭全屏优化。这个我没有认真测试，不敢保证一定要关，但关了没坏处。</p></section></section><section><h2>全流程总结<a href="#全流程总结"><span>#</span></a></h2><p>其实操作很简单：</p><ol>
<li>打开 BoosterX，快速方法里选”最佳”，点应用</li>
<li>按上面的步骤改电源计划和设备管理器</li>
</ol><p></p><figure><img alt="BoosterX 快速方法选择最佳" loading="lazy" width="1340" height="943" src="/_astro/boosterx-quick-method-best.OnLlBsLQ_ZTaOPN.webp" /><figcaption>BoosterX 快速方法选择最佳</figcaption></figure><p></p></section><section><h2>效果<a href="#效果"><span>#</span></a></h2><p>真的非常见效。起码在我 9955HX + 5070Ti Laptop 装了 XOS11 25H2 的机器上，绝对不可能是什么心理作用，实在是太明显了。</p><p>另外这软件还顺带解决了一个问题——我只有完美死斗会跳 ms，突然一下 20ms 多卡手一下，但其他场景都没有，包括完美天梯实战也没有。一开始还以为是完美自己的问题，今天才发现不是。优化完也搞定了。</p><p>鼠标只是整套优化的一环，从 BIOS、CPU/GPU 超频到系统设置的全套折腾，我另外整理了一篇：<a href="/posts/windows/ultimategameperformance/">我的游戏性能优化方案</a>。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/anime/zero-no-tsukaima/zero-no-tsukaima/</id>
      <title type="text">《零之使魔》观后感</title>
      <published>2026-07-19T00:00:00.000Z</published>
      <updated>2026-09-26T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/anime/zero-no-tsukaima/zero-no-tsukaima/"/>
      <summary type="text">经典异世界恋爱动画《零之使魔》观后感，聊聊剧情节奏、感情线处理和人物塑造方面的优缺点。</summary>
      <content type="html"><![CDATA[<section><h1>《零之使魔》观后感<a href="#零之使魔观后感"><span>#</span></a></h1><p></p><figure><img alt="《零之使魔》观后感旧版封面" loading="lazy" width="1200" height="849" src="/_astro/image-001.5WnA8ytg_Z1GmTul.webp" /><figcaption>《零之使魔》观后感旧版封面</figcaption></figure><p></p><section><h2>问题：<a href="#问题"><span>#</span></a></h2><section><h3>1. 剧情节奏方面<a href="#1-剧情节奏方面"><span>#</span></a></h3><p>最大的问题就是节奏太赶了。</p><p>基本上每个小故事都是两集左右解决，刚开始铺垫，马上进入冲突，然后迅速收尾，导致很多剧情看下来都有一种“刚热起来就结束了”的感觉。</p><p>如果能在几个大篇章之间穿插一两集露易丝和才人的日常回，让两个人不用靠各种事件推动，而是在平常相处中慢慢升温，效果应该会好很多。</p><p>毕竟这部作品最大的卖点就是露易丝和才人的感情变化，不是单纯看他们一路打怪升级。现在这种处理方式，多少有点浪费了角色之间的互动空间。</p><hr /></section><section><h3>2. 感情线处理方面<a href="#2-感情线处理方面"><span>#</span></a></h3><p>感情线的问题其实挺明显。</p><p>每次露易丝和才人闹矛盾，最后的和解过程都显得非常敷衍。尤其是才人的一些行为，放在现代人的标准里，已经不是简单的“花心”了，很多时候都快接近精神出轨甚至实质出轨。</p><p>结果露易丝每次都是气一会，然后被几句话哄好。</p><p>说实话，这种处理很难让人相信她是真的原谅了。很多时候不是观众想看男女主一直吵架，而是希望角色的情绪变化能合理一点。</p><p>这种强行快速翻篇的处理方式，看得人就是一种感觉：</p><blockquote><p>事情明明没有解决，但编剧说解决了。</p></blockquote><hr /></section><section><h3>3. 人物塑造方面<a href="#3-人物塑造方面"><span>#</span></a></h3><p>谢丝塔和女王这两个角色其实也有类似的问题。</p><p>她们明知道才人的正宫是露易丝，却依然毫不避讳地主动进攻。</p><p>异世界价值观不同，这个确实可以解释，但站在观众角度来看，多少还是会觉得膈应。</p><p>不过最大的问题还是才人。</p><p>才人这个角色真的属于典型日漫男主模板，而且是比较抽象的那种。</p><p>他明明是从现代日本来的，按理来说应该知道什么叫基本的边界感和分寸感，结果到了异世界之后直接放飞自我，一次次被欲望牵着走。</p><p>说难听点，就是经典“小头控制大头”。</p><p>很多时候看得真的很难受，因为露易丝已经明显表现出生气、不爽、吃醋了，他还是完全没有察觉。</p><p>这种“女主气炸了，男主还一脸问号”的桥段，在很多老日漫里确实很常见，但看多了是真的累。</p><hr /><p>其他角色方面，第一季前半段其实还挺有意思。</p><p>一开始那些贵族给人的感觉基本都不是什么好东西，校园霸凌、贵族阶级压迫这些元素也都有体现。</p><p>结果后面不知道怎么回事，全部开始大和解。</p><p>前面铺垫的矛盾基本都不了了之，很多设定甚至直接消失。</p><p>比如露易丝家族和丘鲁克家族之间的世仇，最后也没有真正展开。</p><p>前期还以为要搞什么贵族斗争、家族恩怨，结果后面大家全变兄弟，多少有点虎头蛇尾。</p><hr /></section></section></section>
<section><h1>优点：<a href="#优点"><span>#</span></a></h1><p>露易丝不愧是四季 ED 都安排角色曲的绝对女主。</p><p>当然，她这个角色也是非常容易让人血压升高的类型。</p><p>脾气差、傲娇、喜欢嘴硬，这些缺点确实存在。我个人其实也不是特别喜欢这种属性。</p><p>就拿《<a href="/posts/anime/oreimo/oreimo/">俺妹</a>》的桐乃来说，只看动画前几集还能忍住不骂桐乃的人，我觉得多少有点神人属性。</p><p>但话又说回来，缺点归缺点，露易丝这个角色确实塑造成功了。</p><p>她不是一个单纯的“傲娇女主模板”，随着剧情推进，她的成长、她和才人之间关系的变化，都让这个角色慢慢有了自己的魅力。</p><p>整体来说，《零之使魔》有不少明显的问题，剧情节奏、感情处理、男主塑造都有槽点。</p><p>但不可否认的是，它依然塑造出了一个非常经典的女主，也代表了一个时代异世界恋爱动画的风格。</p><p></p><figure><img alt="《零之使魔》观后感题图" loading="lazy" width="2048" height="1152" src="/_astro/image-002.CvXnJClf_Z1x8xOm.webp" /><figcaption>《零之使魔》观后感题图</figcaption></figure><p></p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/deploysearxng/</id>
      <title type="text">Docker 部署 SearXNG 完整教程</title>
      <published>2026-07-19T00:00:00.000Z</published>
      <updated>2026-08-26T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/deploysearxng/"/>
      <summary type="text">Docker快速部署 SearXNG 开源元搜索引擎，聚合多个搜索引擎结果，注重隐私的搜索利器。可以给Ai加skills然后调用</summary>
      <content type="html"><![CDATA[<section><h1>🐳 Docker 部署 SearXNG 完整教程<a href="#-docker-部署-searxng-完整教程"><span>#</span></a></h1><blockquote><p><strong>SearXNG</strong> 是一款开源的元搜索引擎，聚合多个搜索引擎的结果，同时<strong>不追踪用户、不建立用户画像</strong>，是注重隐私的搜索利器。本文将手把手演示如何通过 Docker Desktop 在 NAS 上快速部署 SearXNG。</p></blockquote><hr /><section><h2>一、搜索并拉取 SearXNG 镜像<a href="#一搜索并拉取-searxng-镜像"><span>#</span></a></h2><p>打开 Docker Desktop，进入左侧 <strong>「镜像仓库」</strong> 选项卡，在搜索框中输入 <code>SearXNG</code>：</p><p></p><figure><img alt="在 Docker 镜像仓库中搜索 SearXNG" loading="lazy" width="1467" height="876" src="/_astro/image-20260719205345924.Dh-EFoVL_JU5A7.webp" /><figcaption>在 Docker 镜像仓库中搜索 SearXNG</figcaption></figure><p></p><p>在搜索结果中选择官方镜像 <strong><code>searxng/searxng</code></strong>（下载量 50M+，⭐ 185），点击右侧的下载按钮拉取镜像。</p><hr /></section><section><h2>二、从本地镜像创建容器<a href="#二从本地镜像创建容器"><span>#</span></a></h2><p>镜像下载完成后，切换到左侧 <strong>「本地镜像」</strong> 选项卡，找到 <code>searxng/searxng</code> 镜像，点击右侧的 <strong>▶ 运行</strong> 按钮启动创建流程：</p><p></p><figure><img alt="在本地镜像中找到 SearXNG 并点击运行" loading="lazy" width="1383" height="833" src="/_astro/image-20260719205519028.BkbxDTve_ZDpmkU.webp" /><figcaption>在本地镜像中找到 SearXNG 并点击运行</figcaption></figure><p></p><p>在弹出的 <strong>「创建容器」</strong> 对话框中，确认以下设置：</p>

<table><thead><tr><th>配置项</th><th>值</th></tr></thead><tbody><tr><td>镜像名称</td><td><code>searxng/searxng:latest</code></td></tr><tr><td>容器名称</td><td><code>searxng</code>（可自定义）</td></tr><tr><td>开机自动开启</td><td>✅ 勾选</td></tr></tbody></table><p></p><figure><img alt="创建容器 - 基本设置" loading="lazy" width="1286" height="878" src="/_astro/image-20260719205532712.IrP0mo-J_ZSrIO4.webp" /><figcaption>创建容器 - 基本设置</figcaption></figure><p></p><blockquote><p>💡 <strong>提示</strong>：「资源限制」可保持默认（CPU 权重和内存限制均为自动），除非你的 NAS 性能紧张需要手动限制。</p></blockquote><p>配置完成后，点击 <strong>「下一步」</strong> 进入高级设置。</p><hr /></section><section><h2>三、配置端口映射<a href="#三配置端口映射"><span>#</span></a></h2><p>在 <strong>「高级设置 → 端口设置」</strong> 中，添加端口映射规则，将容器的 <code>8080</code> 端口映射到宿主机的 <code>8080</code> 端口：</p>

<table><thead><tr><th>本地端口</th><th>容器端口</th><th>协议</th></tr></thead><tbody><tr><td>8080</td><td>8080</td><td>TCP</td></tr></tbody></table><p></p><figure><img alt="端口映射设置" loading="lazy" width="905" height="413" src="/_astro/image-20260719205632751.B7_MIis7_17aB3E.webp" /><figcaption>端口映射设置</figcaption></figure><p></p><blockquote><p>⚠️ <strong>注意</strong>：如果 <code>8080</code> 端口已被其他服务占用，请将「本地端口」修改为其他可用端口（如 <code>8081</code>、<code>8888</code> 等），后续访问时使用对应端口即可。</p></blockquote><hr /></section><section><h2>四、配置存储路径映射<a href="#四配置存储路径映射"><span>#</span></a></h2><p>继续在同一页面的 <strong>「存储位置」</strong> 部分，添加以下两条路径映射规则，将容器内的配置和缓存目录持久化到 NAS 本地：</p>

<table><thead><tr><th>NAS 宿主机路径</th><th>容器内路径</th><th>权限</th></tr></thead><tbody><tr><td><code>/vol1/1000/Docker/SearXNG/config</code></td><td><code>/etc/searxng</code></td><td>读写</td></tr><tr><td><code>/vol1/1000/Docker/SearXNG/cache</code></td><td><code>/var/cache/searxng</code></td><td>读写</td></tr></tbody></table><p></p><figure><img alt="存储路径映射设置" loading="lazy" width="874" height="320" src="/_astro/image-20260719205634455.Fy0Fz3at_YpAlJ.webp" /><figcaption>存储路径映射设置</figcaption></figure><p></p><blockquote><p>🔑 <strong>关键提醒</strong>：<strong>务必映射 <code>/etc/searxng</code> 目录</strong>——这一步非常重要，因为我们后续需要覆盖默认的配置文件来自定义 SearXNG 的行为。如果不映射此路径，配置文件将仅存在于容器内部，容器重建后会丢失。</p></blockquote><hr /></section><section><h2>五、启动容器<a href="#五启动容器"><span>#</span></a></h2><p>完成以上所有配置后，确认并启动容器。稍等片刻，容器即可正常运行。</p><p>当容器状态显示为 <strong>🟢 运行中</strong>，说明部署成功！此时可以通过 <code>http://&lt;你的NAS-IP&gt;:8080</code> 访问 SearXNG 搜索页面。</p><hr /></section><section><h2>六、覆盖自定义配置文件<a href="#六覆盖自定义配置文件"><span>#</span></a></h2><p>为了获得更好的搜索体验，需要将默认配置文件替换为优化后的版本。</p><section><h3>6.1 下载优化配置<a href="#61-下载优化配置"><span>#</span></a></h3><p>下载我提供的优化配置文件：</p><p>🔗 <a href="https://imgbed.9ll.uk/file/1784479323375_settings.yml" target="_blank">settings.yml 下载链接</a></p></section><section><h3>6.2 替换配置文件<a href="#62-替换配置文件"><span>#</span></a></h3><ol>
<li>进入你映射到 <code>/etc/searxng</code> 的 <strong>NAS 宿主机目录</strong>（例如 <code>/vol1/1000/Docker/SearXNG/config</code>）</li>
<li>找到目录中的 <code>settings.yml</code> 文件</li>
<li>用下载的文件<strong>覆盖</strong>原文件</li>
</ol></section><section><h3>6.3 重启容器<a href="#63-重启容器"><span>#</span></a></h3><p>配置文件替换完成后，回到 Docker Desktop <strong>重启 SearXNG 容器</strong>，使新配置生效。</p><hr /></section></section><section><h2>七、访问与使用<a href="#七访问与使用"><span>#</span></a></h2><p>打开浏览器，访问：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>http://&lt;你的NAS-IP&gt;:8080</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>你将看到 SearXNG 的搜索界面，可以直接开始使用了 🎉</p><hr /></section><section><h2>📋 常见问题<a href="#-常见问题"><span>#</span></a></h2><section><h3>Q1：端口冲突怎么办？<a href="#q1端口冲突怎么办"><span>#</span></a></h3><p>修改本地端口映射即可，例如将本地端口改为 <code>8081</code>，然后通过 <code>http://&lt;NAS-IP&gt;:8081</code> 访问。</p></section><section><h3>Q2：容器启动后访问不了？<a href="#q2容器启动后访问不了"><span>#</span></a></h3><p>检查以下几点：</p><ul>
<li>容器状态是否为「运行中」</li>
<li>端口映射是否正确</li>
<li>NAS 防火墙是否放行了对应端口</li>
</ul></section><section><h3>Q3：配置文件没有生效？<a href="#q3配置文件没有生效"><span>#</span></a></h3><p>确认配置文件已正确覆盖到映射的宿主机路径，并且<strong>重启了容器</strong>。</p><hr /></section></section></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/deployimgbed/</id>
      <title type="text">Workers 部署一个免费图床</title>
      <published>2026-07-18T00:00:00.000Z</published>
      <updated>2026-08-26T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/deployimgbed/"/>
      <summary type="text">从 Fork 项目到 D1 建表再到 GitHub Actions 自动部署，一步步搭建跑在 Cloudflare Workers 上的免费图床</summary>
      <content type="html"><![CDATA[<section><h1>折腾 CloudFlare ImgBed：用 Workers 部署一个免费图床<a href="#折腾-cloudflare-imgbed用-workers-部署一个免费图床"><span>#</span></a></h1><section><h2>为什么想搞这个<a href="#为什么想搞这个"><span>#</span></a></h2><p>之前我一直用别人搭的图床，偶尔抽风挂了就得换一个，挺烦的。后来想着干脆自己搞一个算了，反正也不需要多大的量，个人博客够用就行。</p><p>研究了一圈发现 CloudFlare ImgBed 这个项目挺合适的。它跑在 Cloudflare Workers 上，数据库用 D1，存储可以选 R2 或者 Txxxram Bot，免费额度对个人图床来说完全够用。最关键的是不用自己搞服务器，Cloudflare 的边缘网络速度也不差。</p><p>部署方式有两种：Pages 和 Workers。Pages 更简单，Dashboard 里点几下就行。Workers 需要用 GitHub Actions，适合熟悉 CI/CD 流程的用户，支持 push 到 main 分支时自动部署，也可以手动触发。两种方式功能完全一样，我选了 Workers，主要想试试 GitHub Actions 自动部署的感觉。</p></section><section><h2>第一步：Fork 项目<a href="#第一步fork-项目"><span>#</span></a></h2><p>先去 GitHub 上找到 <a href="https://github.com/MarSeventh/CloudFlare-ImgBed" target="_blank">CloudFlare ImgBed</a>，点右上角 Fork 到自己的账户下。这一步没什么好说的，应该都会。</p></section><section><h2>第二步：准备 Cloudflare 资源<a href="#第二步准备-cloudflare-资源"><span>#</span></a></h2><section><h3>2.1 获取 API Token<a href="#21-获取-api-token"><span>#</span></a></h3><p>登录 <a href="https://dash.cloudflare.com/" target="_blank">Cloudflare Dashboard</a>，点头像进「我的个人资料」，左边栏有个「API 令牌」，点进去，然后点右上角的「创建令牌」。</p><p>模板选「编辑 Cloudflare Workers」就行。选完会跳到权限配置页面，这里直接默认就好，不用改什么。权限、账户资源、区域资源都是帮你配好的，往下翻到底提交就行。</p><p>创建完之后把 Token 记下来，后面要填到 GitHub Secrets 里。</p></section><section><h3>2.2 获取 Account ID<a href="#22-获取-account-id"><span>#</span></a></h3><p>Account ID 在 Dashboard 首页右侧栏的 Account Details 区域。我一开始没找到，后来发现直接在首页问 Ask AI 就行，它会告诉你在哪。</p><p></p><figure><img alt="Dashboard 首页 - 用 Ask AI 查 Account ID" loading="lazy" width="2048" height="1011" src="/_astro/get-account-id.JPbVSDor_ZiN9ut.webp" /><figcaption>Dashboard 首页 - 用 Ask AI 查 Account ID</figcaption></figure><p></p><p>复制下来备用。</p></section><section><h3>2.3 创建 D1 数据库<a href="#23-创建-d1-数据库"><span>#</span></a></h3><p>数据库用来存图床的文件元数据，比如文件名、大小、上传时间之类的。KV 和 D1 二选一，我选了 D1，免费额度比 KV 多不少。</p><p>进 Dashboard 左侧「存储和数据库」→「D1 SQL 数据库」，点「创建数据库」。名字必须填的 <code>img_d1</code>，区域选了亚太地区，然后点创建。</p><p>创建完之后回到 D1 数据库的概述页面，这里能看到数据库 ID。<strong>点旁边的复制按钮把 ID 复制下来</strong>，后面配置 GitHub Secrets 要用。</p><p></p><figure><img alt="D1 概述页 - 红色箭头指向复制数据库 ID 的按钮" loading="lazy" width="2048" height="1011" src="/_astro/d1-copy-database-id.CkXt_3H9_Z1WAjQV.webp" /><figcaption>D1 概述页 - 红色箭头指向复制数据库 ID 的按钮</figcaption></figure><p></p><p>接下来去 D1 的「控制台」选项卡，把初始化 SQL 执行一下。这段 SQL 会建好几个表：<code>files</code>（存文件信息）、<code>settings</code>（存配置）、<code>index_operations</code>（索引操作记录）之类的，还有索引和触发器。</p><p></p><figure><img alt="D1 控制台 - 执行 CREATE TABLE 成功，显示&quot;此查询已成功执行&quot;" loading="lazy" width="2048" height="990" src="/_astro/d1-console-execute-sql.DYCELYag_Z4VT9K.webp" /><figcaption>D1 控制台 - 执行 CREATE TABLE 成功，显示"此查询已成功执行"</figcaption></figure><p></p><p>直接复制粘贴执行就行。<strong>要分段输入</strong>，别一次全贴进去，容易出问题。控制台会显示每条语句的执行结果，看到「此查询已成功执行」就说明没问题。</p><p>这里贴一下主要的建表语句，方便你对照检查：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>CREATE</span><span> </span><span>TABLE</span><span> </span><span>IF</span><span> </span><span>NOT</span><span> </span><span>EXISTS</span><span> files (</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>id </span><span>TEXT</span><span> </span><span>PRIMARY KEY</span><span>,</span></div></div><div><div><div>3</div></div><div><span>    </span><span>value</span><span> </span><span>TEXT</span><span>,</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>metadata </span><span>TEXT</span><span> </span><span>NOT NULL</span><span>,</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>file_name </span><span>TEXT</span><span>,</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>file_type </span><span>TEXT</span><span>,</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>file_size </span><span>TEXT</span><span>,</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>upload_ip </span><span>TEXT</span><span>,</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>upload_address </span><span>TEXT</span><span>,</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>list_type </span><span>TEXT</span><span>,</span></div></div><div><div><div>11</div></div><div><span>    </span><span>timestamp</span><span> </span><span>INTEGER</span><span>,</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>label </span><span>TEXT</span><span>,</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>directory </span><span>TEXT</span><span>,</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>channel </span><span>TEXT</span><span>,</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>channel_name </span><span>TEXT</span><span>,</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>tg_file_id </span><span>TEXT</span><span>,</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>tg_chat_id </span><span>TEXT</span><span>,</span></div></div><div><div><div>18</div></div><div><span><span>    </span></span><span>tg_bot_token </span><span>TEXT</span><span>,</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>is_chunked </span><span>BOOLEAN</span><span> </span><span>DEFAULT</span><span> FALSE,</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>tags </span><span>TEXT</span><span>,</span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>created_at </span><span>DATETIME</span><span> </span><span>DEFAULT</span><span> CURRENT_TIMESTAMP,</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>updated_at </span><span>DATETIME</span><span> </span><span>DEFAULT</span><span> CURRENT_TIMESTAMP</span></div></div><div><div><div>23</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>settings</code>、<code>index_operations</code>、<code>index_metadata</code>、<code>other_data</code> 几个表，以及一堆索引和触发器。完整的 SQL 在项目的 <a href="https://github.com/MarSeventh/CloudFlare-ImgBed/blob/main/database/init.sql" target="_blank">database/init.sql</a> 里。</p><blockquote><p>踩坑提醒：如果你之前已经建过表想重新来，先把 SQL 开头那几行 <code>DROP TABLE</code> 的注释去掉，执行一遍清掉旧表，再跑建表语句。生产环境慎用这个操作。</p></blockquote></section><section><h3>2.4 创建 R2 存储桶（可选）<a href="#24-创建-r2-存储桶可选"><span>#</span></a></h3><p>如果需要使用 R2 存储渠道，进 Dashboard 左侧「存储和数据库」→「R2 对象存储」，点「创建存储桶」，记下存储桶名称。我暂时没用 R2，先用默认的渠道跑起来再说。</p></section></section><section><h2>第三步：绑定 D1 数据库<a href="#第三步绑定-d1-数据库"><span>#</span></a></h2><p>在配置 GitHub Secrets 之前，先把 D1 数据库绑定到 Worker 上。进 Dashboard → Workers 和 Pages → 找到你刚创建的 Worker（默认叫 <code>cloudflare-imgbed</code>），点进去，选「绑定」标签页。</p><p></p><figure><img alt="Worker 绑定页面 - 点右上角的「添加绑定」按钮" loading="lazy" width="2048" height="1011" src="/_astro/worker-bindings-page.B0JVM7oq_Z11UHY1.webp" /><figcaption>Worker 绑定页面 - 点右上角的「添加绑定」按钮</figcaption></figure><p></p><p>点「添加绑定」，选 D1 数据库。变量名称填 <code>img_d1</code>（跟代码里用的名字一致），D1 数据库选你之前创建的那个。</p><p></p><figure><img alt="添加 D1 绑定 - 变量名称填 img_d1，数据库选 img_d1" loading="lazy" width="923" height="467" src="/_astro/add-d1-binding-dialog.D2moFlg5_Z1Hlp6L.webp" /><figcaption>添加 D1 绑定 - 变量名称填 img_d1，数据库选 img_d1</figcaption></figure><p></p><p>点「添加绑定」就搞定了。</p></section><section><h2>第四步：配置 GitHub Secrets<a href="#第四步配置-github-secrets"><span>#</span></a></h2><p>回到 fork 的仓库，进 <strong>Settings → 机密和变量 → 操作</strong>。</p><p>一开始仓库里什么都没有，是空的。</p><p>点「New repository secret」把需要的值一个个加上。Secrets 是加密存储的，Actions 日志里不会泄露。别用 Variables，public 仓库的话所有人都能看到你的配置。</p><p>如果需要配置部署保护规则（比如环境机密），也可以在 Settings → 环境里设置。</p><p>最终需要加的 Secrets 如下：</p><p></p><figure><img alt="GitHub 仓库机密配置完成 - 三个必填项都加好了" loading="lazy" width="2048" height="997" src="/_astro/github-secrets-configured.C_ytMCBt_Z1zcERH.webp" /><figcaption>GitHub 仓库机密配置完成 - 三个必填项都加好了</figcaption></figure><p></p>

<table><thead><tr><th>Secret 名称</th><th>说明</th><th>是否必填</th></tr></thead><tbody><tr><td><code>CLOUDFLARE_API_TOKEN</code></td><td>Cloudflare API Token</td><td>必填</td></tr><tr><td><code>CLOUDFLARE_ACCOUNT_ID</code></td><td>Cloudflare Account ID</td><td>必填</td></tr><tr><td><code>D1_DATABASE_ID</code></td><td>D1 数据库 ID</td><td>和 KV 二选一</td></tr><tr><td><code>KV_NAMESPACE_ID</code></td><td>KV 命名空间 ID</td><td>和 D1 二选一</td></tr><tr><td><code>R2_BUCKET_NAME</code></td><td>R2 存储桶名称</td><td>可选</td></tr><tr><td><code>WORKER_NAME</code></td><td>Worker 名称</td><td>可选，默认 <code>cloudflare-imgbed</code></td></tr><tr><td><code>WORKER_VARS</code></td><td>业务环境变量（JSON）</td><td>可选</td></tr></tbody></table><p>说实话大部分配置不用在这写。像存储渠道、审查策略什么的，部署完在管理面板里都能改。我一开始把什么都填了，后来发现根本没必要。<code>WORKER_VARS</code> 只有当你需要配 Telegram Bot 这类特殊渠道的时候才用得上，而且格式要是 JSON：</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>"TG_BOT_TOKEN"</span><span>: </span><span>"your-bot-token"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"TG_CHAT_ID"</span><span>: </span><span>"your-chat-id"</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>不过真要用 Telegram 渠道，我建议还是部署完在管理面板里配，比在这填 JSON 方便多了。</p></section><section><h2>第五步：运行部署<a href="#第五步运行部署"><span>#</span></a></h2><p>进仓库的 <strong>操作</strong> 页面，左边选「Deploy to Cloudflare Workers」。</p><p></p><figure><img alt="GitHub Actions - 选择 Deploy to Cloudflare Workers 工作流" loading="lazy" width="2048" height="997" src="/_astro/github-actions-select-deploy.BkpF_-Uv_22C8Af.webp" /><figcaption>GitHub Actions - 选择 Deploy to Cloudflare Workers 工作流</figcaption></figure><p></p><p>点「Run workflow」，选 main 分支，开始跑。</p><p></p><figure><img alt="GitHub Actions - 部署进行中，deploy 任务正在执行" loading="lazy" width="2048" height="997" src="/_astro/github-actions-deploy-running.B-NBPl-j_ZxBQ95.webp" /><figcaption>GitHub Actions - 部署进行中，deploy 任务正在执行</figcaption></figure><p></p><p>部署完成后，可以通过 <code>https://&lt;worker-name&gt;.&lt;account-subdomain&gt;.workers.dev</code> 访问。第一次跑可能会慢一点，耐心等一下。<strong>可能国内访问不了，可以直接看我的Woker优先IP的文章</strong>。</p></section><section><h2>最后看看效果<a href="#最后看看效果"><span>#</span></a></h2><p>部署完绑定好之后，访问你的 Worker 地址，应该能看到图床界面了。</p><p></p><figure><img alt="部署完成 - CloudFlare ImgBed 图床界面，支持拖拽上传" loading="lazy" width="2048" height="997" src="/_astro/imgbed-final-result.DBFh1VF6_Z29kkyM.webp" /><figcaption>部署完成 - CloudFlare ImgBed 图床界面，支持拖拽上传</figcaption></figure><p></p><p>界面还挺好看的，支持拖拽上传、点击上传、Ctrl+V 粘贴上传，底部还有文件管理和统计。默认就能用，挺省心的。</p><p>部署完成后，记得去管理面板配置存储渠道，不然图片传不上去。</p></section><section><h2>踩坑记录<a href="#踩坑记录"><span>#</span></a></h2><p>整个过程其实挺顺利的，就几个小地方需要注意：</p><ol>
<li><strong>D1 初始化 SQL 要分段执行</strong>：一次全贴进去可能会报错，我分了几次才跑成功。控制台会显示每条语句的执行结果，看到「此查询已成功执行」就说明没问题</li>
<li><strong>WORKER_VARS 不用急着配</strong>：大部分业务设置在管理面板都能改，不用一开始就写死在 Secrets 里</li>
<li><strong>部署前记得绑定 D1</strong>：光配置 Secrets 不够，还得在 Cloudflare 这边把数据库绑到 Worker 上，不然部署完图床打开是白的</li>
</ol></section><section><h2>接下来打算<a href="#接下来打算"><span>#</span></a></h2><p>部署完能跑了，下次出个配置文章</p><hr /><p><em>写于 2026 年 7 月，折腾 CloudFlare ImgBed 部署的记录</em></p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/deployumami/</id>
      <title type="text">网站每天多少人访问你知道吗？用 Vercel 零成本搭了一个 Umami 网站统计系统</title>
      <published>2026-07-18T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/deployumami/"/>
      <summary type="text">用 Vercel + Neon 免费部署 Umami 网站统计系统，从 Fork 项目到上线全流程记录，零成本搞定访客数据</summary>
      <content type="html"><![CDATA[<section><h1>网站每天多少人访问你知道吗？用 Vercel 零成本搭了一个 Umami 网站统计系统<a href="#网站每天多少人访问你知道吗用-vercel-零成本搭了一个-umami-网站统计系统"><span>#</span></a></h1><p>搭了网站之后，我一直想看看每天到底有多少人来访问。（网站本身怎么搭的，之前写过：<a href="/posts/deploy/deployfirefly/">不用服务器搭建 Firefly 博客</a>）</p><p>说实话，之前一直没搞统计，不是不想看数据，就是觉得 Google Analytics 太重了，配一堆东西看着就头大。后来我一想，有没有那种简单点的，能直接看到访客数、来源、热门页面就够了。</p><p>搜了一圈发现了 Umami，开源的网站统计工具，界面挺干净的。最关键的是——能直接部署到 Vercel 上，不用买服务器，零成本。</p><p>我一想，反正也不花钱，试试呗。</p><p>搭好之后用了几天，效果确实不错。先放几张图给你们看看：</p><p>这是 Umami 的概览页面，访客来自哪些国家一目了然，世界地图上直接能看到分布：</p><p></p><figure><img alt="Umami 概览页面" loading="lazy" width="2048" height="950" src="/_astro/01-umami%E6%A6%82%E8%A7%88.hKhW-9IL_25sGIs.webp" /><figcaption>Umami 概览页面</figcaption></figure><p></p><p>访客数、访问次数、浏览量、跳出率、平均访问时长，数据摆在那里：</p><p></p><figure><img alt="Umami 实时数据" loading="lazy" width="1080" height="541" src="/_astro/02-umami%E5%AE%9E%E6%97%B6%E6%95%B0%E6%8D%AE.CXVFBJn3_ZuGwrW.webp" /><figcaption>Umami 实时数据</figcaption></figure><p></p><p>热门页面排行也很清楚，哪篇文章有人看、哪篇没人看，心里有数：</p><p></p><figure><img alt="Umami 热门页面" loading="lazy" width="1080" height="626" src="/_astro/03-umami%E7%83%AD%E9%97%A8%E9%A1%B5%E9%9D%A2.B2hj9AiP_csGsB.webp" /><figcaption>Umami 热门页面</figcaption></figure><p></p><p>还有来源分析，从哪个搜索引擎来的、哪个域名引过来的，都有：</p><p></p><figure><img alt="Umami 来源分析" loading="lazy" width="1080" height="642" src="/_astro/04-umami%E6%9D%A5%E6%BA%90%E5%88%86%E6%9E%90.DaUMAaQg_Zm7dh6.webp" /><figcaption>Umami 来源分析</figcaption></figure><p></p><p>看到这些数据之后，我才真正知道自己网站的流量情况。以前全靠猜，现在数据就摆在后台里。</p><p>下面说说我是怎么搭的。</p><section><h2>为什么选 Umami<a href="#为什么选-umami"><span>#</span></a></h2><p>其实一开始我不想用 Neon 的，因为它的免费计划远不如 TiDB。TiDB 的免费额度很大，听起来很诱人。</p><p>但问题是——TiDB 是 MySQL 的，Umami 用的是 PostgreSQL，不太兼容。整了半天还是放弃了，一直构建失败给我整红了。</p><p>后来我一想，算了，换个思路吧。Umami 官方说支持 Vercel 一键部署，Neon 提供免费的 PostgreSQL，那就试试 Neon。</p><p>事实证明这个选择是对的。部署过程比我想象的简单很多，而且 Neon 的免费套餐对个人站点来说完全够用——0.5 GB 存储、100 个项目上限，足够了。</p></section><section><h2>部署过程<a href="#部署过程"><span>#</span></a></h2><section><h3>一、Fork 项目<a href="#一fork-项目"><span>#</span></a></h3><p>打开 Umami 的 GitHub 项目，点右上角的”复刻”，把代码拷到自己账号下。</p><p>项目地址：<code>https://github.com/umami-software/umami</code></p><p></p><figure><img alt="GitHub 复刻 Umami 项目" loading="lazy" width="2048" height="992" src="/_astro/05-github%E5%A4%8D%E5%88%BB%E9%A1%B9%E7%9B%AE.Com5e1S6_Z1AGFth.webp" /><figcaption>GitHub 复刻 Umami 项目</figcaption></figure><p></p></section><section><h3>二、在 Vercel 导入项目<a href="#二在-vercel-导入项目"><span>#</span></a></h3><p>登录 Vercel 控制台，左侧菜单点”项目”，然后右上角点”添加 New”→“项目”。</p><p></p><figure><img alt="Vercel 新建项目" loading="lazy" width="1153" height="1115" src="/_astro/06-vercel%E6%96%B0%E5%BB%BA%E9%A1%B9%E7%9B%AE.CsI1IFUL_Z1I79qL.webp" /><figcaption>Vercel 新建项目</figcaption></figure><p></p><p>在”导入 Git 仓库”下面找到刚复刻的 umami 项目，点”导入”。</p><p></p><figure><img alt="Vercel 导入 Git 仓库" loading="lazy" width="1001" height="878" src="/_astro/07-vercel%E5%AF%BC%E5%85%A5%E4%BB%93%E5%BA%93.BVbbQ-Su_Z1Mhd2y.webp" /><figcaption>Vercel 导入 Git 仓库</figcaption></figure><p></p><p>项目名称可以随便填，我直接用的 “umami”。其他保持默认就行，直接点”部署”。</p><p>这一步会部署失败，因为还没配数据库，不用管它。</p></section><section><h3>三、创建 Neon 数据库<a href="#三创建-neon-数据库"><span>#</span></a></h3><p>回到 Vercel 控制台，左侧菜单找到”存储”，点进去。然后点右上角的”创建 Database”。</p><p></p><figure><img alt="Vercel 部署设置" loading="lazy" width="2048" height="887" src="/_astro/08-vercel%E9%83%A8%E7%BD%B2%E8%AE%BE%E7%BD%AE.BIzZHirr_Z1Wxafc.webp" /><figcaption>Vercel 部署设置</figcaption></figure><p></p><p></p><figure><img alt="Vercel 存储页面" loading="lazy" width="793" height="1186" src="/_astro/09-vercel%E5%AD%98%E5%82%A8%E9%A1%B5%E9%9D%A2.DniQFrUO_1qIUHB.webp" /><figcaption>Vercel 存储页面</figcaption></figure><p></p><p>在弹出的窗口里选 Neon（无服务器 Postgres），然后点”继续”。</p><p></p><figure><img alt="选择 Neon 数据库" loading="lazy" width="764" height="1229" src="/_astro/10-%E9%80%89%E6%8B%A9neon%E6%95%B0%E6%8D%AE%E5%BA%93.OmaYPpAi_27n6S2.webp" /><figcaption>选择 Neon 数据库</figcaption></figure><p></p><p>区域选近一点的，我选了 Singapore（Southeast）。其他保持默认，往下拉点”继续”。</p><p></p><figure><img alt="Neon 区域选择" loading="lazy" width="2048" height="648" src="/_astro/11-neon%E5%8C%BA%E5%9F%9F%E9%80%89%E6%8B%A9.C9X8Bj9O_18wULb.webp" /><figcaption>Neon 区域选择</figcaption></figure><p></p><p>数据库名字可以随便填，确认一下信息没问题就点安装。</p></section><section><h3>四、关联项目<a href="#四关联项目"><span>#</span></a></h3><p>数据库创建好之后，点”Connect to Project”，选择你刚才创建的 umami 项目，然后点”Connect Project”。</p></section><section><h3>五、重新部署<a href="#五重新部署"><span>#</span></a></h3><p>回到 Vercel 的”部署记录”页面，找到刚才失败的那条记录，点右侧三个点，选”重新部署”。</p><p>等几分钟，看到绿色的”就绪”就说明部署成功了。</p><p>不过我折腾的过程中遇到了不少问题。你看这个部署记录，一堆红色的”错误”，给我整红了。反复试了好多次才成功，中间有几次差点放弃。</p><p></p><figure><img alt="部署记录——一堆红色错误" loading="lazy" width="2048" height="613" src="/_astro/13-%E9%83%A8%E7%BD%B2%E8%AE%B0%E5%BD%95%E4%B8%8E%E5%9F%9F%E5%90%8D%E9%85%8D%E7%BD%AE.Cj3njJLe_1LSnll.webp" /><figcaption>部署记录——一堆红色错误</figcaption></figure><p></p><p>后来发现是数据库连接的问题，重新关联了一次项目之后就好了。如果你也遇到部署失败，先检查一下数据库有没有关联成功。</p></section></section><section><h2>登录后台<a href="#登录后台"><span>#</span></a></h2><p>部署成功后，打开你的 Umami 地址（Vercel 给的那个 .vercel.app 域名），就能看到登录页面。</p><p></p><figure><img alt="Umami 登录页面" loading="lazy" width="1080" height="774" src="/_astro/12-umami%E7%99%BB%E5%BD%95%E9%A1%B5%E9%9D%A2.B81OiKRQ_Z1wcNfN.webp" /><figcaption>Umami 登录页面</figcaption></figure><p></p><p>初始账号密码是：</p><ul>
<li>用户名：<code>admin</code></li>
<li>密码：<code>umami</code></li>
</ul><p>登录后记得改密码。</p></section><section><h2>绑定自定义域名<a href="#绑定自定义域名"><span>#</span></a></h2><p>如果你有自己的域名，可以在 Vercel 的”域名”页面绑定。我绑的是 <code>umami.9ll.uk</code>，配置好之后状态显示”Valid 配置”就行了。（上面那张部署记录截图底部也能看到域名配置的部分。）</p><p>绑完域名之后，你的 Umami 就可以通过自己的域名访问了，看起来也更专业一点。</p></section><section><h2>最后<a href="#最后"><span>#</span></a></h2><p>折腾这些东西最大的乐趣，其实不是最后那个结果，而是过程中折腾的那些体验。</p><p>这次搭 Umami 总体来说还行，虽然中间踩了不少坑——TiDB 不兼容、部署反复失败、数据库关联出问题——但折腾完之后用起来确实挺舒服的。数据一目了然，每天打开后台看看访客数、来源、热门页面，心里有数多了。</p><p>如果你也搭了网站但一直没关注数据，可以试试 Umami。零成本、部署简单、数据清晰，对个人站长来说够用了。</p><hr /><p><em>写于 2026 年 7 月，折腾 Umami 网站统计的记录</em></p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/cloudflaretunnel/</id>
      <title type="text">Cloudflare Tunnel Docker部署教程</title>
      <published>2026-07-18T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/cloudflaretunnel/"/>
      <summary type="text">用Docker部署Cloudflare Tunnel，把内网服务暴露到公网</summary>
      <content type="html"><![CDATA[<section><h2>为什么想搞这个<a href="#为什么想搞这个"><span>#</span></a></h2><p>我家里有台 NAS，上面跑了不少服务。平时在家用局域网访问挺方便的，但出门在外就抓瞎了。</p><p>之前试过端口转发，但总觉得不太安全，而且公网 IP 还是动态的，每次都要重新连。后来了解到 Cloudflare Tunnel，发现这东西挺合适的——不用开端口，不用公网 IP，也不用折腾 DDNS，流量还走 Cloudflare 的网络加密。</p><p>整个过程其实不复杂，跟着 Cloudflare 的界面一步步来就行。</p></section>
<section><h2>第一步：进入 Zero Trust 控制台<a href="#第一步进入-zero-trust-控制台"><span>#</span></a></h2><p>登录 Cloudflare Dashboard，左侧菜单往下翻，找到”保护和连接”里的 <strong>Zero Trust</strong>，点进去。</p><p></p><figure><img alt="进入Zero Trust" loading="lazy" width="349" height="1187" src="/_astro/step01-zero-trust-dashboard.Cgyr-Cef_Z1bhCwa.webp" /><figcaption>进入Zero Trust</figcaption></figure><p></p></section>
<section><h2>第二步：创建隧道<a href="#第二步创建隧道"><span>#</span></a></h2><p>进去之后点左侧的”连接器”，然后点右上角的”创建隧道”。</p><p></p><figure><img alt="连接器页面" loading="lazy" width="2048" height="1001" src="/_astro/step02-connectors-page.BM9Pmt6Y_Z1IBug4.webp" /><figcaption>连接器页面</figcaption></figure><p></p><p>隧道类型选 <strong>Cloudflared</strong>，点”选择 Cloudflared”。</p><p></p><figure><img alt="选择Cloudflared" loading="lazy" width="1350" height="659" src="/_astro/step03-select-cloudflared.DN24zcLb_iRcA9.webp" /><figcaption>选择Cloudflared</figcaption></figure><p></p><p>接下来给隧道起个名字，起完之后会到配置页面。这里选 <strong>Docker</strong>，会给你一段 docker run 命令，直接复制就行。</p><p></p><figure><img alt="获取Docker命令" loading="lazy" width="1864" height="954" src="/_astro/step04-docker-run-command.B9zpit5f_1gIblK.webp" /><figcaption>获取Docker命令</figcaption></figure><p></p></section>
<section><h2>第三步：在服务器上部署<a href="#第三步在服务器上部署"><span>#</span></a></h2><p>SSH 到你的服务器，把刚才复制的命令粘贴进去。第一次跑会拉取镜像，等一会儿就好。</p><p></p><figure><img alt="拉取Docker镜像" loading="lazy" width="1357" height="1028" src="/_astro/step05-pull-docker-image.CoKGbB0s_Z292DuK.webp" /><figcaption>拉取Docker镜像</figcaption></figure><p></p><p>镜像拉完、容器跑起来之后，回到 Cloudflare Dashboard，能看到连接器状态变成”已连接”。确认没问题后点”下一步”。</p><p></p><figure><img alt="连接器已连接" loading="lazy" width="2048" height="975" src="/_astro/step06-connector-connected.qHWK_dTn_1lQf0Y.webp" /><figcaption>连接器已连接</figcaption></figure><p></p></section>
<section><h2>第四步：配置公网访问<a href="#第四步配置公网访问"><span>#</span></a></h2><p>接下来设置把哪个域名指向哪个本地服务。</p><p>子域名填你想用的，比如我填了 <code>fnos</code>，域名选你托管在 Cloudflare 的那个，完整主机名就是 <code>fnos.9ll.us</code>。</p><p>服务类型选 <strong>HTTP</strong>，URL 填你本地服务的地址和端口。我填的是 <code>localhost:5244</code>，这是 fnOS 的 Web 端口。</p><p>填完点”保存设置”就好了。</p><p></p><figure><img alt="配置公共主机名" loading="lazy" width="2048" height="973" src="/_astro/step07-public-hostname.Dyqwarrz_ZOVa2z.webp" /><figcaption>配置公共主机名</figcaption></figure><p></p><p>到这里正常来说就结束了，通过 <code>fnos.9ll.us</code> 就能访问你 NAS 上的服务了。</p><p>对了，如果你暴露的是给网页前端调用的 API，大概率还会撞上跨域问题。我给 Tunnel 后面的 Memos API 补 CORS 的过程记在<a href="/posts/network/cloudflare_cors/">这篇</a>里，遇到的话可以参考。</p></section>
<section><h2>代理环境下的坑<a href="#代理环境下的坑"><span>#</span></a></h2><p>但是，如果你的 Docker 宿主机开了代理，而且 tunnel 流量也被代理了，那可能会连不上。</p><p>这种情况下需要做两件事：</p><ol>
<li>把本地地址改成局域网 IP（比如 <code>192.168.x.x</code>），不要用 <code>localhost</code></li>
<li>代理的 Fake-IP-Filter 里加上这几个域名：</li>
</ol><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>+.argotunnel.com</span></div></div><div><div><div>2</div></div><div><span>+.v2.argotunnel.com</span></div></div><div><div><div>3</div></div><div><span>api.cloudflare.com</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>+.cloudflare.com</code> 看情况加不加都行。</p><p></p><figure><img alt="Fake-IP-Filter配置" loading="lazy" width="1336" height="602" src="/_astro/step08-fake-ip-filter.C4dB7GNT_ckgtI.webp" /><figcaption>Fake-IP-Filter配置</figcaption></figure><p></p><p>这几个域名不加的话，Cloudflare Tunnel 的连接可能会被代理软件劫持或者解析到错误的地址，导致隧道连不上。加上之后让它走直连就正常了。</p></section>
<section><h2>fnOS 用户的另一个选择<a href="#fnos-用户的另一个选择"><span>#</span></a></h2><p>如果你是飞牛 fnOS 用户，应用商店里有个 Cloudflare Tunnel 的 GUI 应用，操作比 Docker 简单不少，界面化配置挺方便的。</p><p>不过我试下来感觉没有 Docker 容器那么稳，可控性也差一些。而且这个应用如果连不上报错的话，去设置里把协议改一下——Docker 默认用的是 QUIC，应用默认是 HTTP/2，两个都试试看哪个能通。</p><p></p><figure><img alt="fnOS Tunnel应用设置" loading="lazy" width="1370" height="757" src="/_astro/step09-fnos-tunnel-app.YCUvFIzR_v5KbP.webp" /><figcaption>fnOS Tunnel应用设置</figcaption></figure><p></p><p>我个人还是推荐 Docker 方式，毕竟出了问题排查起来更方便，日志也能直接看。</p><p>最后补一句：把内网服务挂到公网不止 Tunnel 这一条路。我后来在家里那种只有 IPv6 的环境下，又用 Lucky + Cloudflare 搞了一套<a href="/posts/network/ipv6-cloudflare-lucky/">双栈 HTTPS 方案</a>，不走隧道，有公网 IPv6 的话值得看看。</p><hr /><p><em>写于 2026 年 7 月，折腾 Cloudflare Tunnel 的记录</em></p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/istoreosinstallopenclash/istoreosinstallopenclash/</id>
      <title type="text">iStoreOS 安装 OpenClash</title>
      <published>2026-07-17T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/istoreosinstallopenclash/istoreosinstallopenclash/"/>
      <summary type="text">在 iStoreOS 上通过命令行安装 OpenClash 并配置代理的完整流程，包含依赖安装、ipk 部署和首次使用的详细步骤</summary>
      <content type="html"><![CDATA[<p>上一篇写了<a href="/posts/network/fnosvmistoreos/fnos-istoreos/">怎么在 FNOS 上装 iStoreOS 虚拟机</a>，装完之后总得干点啥吧。iStoreOS 本身就是 OpenWrt 的改版，拿来跑 OpenClash 再合适不过了。</p>
<p>其实我之前一直用的是软路由方案，后来换了 NAS 就想着能不能把软路由也虚拟化了，省一台设备。装完 iStoreOS 虚拟机之后第一件事，就是把 OpenClash 搞上。</p>
<section><h2>一、找到后台地址<a href="#一找到后台地址"><span>#</span></a></h2><p>iStoreOS 开机之后，输入 <code>quickstart</code>，然后选第一个回车。</p><p></p><figure><img alt="quickstart 查看 IP" loading="lazy" width="1885" height="1178" src="/_astro/01-quickstart%E6%9F%A5%E7%9C%8BIP.Clp7HHc2_1PC5xE.webp" /><figcaption>quickstart 查看 IP</figcaption></figure><p></p><p>然后就能看到后台管理地址了。我这边显示的是 <code>192.168.31.195</code>，你的可能不一样，看实际显示的就行。</p><p>浏览器打开这个地址，就能进 Web 管理页面。</p></section>
<section><h2>二、改密码<a href="#二改密码"><span>#</span></a></h2><p>进去之后第一件事，建议先改密码。</p><p>进 <strong>系统 → 管理权</strong>，找到「路由器密码」那栏，改成你自己的。</p><p></p><figure><img alt="设置 root 密码" loading="lazy" width="2048" height="990" src="/_astro/02-%E8%AE%BE%E7%BD%AEroot%E5%AF%86%E7%A0%81.UbK-8a67_VaaCT.webp" /><figcaption>设置 root 密码</figcaption></figure><p></p><p>不改的话默认密码好像挺简单的，后面进终端要用 root 登录，还是改一下比较安心。</p></section>
<section><h2>三、装依赖<a href="#三装依赖"><span>#</span></a></h2><p>这一步是最容易出问题的，</p><p>进 <strong>服务 → 终端</strong>（TTYD），用户名 <code>root</code>，密码填刚才设的。</p><p>先更新一下软件源：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>opkg</span><span> </span><span>update</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p></p><figure><img alt="终端 opkg update" loading="lazy" width="2048" height="990" src="/_astro/03-%E7%BB%88%E7%AB%AFopkg-update.BwzzrGON_1iOwHe.webp" /><figcaption>终端 opkg update</figcaption></figure><p></p><p>这一步可能会冒出一堆报错，类似 <code>Signature check failed</code> 或者 <code>returned 8</code>。我第一次看到这个报错的时候还挺慌的，以为哪里搞错了。后来查了一下，好像是签名验证的问题，不影响后面安装。不用管它，继续往下走就行。</p><p>然后装依赖包：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>opkg</span><span> </span><span>install</span><span> </span><span>bash</span><span> </span><span>iptables</span><span> </span><span>dnsmasq-full</span><span> </span><span>curl</span><span> </span><span>ca-bundle</span><span> </span><span>ipset</span><span> </span><span>ip-full</span><span> </span><span>iptables-mod-tproxy</span><span> </span><span>iptables-mod-extra</span><span> </span><span>ruby</span><span> </span><span>ruby-yaml</span><span> </span><span>kmod-tun</span><span> </span><span>kmod-inet-diag</span><span> </span><span>unzip</span><span> </span><span>luci-compat</span><span> </span><span>luci</span><span> </span><span>luci-base</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p></p><figure><img alt="安装依赖包" loading="lazy" width="2048" height="586" src="/_astro/04-%E5%AE%89%E8%A3%85%E4%BE%9D%E8%B5%96%E5%8C%85.CB-DbXWJ_ZJLLqa.webp" /><figcaption>安装依赖包</figcaption></figure><p></p><p>装的时候会看到一堆输出，不用逐行看，只要最后没报致命错误就行。有些包可能会提示已经装过了，那说明 iStoreOS 自带了一部分，跳过就好。</p><p>整个依赖安装大概要等个一两分钟，看你的网络情况。</p></section>
<section><h2>四、下载并安装 OpenClash<a href="#四下载并安装-openclash"><span>#</span></a></h2><p>依赖装好之后，就可以装 OpenClash 本体了。</p><p>去 GitHub 的 <a href="https://github.com/vernesong/OpenClash/releases" target="_blank">OpenClash Releases</a> 页面，找到最新版本，下载 <code>ipk</code> 文件。</p><p></p><figure><img alt="下载 OpenClash" loading="lazy" width="1090" height="289" src="/_astro/05-%E4%B8%8B%E8%BD%BDOpenClash.BIJ4vdCQ_1xbXsR.webp" /><figcaption>下载 OpenClash</figcaption></figure><p></p><p>下载的时候注意看一下文件名，别下错了。一般下载量最大的那个就是。</p><p>然后进 <strong>系统 → 软件包</strong>，点「上传软件包」，把刚才下载的 ipk 传上去。</p><p></p><figure><img alt="上传软件包" loading="lazy" width="2048" height="990" src="/_astro/06-%E4%B8%8A%E4%BC%A0%E8%BD%AF%E4%BB%B6%E5%8C%85.ehSqAExH_1fAUe5.webp" /><figcaption>上传软件包</figcaption></figure><p></p><p>上传完会弹个确认框，点安装就行。安装过程很快，几秒钟就完事。</p></section>
<section><h2>五、首次使用<a href="#五首次使用"><span>#</span></a></h2><p>装完之后刷新网页，<strong>服务</strong> 菜单下面就能看到 OpenClash 了。如果没看到，多刷新几次，或者等一会儿再试。</p><p>第一次进去可能会提示你下载内核，还会给你一个已经测速好的 CDN 列表。选个延迟低的点下载就行。</p><p></p><figure><img alt="Mihomo 管理页面" loading="lazy" width="2048" height="990" src="/_astro/07-Mihomo%E7%AE%A1%E7%90%86%E9%A1%B5%E9%9D%A2.AKVbZn14_Z1HCwN2.webp" /><figcaption>Mihomo 管理页面</figcaption></figure><p></p><p>内核下好之后，导入你的订阅就可以用了。订阅哪来都行，想零成本自建的话，可以看我用 Cloudflare Pages 白嫖节点那篇：<a href="/posts/deploy/cf-pages-edgetunnel/">不用服务器自建节点</a>。</p></section>
<section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><ol>
<li><code>quickstart</code> → 选第一个 → 拿到后台地址</li>
<li>系统 → 管理权 → 改密码</li>
<li>服务 → 终端 → <code>opkg update</code> → <code>opkg install</code> 装依赖</li>
<li>GitHub 下载 OpenClash 的 ipk → 系统 → 软件包 → 上传安装</li>
<li>刷新 → 服务 → OpenClash → 下载内核 → 导入订阅</li>
</ol><hr /><p><em>写于 2026 年 7 月，折腾 iStoreOS 安装 OpenClash 的记录</em></p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/fnosvmistoreos/fnos-istoreos/</id>
      <title type="text">在 FNOS 上装 iStoreOS 虚拟机</title>
      <published>2026-07-16T00:00:00.000Z</published>
      <updated>2026-09-10T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/fnosvmistoreos/fnos-istoreos/"/>
      <summary type="text">通过 FNOS 运行 iStoreOS 虚拟机，涵盖镜像转换、QCOW2 替换和首次启动的完整流程</summary>
      <content type="html"><![CDATA[<section><h1>在 FNOS 上装 iStoreOS 虚拟机<a href="#在-fnos-上装-istoreos-虚拟机"><span>#</span></a></h1><p>前阵子一直在折腾 NAS，用的是 FNOS。用了一段时间感觉还行，但有些网络方面的功能想折腾一下，就想着在上面跑个 iStoreOS 的虚拟机。</p><p>iStoreOS 是基于 OpenWrt 的，软路由功能该有的都有，而且有应用商店，装插件方便。我之前在别的机器上装过，这次想在 FNOS 上跑一个。</p><section><h2>一、准备工作<a href="#一准备工作"><span>#</span></a></h2><p>先去 iStoreOS 官网下了个固件，x86_64 的版本。下载地址是 fw.koolcenter.com/iStoreOS/x86_64/，挑一个合适的版本就行。</p><p></p><figure><img alt="01-下载iStoreOS固件" loading="lazy" width="2048" height="1058" src="/_astro/01-%E4%B8%8B%E8%BD%BDiStoreOS%E5%9B%BA%E4%BB%B6.KZClmF-w_Z1EQ0St.webp" /><figcaption>01-下载iStoreOS固件</figcaption></figure><p></p><p>然后在 FNOS 里先把 OVS 和 SSH 开了。</p><p>OVS 在 iStoreOS 后台的网络设置里关掉。</p><p></p><figure><img alt="02-OVS设置" loading="lazy" width="1519" height="801" src="/_astro/02-OVS%E8%AE%BE%E7%BD%AE.a0PMgbhi_1NbuRe.webp" /><figcaption>02-OVS设置</figcaption></figure><p></p><p>SSH 在 FNOS 的系统设置里开启。</p><p></p><figure><img alt="03-SSH设置" loading="lazy" width="1376" height="746" src="/_astro/03-SSH%E8%AE%BE%E7%BD%AE.CeeQMS3D_IhkbK.webp" /><figcaption>03-SSH设置</figcaption></figure><p></p></section><section><h2>二、新建虚拟机<a href="#二新建虚拟机"><span>#</span></a></h2><p>FNOS 的虚拟机管理界面挺直观的，新建一个虚拟机，填好名称，选 Linux 系统。</p><p></p><figure><img alt="04-新建虚拟机" loading="lazy" width="1376" height="801" src="/_astro/04-%E6%96%B0%E5%BB%BA%E8%99%9A%E6%8B%9F%E6%9C%BA.mPsNIPNv_58GkP.webp" /><figcaption>04-新建虚拟机</figcaption></figure><p></p><p>先随便上传一个 ISO 文件到文件管理，后面要用替换镜像的方式装 iStoreOS。</p><p></p><figure><img alt="05-上传ISO文件" loading="lazy" width="2048" height="990" src="/_astro/05-%E4%B8%8A%E4%BC%A0ISO%E6%96%87%E4%BB%B6.B3qMcPhD_266373.webp" /><figcaption>05-上传ISO文件</figcaption></figure><p></p><p>配置虚拟机的 CPU、内存、挂载这个 ISO。</p><p></p><figure><img alt="06-硬件配置" loading="lazy" width="1376" height="801" src="/_astro/06-%E7%A1%AC%E4%BB%B6%E9%85%8D%E7%BD%AE.B2v1uGej_1zpHpk.webp" /><figcaption>06-硬件配置</figcaption></figure><p></p><section><h3>存储空间<a href="#存储空间"><span>#</span></a></h3><p>存储空间我选了个 SSD，给 16G 其实就够了，富哥可以上 32G 甚至更大，看你自己。</p><p></p><figure><img alt="07-添加存储空间" loading="lazy" width="1376" height="801" src="/_astro/07-%E6%B7%BB%E5%8A%A0%E5%AD%98%E5%82%A8%E7%A9%BA%E9%97%B4.DnbVtflr_Z1vwTxw.webp" /><figcaption>07-添加存储空间</figcaption></figure><p></p></section><section><h3>网卡<a href="#网卡"><span>#</span></a></h3><p>网卡也选好，这个按自己网络环境来就行。</p><p></p><figure><img alt="08-添加网卡" loading="lazy" width="1376" height="801" src="/_astro/08-%E6%B7%BB%E5%8A%A0%E7%BD%91%E5%8D%A1._2uS3dL2_Z1rqe0t.webp" /><figcaption>08-添加网卡</figcaption></figure><p></p></section></section><section><h2>三、镜像转换<a href="#三镜像转换"><span>#</span></a></h2><p>iStoreOS 官方给的是 .img 格式的镜像，但 FNOS 的虚拟机不认这个格式，它用的是 .qcow2。所以需要转换一下。</p><p>我用的工具是 StarWind V2V Converter，免费的，下载地址：
<a href="https://www.starwindsoftware.com/tmplink/starwindconverter.exe" target="_blank">https://www.starwindsoftware.com/tmplink/starwindconverter.exe</a></p><p>先安装 V2V。</p><p></p><figure><img alt="09-V2V安装" loading="lazy" width="582" height="476" src="/_astro/09-V2V%E5%AE%89%E8%A3%85.DrxbvjfT_27JNoY.webp" /><figcaption>09-V2V安装</figcaption></figure><p></p><p>打开后选择 Local file，选自己下载的 iStoreOS .img 镜像（记得先解压）。</p><p></p><figure><img alt="10-V2V选择来源" loading="lazy" width="832" height="939" src="/_astro/10-V2V%E9%80%89%E6%8B%A9%E6%9D%A5%E6%BA%90.1Lsx7pyA_1xTASi.webp" /><figcaption>10-V2V选择来源</figcaption></figure>
<figure><img alt="11-V2V确认源镜像" loading="lazy" width="832" height="939" src="/_astro/11-V2V%E7%A1%AE%E8%AE%A4%E6%BA%90%E9%95%9C%E5%83%8F.sHds3ed8_FC2fh.webp" /><figcaption>11-V2V确认源镜像</figcaption></figure><p></p><p>目标位置选本地文件。</p><p></p><figure><img alt="12-V2V选择目标位置" loading="lazy" width="832" height="939" src="/_astro/12-V2V%E9%80%89%E6%8B%A9%E7%9B%AE%E6%A0%87%E4%BD%8D%E7%BD%AE.csZijMAD_ZJBtsD.webp" /><figcaption>12-V2V选择目标位置</figcaption></figure><p></p><p>目标格式选 QCOW2。</p><p></p><figure><img alt="13-V2V选择目标格式" loading="lazy" width="832" height="939" src="/_astro/13-V2V%E9%80%89%E6%8B%A9%E7%9B%AE%E6%A0%87%E6%A0%BC%E5%BC%8F.DmrMCGEM_1Vlyjy.webp" /><figcaption>13-V2V选择目标格式</figcaption></figure><p></p><p>版本选 Qcow2 version 3。</p><p></p><figure><img alt="14-V2V选择版本" loading="lazy" width="832" height="939" src="/_astro/14-V2V%E9%80%89%E6%8B%A9%E7%89%88%E6%9C%AC.DY1oNL10_uhcD9.webp" /><figcaption>14-V2V选择版本</figcaption></figure><p></p><p>设置输出文件名，然后点 Convert。</p><p></p><figure><img alt="15-V2V开始转换" loading="lazy" width="832" height="939" src="/_astro/15-V2V%E5%BC%80%E5%A7%8B%E8%BD%AC%E6%8D%A2.BwRNXGpO_Z4ypIP.webp" /><figcaption>15-V2V开始转换</figcaption></figure><p></p><p>转换完成。</p><p></p><figure><img alt="16-V2V转换完成" loading="lazy" width="832" height="939" src="/_astro/16-V2V%E8%BD%AC%E6%8D%A2%E5%AE%8C%E6%88%90.BotoIiL3_Z1T5jrd.webp" /><figcaption>16-V2V转换完成</figcaption></figure><p></p><p>我这边出来的文件名是 <code>istoreos-24.10.7-2026070313-x86-64-squashfs-combined.qcow2</code>。</p></section><section><h2>四、替换镜像<a href="#四替换镜像"><span>#</span></a></h2><p>这个步骤有点绕，我第一次搞的时候卡了一会儿。</p><p>FNOS 新建虚拟机会自动生成一个 .qcow2 文件，但我们要把这个文件替换成自己转换好的那个。</p><p>先到虚拟机详情里找到实际存储路径。</p><p></p><figure><img alt="17-虚拟机存储路径" loading="lazy" width="1376" height="801" src="/_astro/17-%E8%99%9A%E6%8B%9F%E6%9C%BA%E5%AD%98%E5%82%A8%E8%B7%AF%E5%BE%84.C3uC_UnX_ZE4qKm.webp" /><figcaption>17-虚拟机存储路径</figcaption></figure><p></p><p>用 SSH 连上 FNOS，找到这个路径下的文件。</p><p></p><figure><img alt="18-SSH文件管理" loading="lazy" width="1357" height="1030" src="/_astro/18-SSH%E6%96%87%E4%BB%B6%E7%AE%A1%E7%90%86.CqoAJxMz_13lb6i.webp" /><figcaption>18-SSH文件管理</figcaption></figure><p></p><p>操作步骤：</p><ol>
<li>复制 FNOS 自动生成的那个 .qcow2 的文件名（一串 UUID 那种，比如 <code>b4cf05dd-...-q6m2.qcow2</code>）</li>
<li>删掉 FNOS 自动生成的文件</li>
<li>把自己上传的镜像重命名为刚才复制的文件名</li>
</ol><p>其实就是<strong>偷梁换柱</strong>——让 FNOS 以为自己生成的文件还在，实际上已经被换成了 iStoreOS 的镜像。</p></section><section><h2>五、开机<a href="#五开机"><span>#</span></a></h2><p>替换完之后，回到虚拟机配置页面，把之前挂载的那个 ISO 镜像删掉（点叉号）。</p><p></p><figure><img alt="19-移除ISO镜像" loading="lazy" width="1357" height="1030" src="/_astro/19-%E7%A7%BB%E9%99%A4ISO%E9%95%9C%E5%83%8F.C90qCjke_Rg6r9.webp" /><figcaption>19-移除ISO镜像</figcaption></figure>
<figure><img alt="20-确认移除" loading="lazy" width="1376" height="746" src="/_astro/20-%E7%A1%AE%E8%AE%A4%E7%A7%BB%E9%99%A4.CArnMUcU_2shYRb.webp" /><figcaption>20-确认移除</figcaption></figure><p></p><p>然后直接开机。</p><p>看到 <code>iStoreOS is ready! Please press Enter to activate this console.</code> 这行提示，回车即可。</p><p></p><figure><img alt="21-启动成功" loading="lazy" width="2048" height="977" src="/_astro/21-%E5%90%AF%E5%8A%A8%E6%88%90%E5%8A%9F.DhMRPOho_5EkiR.webp" /><figcaption>21-启动成功</figcaption></figure><p></p></section><section><h2>折腾完的感受<a href="#折腾完的感受"><span>#</span></a></h2><p>说实话，在 FNOS 上装 iStoreOS 不算复杂，就是镜像转换和替换这个环节稍微绕一点。核心思路就是用 V2V 把 .img 转成 .qcow2，然后替换掉 FNOS 自动生成的那个文件。</p><p>装好之后能开机了，配置和具体玩法我后面再写一篇文章细说。反正先跑起来再说，后面慢慢折腾。</p><p>后来那篇也写好了：装完第一件事就是拿它跑 <a href="/posts/network/istoreosinstallopenclash/istoreosinstallopenclash/">OpenClash</a>，把软路由的代理功能整起来。</p></section><section><h2>极简流程<a href="#极简流程"><span>#</span></a></h2><ol>
<li>下载 iStoreOS 固件，FNOS 开启 OVS 和 SSH</li>
<li>新建虚拟机，配好存储和网卡</li>
<li>用 StarWind V2V 把 iStoreOS 的 .img 转成 .qcow2</li>
<li>SSH 连 FNOS，找到虚拟机存储路径，把转换好的 .qcow2 上传</li>
<li>复制 FNOS 自动生成的 .qcow2 文件名，删掉它，把上传的镜像改名替换</li>
<li>回到虚拟机，开机，回车</li>
</ol><hr /><p><em>写于 2026 年 7 月，在 FNOS 上折腾 iStoreOS 的记录</em></p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/deploy/deployfirefly/</id>
      <title type="text">折腾博客的第一步：不用服务器搭建 Firefly 博客</title>
      <published>2026-07-15T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/deploy/deployfirefly/"/>
      <summary type="text">不买服务器也能搭一个自己的博客，从 Fork 仓库到博客上线再到日常写文章发布，每一步都写得很细</summary>
      <content type="html"><![CDATA[<p>很多人想搭一个自己的博客，但真正开始的时候才发现：</p>
<p>买服务器麻烦。</p>
<p>配环境麻烦。</p>
<p>搭数据库麻烦。</p>
<p>甚至只是把文章显示出来，都要折腾半天。</p>
<p>其实现在很多博客已经不需要服务器了。Astro 这类静态博客框架，写完文章后直接生成网页文件，丢到 Vercel、Cloudflare Pages 这类平台上就能跑。不用管服务器，不用装数据库，免费额度对个人博客也够用。</p>
<p>我选择 Firefly 的原因很简单：它基于 Astro，速度快，而且默认设计已经比较完整。不需要自己从零写主题，稍微改改配置，就能变成一个属于自己的博客。</p>
<p>今天就记录一下我自己部署 Firefly 博客主题时整理的一套流程。Firefly 是一个基于 Astro 的博客主题，支持暗色模式、评论系统、全文搜索这些功能，样式也挺好看。下面以 Vercel 为例，从零开始讲怎么把它跑起来。</p>

<p></p><figure><img alt="Firefly 博客首页示意" loading="lazy" width="2048" height="990" src="/_astro/cover.QVQ2E4tm_26sNsz.webp" /><figcaption>Firefly 博客首页示意</figcaption></figure><p></p>
<section><h2>一、Fork 仓库<a href="#一fork-仓库"><span>#</span></a></h2><p>打开 Firefly 的 GitHub 仓库：<a href="https://github.com/CuteLeaf/Firefly" target="_blank">CuteLeaf/Firefly</a></p><p>页面右上角有个 <strong>Fork</strong> 按钮，点一下。</p><p></p><figure><img alt="Fork" loading="lazy" width="2048" height="990" src="/_astro/fork.COWmcMDY_Z28Bgt4.webp" /><figcaption>Fork</figcaption></figure><p></p><p>弹出的页面里 <strong>Repository name</strong> 可以改成你喜欢的名字，比如 <code>my-blog</code>，然后点 <strong>Create fork</strong>。等几秒钟仓库就到你账号下了。浏览器地址栏变成 <code>https://github.com/你的用户名/my-blog</code> 就说明成功了。</p><p>Fork 比下载压缩包好的地方在于：后面作者更新了代码，你的仓库页面上会出现 <strong>Sync fork</strong> 按钮，点一下就能同步最新版本。</p></section>
<section><h2>二、注册 Vercel 并导入项目<a href="#二注册-vercel-并导入项目"><span>#</span></a></h2><p>打开 <a href="https://vercel.com/" target="_blank">vercel.com</a>，点 <strong>Sign Up</strong>，选 <strong>Continue with GitHub</strong>，用 GitHub 账号登录。</p><p>登录后进入 Dashboard，右上角点 <strong>Add New…</strong> → <strong>Project</strong>。</p><p>Vercel 会列出你 GitHub 账号下所有的仓库。找到刚才 Fork 的那个，点 <strong>Import</strong>。</p><p>如果列表里没看到，点页面上的 <strong>Adjust GitHub App Permissions</strong>，授权 Vercel 访问那个仓库，回来刷新就有了。</p><p>导入之后进入配置页面，Vercel 会自动识别 Astro 框架。确认这几个参数：</p><ul>
<li><strong>Build Command</strong>: <code>pnpm build</code></li>
<li><strong>Output Directory</strong>: <code>dist</code></li>
<li><strong>Install Command</strong>: <code>pnpm install</code></li>
</ul><p>一般 Vercel 会自动填好，检查一下没问题就点 <strong>Deploy</strong>。</p></section>
<section><h2>三、等构建完成<a href="#三等构建完成"><span>#</span></a></h2><p>点完 Deploy，Vercel 开始构建。页面上会显示四个阶段：</p><ol>
<li><strong>Cloning</strong> — 拉代码</li>
<li><strong>Installing</strong> — 安装依赖包</li>
<li><strong>Building</strong> — 编译。这一步最慢，要处理图片、生成搜索索引，大概一两分钟</li>
<li><strong>Finalizing</strong> — 收尾</li>
</ol><p>构建成功后页面会跳转，显示 <strong>Congratulations</strong>，上面有你的博客地址，格式大概是 <code>https://my-blog-xxxxx.vercel.app</code>。点开就能看到博客了。</p><p><img alt="" loading="lazy" width="2048" height="1021" src="/_astro/vercel.D5KBGMyc_Z28ln6E.webp" /></p><p>如果构建失败了，点 <strong>Building</strong> 旁边的 <strong>View logs</strong> 看报错。最常见的是 Node.js 版本太低——Firefly 要求 22 以上。去项目的 <strong>Settings → General → Node.js Version</strong>，改成 <code>22.x</code>，保存后回到 <strong>Deployments</strong> 点 <strong>Redeploy</strong> 就行。</p></section>
<section><h2>四、绑定自定义域名<a href="#四绑定自定义域名"><span>#</span></a></h2><p>默认的 <code>my-blog-xxxxx.vercel.app</code> 能用，但你多半想用自己的域名。</p><p>在项目的 <strong>Settings → Domains</strong>，输入你的域名（比如 <code>blog.example.com</code>），点 <strong>Add</strong>。Vercel 会根据你的域名类型提示对应 DNS 记录，按照页面提示添加即可。</p><p>域名在哪里买的就去哪里加。阿里云搜「云解析 DNS」，腾讯云搜「DNSPod」，Cloudflare 在域名的 DNS 设置页。</p><p>加完回到 Vercel 等一两分钟，点 <strong>Refresh</strong>。验证通过后 Vercel 会自动申请 HTTPS 证书。</p></section>
<section><h2>五、改站点配置<a href="#五改站点配置"><span>#</span></a></h2><p>博客上线了，但标题、副标题还是默认值。</p><p>这些在 <code>src/config/siteConfig.ts</code> 里改：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>siteConfig</span><span>:</span><span> </span><span>SiteConfig</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>title</span><span>:</span><span> </span><span>"你的博客名"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>subtitle</span><span>:</span><span> </span><span>"你的副标题"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>site_url</span><span>:</span><span> </span><span>"https://你的域名"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><img alt="" loading="lazy" width="1405" height="1044" src="/_astro/vscode.C2eTSRrM_Z7WdCh.webp" /></p><p><code>site_url</code> 一定要改成你实际的域名，不然 sitemap 和 RSS 生成的地址会是错的。</p><p>其他配置文件都在 <code>src/config/</code> 目录下：</p><ul>
<li><code>profileConfig.ts</code> — 头像、简介、社交链接</li>
<li><code>navBarConfig.ts</code> — 顶部导航栏</li>
<li><code>sidebarConfig.ts</code> — 侧边栏</li>
<li><code>commentConfig.ts</code> — 评论系统（支持 Twikoo、Waline、Giscus，Waline 的详细搭建我写过：<a href="/posts/deploy/deploywaline/">用 Vercel + TiDB 搭建 Waline 评论系统</a>）</li>
</ul><p>具体怎么改看 <a href="https://docs-firefly.cuteleaf.cn/zh/guide/getting-started.html" target="_blank">Firefly 官方文档</a>，写得比较全。</p><p>改完怎么生效？往下看。</p></section>
<section><h2>六、写文章，push 就发布<a href="#六写文章push-就发布"><span>#</span></a></h2><p>这是你后面用得最多的操作。有两种方式，看你习惯哪种。</p><section><h3>方式一：直接在 GitHub 网页上写（不需要装任何东西）<a href="#方式一直接在-github-网页上写不需要装任何东西"><span>#</span></a></h3><p>所有操作都在浏览器里完成：</p><ol>
<li>打开你的 GitHub 仓库，进入 <code>src/content/posts/</code> 目录</li>
<li>点 <strong>Add file → Create new file</strong></li>
<li>文件名填 <code>我的第一篇文章/index.md</code>（斜杠前面是文件夹名，GitHub 会自动创建）</li>
<li>写内容：</li>
</ol><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>title</span><span>: </span><span>我的第一篇文章</span></div></div><div><div><div>3</div></div><div><span>published</span><span>: </span><span>2026-07-15</span></div></div><div><div><div>4</div></div><div><span>description</span><span>: </span><span>这是一篇测试文章</span></div></div><div><div><div>5</div></div><div><span>tags</span><span>: [</span><span>测试</span><span>, </span><span>博客</span><span>]</span></div></div><div><div><div>6</div></div><div><span>category</span><span>: </span><span>日常</span></div></div><div><div><div>7</div></div><div><span>---</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>正文内容。支持 Markdown 语法。</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ol>
<li>页面底部填个提交信息，点 <strong>Commit changes</strong></li>
</ol><p>提交之后回到 Vercel Dashboard，它已经开始自动构建了。等一两分钟，新文章就上线了。</p><p>上传配图的话，在同一个文件夹里点 <strong>Add file → Upload files</strong>，把图片拖进去。在文章里用 <code>![描述](./图片名.png)</code> 引用。</p></section><section><h3>方式二：本地编辑，push 到 GitHub（推荐）<a href="#方式二本地编辑push-到-github推荐"><span>#</span></a></h3><p>经常写文章的话建议装本地环境，用自己顺手的编辑器写。</p><p><strong>装环境（一次性）：</strong></p><ol>
<li>装 <a href="https://nodejs.org/" target="_blank">Node.js</a> 22.x 版本</li>
<li>打开终端（Windows 搜 PowerShell，Mac 搜终端），运行 <code>npm install -g pnpm</code></li>
<li>装 <a href="https://git-scm.com/" target="_blank">Git</a></li>
<li>终端里运行 <code>git clone https://github.com/你的用户名/my-blog.git</code></li>
</ol><p><strong>日常流程：</strong></p><ol>
<li>在 <code>src/content/posts/</code> 下新建文件夹，写 <code>index.md</code></li>
<li>想预览效果就跑 <code>pnpm dev</code>，浏览器打开 <code>http://localhost:4321</code></li>
<li>写好之后：</li>
</ol><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>git</span><span> </span><span>add</span><span> </span><span>.</span></div></div><div><div><div>2</div></div><div><span>git</span><span> </span><span>commit</span><span> </span><span>-m</span><span> </span><span>"新增一篇文章"</span></div></div><div><div><div>3</div></div><div><span>git</span><span> </span><span>push</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Push 上去，Vercel 自动构建，一两分钟后新文章上线。不需要登录 Vercel，不需要手动触发任何东西。</p><p>用 VS Code 的话更方便，左侧栏有 Git 按钮，点一下就能提交推送。</p></section></section>
<section><h2>七、自动部署是怎么回事<a href="#七自动部署是怎么回事"><span>#</span></a></h2><p>你 push 代码到 GitHub，Vercel 怎么知道的？</p><p>导入仓库的时候，Vercel 会在你的 GitHub 仓库里装一个 Webhook。每次你 push 代码到主分支，GitHub 通知 Vercel，Vercel 自动拉代码、构建、部署。你什么都不用做。</p><p>还有个有用的功能：新建分支 push 上去的话，Vercel 会给这个分支生成一个预览地址（Preview Deployment），主分支不受影响。不确定改动效果的时候，先在分支上试试。</p></section>
<section><h2>总结<a href="#总结"><span>#</span></a></h2><p>日常写博客的流程就四步：</p><ol>
<li>在 <code>src/content/posts/</code> 下写文章</li>
<li>Push 到 GitHub（或者在 GitHub 网页上直接提交）</li>
<li>Vercel 自动构建，等一两分钟</li>
<li>新文章上线</li>
</ol><p>买服务器、配 Nginx、搞 HTTPS 这些全不用管。写完推上去就好了。</p><p>后面<strong>我把 Firefly 的配套也陆续记录完了：<a href="/posts/deploy/deploywaline/">Waline 评论系统</a>、<a href="/posts/deploy/deployumami/">Umami 网站统计</a>、<a href="/posts/blog/configsite/">配置文件详解</a>都有了，按需取用</strong>。如果你也喜欢折腾自己的小网站，可以一起交流。</p><p>有不懂的问题<strong>直接在评论区问</strong></p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/cloudmail/</id>
      <title type="text">CloudMail 一个基于Workers无限收发邮箱的开源项目</title>
      <published>2026-07-13T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/cloudmail/"/>
      <summary type="text">只需要一个域名，就可以创建多个不同的邮箱，类似各大邮箱平台，本项目可部署到 Cloudflare Workers ，个人0服务器成本，搭建自己的邮箱服务</summary>
      <content type="html"><![CDATA[<section><h2>前言<a href="#前言"><span>#</span></a></h2><p>只需要一个域名，就可以创建无限个的邮箱地址，类似各大邮箱平台的体验。本项目可部署到 Cloudflare Workers，个人 <strong>0 服务器成本</strong>，搭建属于自己的邮箱服务。</p><p>当然，你也能用它来<strong>管理不同平台的账号邮箱</strong>，把注册邮箱和私人邮箱分开，更加安全，可以<strong>完全隐藏自己的真实邮箱</strong></p><blockquote><p><strong>前提条件：</strong> 你的域名必须已托管至 Cloudflare（即 NS 服务器指向 Cloudflare）。</p></blockquote><p>之前用 Cloudflare Email Routing + Resend 折腾过一套收发域名邮箱（<a href="/posts/network/cloudflareemailrouting/">那篇在这</a>），收件靠路由转发、发件靠 Resend API，能用但功能比较散。</p><p>（昨天还想着自己用 WPF 做一个调用 Resend API 的项目呢…今天就发现了更好的开源方案）</p><hr /></section>
<section><h2>第一部分：部署 CloudMail 收件服务<a href="#第一部分部署-cloudmail-收件服务"><span>#</span></a></h2><p>整体流程：<strong>Fork 仓库 → 创建 Worker → 配置环境变量 → 创建存储资源并绑定 → 设置域名邮件路由 → 初始化数据库 → 注册管理员</strong></p><section><h3>1. Fork 仓库并创建 Worker<a href="#1-fork-仓库并创建-worker"><span>#</span></a></h3><p>首先 Fork 仓库：<a href="https://github.com/hy4962/cloud-mail" target="_blank">cloud-mail</a></p><p>然后在 Cloudflare Dashboard 中创建 Worker，选择<strong>从 GitHub 导入</strong>。</p><blockquote><p>点开 <strong>高级设置</strong>，将路径设置为 <code>/mail-worker</code>，这一步不要漏掉。</p></blockquote><p></p><figure><img alt="从Github导入Worker" loading="lazy" width="760" height="799" src="/_astro/CreateWorker.3sj0We6__1aWc4n.webp" /><figcaption>从Github导入Worker</figcaption></figure><p></p></section><section><h3>2. 配置环境变量<a href="#2-配置环境变量"><span>#</span></a></h3><p>在 Worker 的<strong>设置 → 变量和机密</strong>中，添加以下环境变量：</p>

<table><thead><tr><th>变量名</th><th>必需</th><th>用途</th></tr></thead><tbody><tr><td><code>domain</code></td><td>✅</td><td>邮箱域名。多域名用 JSON 数组格式，例如 <code>["example.com","example2.com"]</code></td></tr><tr><td><code>admin</code></td><td>✅</td><td>管理员邮箱地址，例如 <code>admin@example.com</code></td></tr><tr><td><code>jwt_secret</code></td><td>✅</td><td>JWT 密钥，随便输入一串字符串即可，<strong>不要包含特殊字符</strong></td></tr></tbody></table><blockquote><p>⚠️ <strong>建议：</strong> 如果你还没有绑定自定义域名到 Worker，推荐先绑定再配置环境变量。绑定域名的方式见<a href="/posts/network/cloudflareoptimalip/">优选 IP 那篇文章</a>。</p></blockquote><p></p><figure><img alt="环境变量配置" loading="lazy" width="1979" height="1209" src="/_astro/string.mI5-sGBT_1B5Vjl.webp" /><figcaption>环境变量配置</figcaption></figure><p></p></section><section><h3>3. 创建存储资源（D1 + KV + R2）<a href="#3-创建存储资源d1--kv--r2"><span>#</span></a></h3><p>CloudMail 需要三种存储资源，分别用于数据库、缓存和附件存储：</p>

<table><thead><tr><th>资源类型</th><th>推荐命名</th><th>用途</th></tr></thead><tbody><tr><td>D1 数据库</td><td><code>Cloud-mail-DB</code></td><td>存储邮件数据</td></tr><tr><td>KV 存储</td><td><code>Cloud-mail-KV</code></td><td>缓存与配置</td></tr><tr><td>R2 存储桶</td><td><code>Cloud-mail-R2</code></td><td>邮件附件</td></tr></tbody></table><p>按推荐名称创建即可，当然你也可以用自己的命名。</p><p></p><figure><img alt="创建D1数据库" loading="lazy" width="2048" height="990" src="/_astro/CreateD1db.9u2bzXgQ_2ehk2f.webp" /><figcaption>创建D1数据库</figcaption></figure><p></p><p></p><figure><img alt="创建KV存储" loading="lazy" width="2048" height="990" src="/_astro/CreateKV.CAxKpUEU_A0tpa.webp" /><figcaption>创建KV存储</figcaption></figure><p></p><p></p><figure><img alt="创建R2存储桶" loading="lazy" width="2048" height="1001" src="/_astro/CreateR2.Blh3Pjj2_ZVmqYJ.webp" /><figcaption>创建R2存储桶</figcaption></figure><p></p><p>创建完成后，回到 Worker 的<strong>设置 → 绑定</strong>页面，将三个资源全部绑定上去：</p><blockquote><p>⚠️ <strong>绑定的时候变量名必须严格为</strong> <code>db</code>、<code>kv</code>、<code>r2</code>，否则 Worker 无法正确读取。</p></blockquote><p></p><figure><img alt="绑定KV和DB变量" loading="lazy" width="1283" height="870" src="/_astro/KVDB.d1DzXNm-_Z2cDuaK.webp" /><figcaption>绑定KV和DB变量</figcaption></figure><p></p><p></p><figure><img alt="绑定设置详情" loading="lazy" width="1979" height="1209" src="/_astro/Binding.BIikSpcY_ZQtJBX.webp" /><figcaption>绑定设置详情</figcaption></figure><p></p></section><section><h3>4. 配置域名邮件路由<a href="#4-配置域名邮件路由"><span>#</span></a></h3><p>进入 Cloudflare 的 <strong>电子邮件 → 电子邮件路由</strong> 页面，将收到的邮件转发到刚才创建的 Worker 项目。</p><blockquote><p>如果你的域名刚托管过来，可能需要先点击启用电子邮件路由，Cloudflare 会自动添加所需的 MX 记录。</p></blockquote><p></p><figure><img alt="域名邮件路由设置" loading="lazy" width="2048" height="990" src="/_astro/Form.CzpWmuwT_Z1DfOJK.webp" /><figcaption>域名邮件路由设置</figcaption></figure><p></p></section><section><h3>5. 初始化数据库并注册管理员<a href="#5-初始化数据库并注册管理员"><span>#</span></a></h3><p>在浏览器中访问以下地址来初始化数据库：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://你的worker域名/api/init/你的jwt_secret</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>看到浏览器返回 <strong>success</strong> 即表示初始化成功。</p><p>然后直接访问：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://你的worker域名/</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>在页面上 <strong>注册管理员账号</strong>。管理员账号就是你在步骤 2 中设置的 <code>admin</code> 环境变量值。</p><p>到这一步，CloudMail 的收件功能就部署完成了 🎉</p><p></p><figure><img alt="管理员登录界面" loading="lazy" width="2048" height="990" src="/_astro/adminlogin.B3PtofU9_EdKqo.webp" /><figcaption>管理员登录界面</figcaption></figure><p></p><hr /></section></section>
<section><h2>第二部分：配置 Resend 发件服务<a href="#第二部分配置-resend-发件服务"><span>#</span></a></h2><p>Cloudflare Workers 本身不支持发邮件（25 端口被封禁），所以我们需要借助第三方发件服务。<strong>Resend</strong> 提供了免费额度（每月 3000 封），对个人使用来说足够了。</p><p>整体流程：<strong>注册 Resend → 添加域名并验证 DNS → 创建 API Key → 配置 Webhook 回调 → 填入 Token</strong></p><section><h3>1. 注册 Resend 并添加域名<a href="#1-注册-resend-并添加域名"><span>#</span></a></h3><p>前往 <a href="https://resend.com/login" target="_blank">Resend 官网</a> 注册账号，然后在控制台中添加你的域名。</p><p>添加后，Resend 会提供几条 DNS 记录（SPF、DKIM 等），你需要到 Cloudflare 中添加这些记录来完成域名验证。</p><p></p><figure><img alt="Resend添加域名" loading="lazy" width="2048" height="990" src="/_astro/resendadd.CFSrdh-5_2oy2f2.webp" /><figcaption>Resend添加域名</figcaption></figure><p></p></section><section><h3>2. 创建 API Key<a href="#2-创建-api-key"><span>#</span></a></h3><p>在 Resend 控制台的 <strong>API Keys</strong> 页面创建一个新的 Key，复制保存好，后面要用。</p><p></p><figure><img alt="创建Resend API Key" loading="lazy" width="2048" height="990" src="/_astro/resendapi.CY--9Luf_Z1HqTSp.webp" /><figcaption>创建Resend API Key</figcaption></figure><p></p></section><section><h3>3. 配置 Webhook 回调<a href="#3-配置-webhook-回调"><span>#</span></a></h3><p>在 Resend 的 <strong>Webhooks</strong> 页面，添加一个新的 Webhook，用于接收邮件发送状态的回调通知：</p><ul>
<li><strong>Endpoint URL</strong> 填写：<code>https://你的worker域名/api/webhooks</code></li>
<li><strong>Events</strong> 权限选择如下图所示：</li>
</ul><p></p><figure><img alt="添加Webhook" loading="lazy" width="2048" height="990" src="/_astro/resendaddweb.CkezoEB4_Z2au0td.webp" /><figcaption>添加Webhook</figcaption></figure><p></p><p></p><figure><img alt="Webhook权限设置" loading="lazy" width="618" height="557" src="/_astro/resendweb.pPyIKOTj_GViLC.webp" /><figcaption>Webhook权限设置</figcaption></figure><p></p></section><section><h3>4. 填入 Resend Token 到 CloudMail<a href="#4-填入-resend-token-到-cloudmail"><span>#</span></a></h3><p>回到 CloudMail 的管理员后台，在设置页面中填入刚才复制的 Resend API Key：</p><p></p><figure><img alt="填入Resend Token" loading="lazy" width="2048" height="990" src="/_astro/addtoken.BC_L_w-9_ZxmiTT.webp" /><figcaption>填入Resend Token</figcaption></figure><p></p><hr /></section></section>
<section><h2>结语<a href="#结语"><span>#</span></a></h2><p>至此，CloudMail 已经完成全部部署。</p><ul>
<li>收邮件由 Cloudflare Email Routing + Workers 负责。</li>
<li>发邮件由 Resend 提供 API 支持。</li>
</ul><p>整个方案几乎不需要服务器，只需要一个托管到 Cloudflare 的域名，就能拥有自己的邮箱系统。</p><p>另外，CloudMail 后台还支持将收到的邮件自动转发到自己的真实邮箱，即使不经常登录后台，也不会错过任何新邮件。</p><p>过程中遇到问题可以<strong>直接在评论区留言</strong>，看到都会回复的</p><p>文章是<strong>摆烂技术站</strong>原创，需要转载等用途请联系<strong><a href="#">WeChatOfficialAccount@9ll.uk</a></strong></p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/cloudflareemailrouting/</id>
      <title type="text">Cloudflare+Resend域名邮箱</title>
      <published>2026-07-12T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/cloudflareemailrouting/"/>
      <summary type="text">域名托管Cloudflare实现无限收邮箱+Resend发邮件</summary>
      <content type="html"><![CDATA[<section><h2>前言<a href="#前言"><span>#</span></a></h2><p>偶尔生活需要隐藏自己的真实邮箱或者无限邮箱薅羊毛？</p><p>本篇文章为此诞生</p></section>
<section><h2>在Cloudflare开启电子路由（收邮件）<a href="#在cloudflare开启电子路由收邮件"><span>#</span></a></h2><p>1.先进去自己的控制台，点击电子邮件路由，接入自己的域名</p><p></p><figure><img alt="routing" loading="lazy" width="2048" height="990" src="/_astro/routing.COhUeXF2_Z2t58iR.webp" /><figcaption>routing</figcaption></figure><p></p><p>2.点击设置，然后点击锁定，貌似点了锁定就好像会自己解析下面的DNS记录？反正没自动解析就自己动手一下吧</p><p></p><figure><img alt="lock" loading="lazy" width="2048" height="1001" src="/_astro/lock.ta5LWLAy_1HmRKO.webp" /><figcaption>lock</figcaption></figure><p></p><p>3.然后去目标地址，增加一下自己真正的邮箱，然后点击添加，我记得会发一封验证邮件，自己去验证一下就好啦</p><p></p><figure><img alt="添加目标邮箱" loading="lazy" width="2048" height="990" src="/_astro/addemail.E2_PJ1qy_2od5yr.webp" /><figcaption>添加目标邮箱</figcaption></figure><p></p><p>4.之后就是去路由规则，点击编辑，操作<strong>选择发送到电子邮件</strong>，目标<strong>填写自己的邮箱</strong>，点击<strong>保存</strong>，记得把状态开启，变成<strong>活跃</strong></p><p></p><figure><img alt="路由规则编辑" loading="lazy" width="2048" height="990" src="/_astro/emailopt1.DNPT4rJG_1W367B.webp" /><figcaption>路由规则编辑</figcaption></figure><p></p><p></p><figure><img alt="选择发送到电子邮件" loading="lazy" width="2048" height="990" src="/_astro/emailopt2.DtZhevpX_Z26ONas.webp" /><figcaption>选择发送到电子邮件</figcaption></figure><p></p><p></p><figure><img alt="保存路由规则" loading="lazy" width="1664" height="208" src="/_astro/emailopt3.CT6xSFq4_ZfcIHM.webp" /><figcaption>保存路由规则</figcaption></figure><p></p></section>
<section><h2>配置Resend（发送邮件）<a href="#配置resend发送邮件"><span>#</span></a></h2><p>1.注册好<a href="https://resend.com/" target="_blank">Resend</a>后，来到<a href="https://resend.com/domains" target="_blank">Domains · Resend</a></p><p></p><figure><img alt="Resend添加域名" loading="lazy" width="2048" height="990" src="/_astro/resendadd.ND3TNTun_nP2fM.webp" /><figcaption>Resend添加域名</figcaption></figure><p></p><p>2.然后按信息配置好DNS记录即可(没图)，因为这玩意免费计划只能添加一个域名，懒得删了再弄了，反正也没有难度</p><p>3.之后是用Resend发邮件即可(…)</p></section>
<section><h2>结语<a href="#结语"><span>#</span></a></h2><p>Resend只能添加一个域名挺伤的，其实还有一个专门做了一套可以运行在workers的开源项目，叫 CloudMail，收发一体，我后来部署了一版，过程写在<a href="/posts/network/cloudmail/">这篇文章</a>里，比这套组合还省事。</p><p>Resend特么的貌似还不能直接在控制台发送邮件，只有一个不好用的广播，但是官方提供了非常详细的Api文档，这几天有时间我写一个客户端开源一下？</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/network/cloudflareoptimalip/</id>
      <title type="text">Cloudflare Workers优选IP</title>
      <published>2026-07-12T00:00:00.000Z</published>
      <updated>2026-10-05T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/network/cloudflareoptimalip/"/>
      <summary type="text">CloudFlare优选IP</summary>
      <content type="html"><![CDATA[<section><h2>什么是优选IP，为什么要搞这个<a href="#什么是优选ip为什么要搞这个"><span>#</span></a></h2><p>用过 Cloudflare 的人应该都有感觉：国内访问 Cloudflare 托管的网站，有时候慢得离谱。明明是全球最大的 CDN 厂商之一，怎么体验还不如一些小厂？</p><p>原因出在路由上。</p><p>Cloudflare 用的是 anycast 技术——同一个 IP 地址在全球很多地方都有节点，你的请求会被路由到”最近”的那个。问题是这个”最近”是按网络拓扑算的，不是按地理距离。国内三大运营商到 Cloudflare 节点的线路经常绕路，比如电信用户可能被送到美国圣何塞的节点，明明直线距离不到两千公里的事，数据包绕了半个地球才到。</p><p>优选IP就是找那些对国内线路”更友好”的 Cloudflare 节点。这些节点可能物理位置不在国内（Cloudflare 在大陆没有免费节点），但经过的网络路径更短、中间跳数更少、延迟更低。</p><section><h3>效果到底有多大<a href="#效果到底有多大"><span>#</span></a></h3><p>看你的运营商和地区，差别很大：</p><ul>
<li><strong>电信用户</strong>：改善通常最明显。因为电信到 Cloudflare 默认线路特别绕，优选一下可能从 300ms+ 降到 100ms 左右</li>
<li><strong>联通用户</strong>：默认线路本来还行，改善幅度中等</li>
<li><strong>移动用户</strong>：情况不稳定，有时候有效果有时候没有，跟具体省份有关</li>
</ul><p>至于下载速度，优选IP能让 Cloudflare 托管的静态资源（图片、CSS、JS）加载更快。但对于 Workers 的计算性能，优选IP帮不上忙——它只优化你到 Cloudflare 这段路，不改变 Cloudflare 内部处理请求的速度。</p><p><strong>网站打开更快了，下载更快了，但 Worker 跑代码的速度不变。</strong></p></section><section><h3>两种实现方式<a href="#两种实现方式"><span>#</span></a></h3><p>搞优选IP有两种路子，复杂程度不同：</p>

<table><thead><tr><th>方式</th><th>难度</th><th>稳定性</th><th>适合谁</th></tr></thead><tbody><tr><td>CNAME 优选域名</td><td>低</td><td>较高（别人在维护）</td><td>大多数人</td></tr><tr><td>A 记录直连 IP</td><td>高</td><td>低（IP 会过期）</td><td>想折腾的人</td></tr></tbody></table><p><strong>CNAME 方式</strong>就是你把域名指向一个别人已经配好的优选域名（比如 <code>vip.090227.xyz</code>），这个域名背后对应的是维护者筛出来的高质量 Cloudflare IP。你不用自己找 IP，改一条 DNS 记录就完事了。</p><p><strong>A 记录方式</strong>是你自己去测速、自己找 IP、自己填到 DNS 里。更灵活，但维护成本高——好 IP 今天能用不代表明天还能用。</p><p>下面两种都讲。</p><hr /></section></section>
<section><h2>方式一：CNAME 优选域名（推荐大多数人）<a href="#方式一cname-优选域名推荐大多数人"><span>#</span></a></h2><section><h3>前提条件<a href="#前提条件"><span>#</span></a></h3><p>域名必须在 Cloudflare 上托管。就是你的域名的 nameserver 指向 Cloudflare，不是说域名从 Cloudflare 买的。在阿里云、腾讯云买的域名，只要把 DNS 服务器改成 Cloudflare 提供的那两个地址就行。</p></section><section><h3>第一步：给 Workers 添加路由<a href="#第一步给-workers-添加路由"><span>#</span></a></h3><p>进入 Cloudflare Dashboard → Workers 和 Pages → 你的项目 → 设置 → 触发器 → 添加路由。</p><p>路由格式就是你的域名加通配符：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>9ll.uk/*</span></div></div><div><div><div>2</div></div><div><span>*.9ll.uk/*</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>第一行匹配主域名下的所有路径，第二行匹配所有二级域名。<strong>按需填，不需要的可以不加</strong>。</p><p>注意一个坑：<strong>这个域名必须已经添加到你的 Cloudflare 账号里</strong>，就是 Dashboard 左侧”网站”列表里要有这个域名。没添加的话路由不会生效。</p><p></p><figure><img alt="添加路由|80" loading="lazy" width="2048" height="990" src="/_astro/route.CDyNIoOu_Z1wBuNd.webp" /><figcaption>添加路由|80</figcaption></figure><p></p></section><section><h3>第二步：选一个优选域名<a href="#第二步选一个优选域名"><span>#</span></a></h3><p>推荐四个网站：</p><ul>
<li><a href="https://www.wetest.vip/page/cloudflare/cname.html" target="_blank">微测网 CF 优选 CNAME</a> — 更新比较勤，域名多</li>
<li><a href="https://cf.090227.xyz/" target="_blank">CF优选域名汇总</a> — 界面简单直接</li>
<li><a href="https://vps789.com/cfip/?remarks=domain" target="_blank">VPS789 CF优选IP监控</a> — 收录最多，三网延迟/丢包/下载速度全给，还带综合评分</li>
<li><a href="https://www.byoip.top/" target="_blank">NB 优选服务</a> — 不只 CF，EdgeOne / Netlify / Vercel 的优选域名都给了，页面直接标当前延迟，免费</li>
</ul><p><strong>VPS789 这个表最值得看的地方是它把三网数据分开列。</strong> 同一个域名电信 51ms、移动 87ms、联通 189ms 是常有的事，光看总分挑不出适合自己的，得盯自己运营商那一列。它的评分是按延迟 30%、丢包 50%、下载速度 20% 加权算的，数据每 30 分钟刷新一次——比你自己 ping 那一下靠谱得多，毕竟一次 ping 的延迟高低说明不了稳定性。</p><p>上面列出来的域名都是别人维护的优选 CNAME，比如 <code>vip.090227.xyz</code>、<code>cf.011011.xyz</code>、NB 优选的 <code>*.cloudflare.19931110.xyz</code> / <code>*.cf.cnae.top</code> 之类的。</p><p>怎么选？两种方法：</p><p><strong>简单粗暴法：</strong> 直接挑一个 ping 一下，延迟低的就用。</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>ping</span><span> </span><span>vip.090227.xyz</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>认真测试法：</strong> 多 ping 几个，记下延迟，挑最低的两三个。不同运营商的结果可能不一样，有条件的话找个跟你同运营商的朋友帮忙测。</p></section><section><h3>第三步：添加 DNS 解析<a href="#第三步添加-dns-解析"><span>#</span></a></h3><p>回到 Cloudflare → 你的域名 → DNS → 记录。</p><p>添加一条 CNAME 记录：</p><ul>
<li><strong>类型</strong>：CNAME</li>
<li><strong>名称</strong>：<code>@</code>（代表主域名）或你想要的二级域名比如 <code>www</code></li>
<li><strong>目标</strong>：你选的优选域名，比如 <code>vip.090227.xyz</code></li>
<li><strong>代理状态</strong>：<strong>关掉</strong>，显示灰色云朵</li>
</ul><p></p><figure><img alt="cname|67" loading="lazy" width="961" height="541" src="/_astro/cname.DGSuP8Bk_7qf6R.webp" /><figcaption>cname|67</figcaption></figure><p></p><p><strong>代理一定要关掉</strong>，这是最多人踩的坑。Cloudflare 的代理（橙色云朵）会把你的流量走 Cloudflare 全球网络，等于绕回了默认路由，优选IP就白做了。关掉代理后，DNS 解析会直接返回优选域名对应的 IP，你的流量走的是那条优化过的路径。</p></section><section><h3>第四步：验证<a href="#第四步验证"><span>#</span></a></h3><p>打开终端：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>ping</span><span> </span><span>你的域名</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>看返回的 IP 是不是跟优选域名的一样。再用 curl 检查一下：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-I</span><span> </span><span>https://你的域名</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>响应头里有 <code>server: cloudflare</code> 和 <code>cf-ray</code> 字段，说明流量确实走了 Cloudflare。</p><p>也可以用 <a href="https://ping.pe/" target="_blank">Ping.pe</a> 这类工具从多个地区测速，对比优化前后的延迟。</p><hr /></section></section>
<section><h2>方式二：A 记录直连优选IP<a href="#方式二a-记录直连优选ip"><span>#</span></a></h2><p>这种方式更折腾，但好处是你可以完全自己控制，不依赖别人维护的域名。</p><section><h3>第一步：找到好用的 Cloudflare IP<a href="#第一步找到好用的-cloudflare-ip"><span>#</span></a></h3><p>去微测网的 <a href="https://www.wetest.vip/page/cloudflare/address_v4.html" target="_blank">CloudFlare优选IPV4地址</a>，上面有测速过的一批 IP 列表。</p><p>或者自己动手：从 Cloudflare 的 IP 段（公开的，<a href="https://www.cloudflare.com/ips/" target="_blank">官网有列表</a>）里挑一些，批量 ping 测速。这活挺枯燥的，但真想搞就耐心点。</p><p>测速的时候注意几点：</p><ul>
<li><strong>从你实际使用的网络测</strong>，别用 VPN 测，那测出来的不准</li>
<li><strong>多测几次</strong>，网络质量有波动，偶尔一次延迟低不代表稳定</li>
<li><strong>分时段测</strong>，晚高峰和凌晨的延迟差别很大</li>
</ul></section><section><h3>第二步：添加 A 记录<a href="#第二步添加-a-记录"><span>#</span></a></h3><p>DNS 设置里添加 A 记录：</p><ul>
<li><strong>类型</strong>：A</li>
<li><strong>名称</strong>：<code>@</code> 或你的二级域名</li>
<li><strong>IPv4 地址</strong>：你测出来的优选 IP</li>
<li><strong>代理状态</strong>：关掉</li>
</ul><p>同样代理要关掉。</p><p>想更稳妥的话，多加几条 A 记录，填不同的优选 IP。DNS 会自动轮询返回，相当于做了个简单的负载均衡。一个 IP 挂了，其他的还能顶上。</p><p>不过要注意：<strong>CNAME 和 A 记录不能同时存在</strong>。同一个域名下如果有 CNAME，就不能再加 A 记录，反过来也一样。想用 A 记录就得先把 CNAME 删了。</p></section><section><h3>第三步：验证<a href="#第三步验证"><span>#</span></a></h3><p>跟 CNAME 方式一样，ping 看 IP，curl 看响应头。</p></section><section><h3>为什么不推荐大多数人用这个<a href="#为什么不推荐大多数人用这个"><span>#</span></a></h3><p>A 记录方式有两个硬伤：</p><ol>
<li><strong>IP 会过期</strong>。Cloudflare 的 IP 归属和路由策略经常调整，今天 30ms 的好 IP，过两个月可能变成 200ms。你得定期重新测速、更新记录。</li>
<li><strong>工作量大</strong>。手动测速、挑选、填记录、定期维护，对个人博客来说性价比不高。</li>
</ol><p>CNAME 方式的域名背后，维护者会不定期更新 IP 列表，你不用操心这些。</p><hr /></section></section>
<section><h2>怎么判断优选IP有没有效果<a href="#怎么判断优选ip有没有效果"><span>#</span></a></h2><p>优化完了得验证，不然白折腾。</p><section><h3>ping 对比<a href="#ping-对比"><span>#</span></a></h3><p>优化前后分别 ping 你的域名，记录延迟。简单但直观：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 优化前</span></div></div><div><div><div>2</div></div><div><span>ping</span><span> </span><span>9ll.uk</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span># 优化后（DNS 传播完）</span></div></div><div><div><div>5</div></div><div><span>ping</span><span> </span><span>9ll.uk</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>多地区测试<a href="#多地区测试"><span>#</span></a></h3><p>用 <a href="https://ping.pe/" target="_blank">Ping.pe</a> 或 <a href="https://www.itdog.cn/" target="_blank">ITDog</a> 从全国多个节点测试。你自己的网络测出来好了不代表所有用户都好了，看个全局。</p></section><section><h3>实际体验<a href="#实际体验"><span>#</span></a></h3><p>浏览器清缓存（Ctrl+Shift+Delete），打开你的网站，感受加载速度。图片多的页面效果对比更明显。</p></section><section><h3>curl 看细节<a href="#curl-看细节"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-o</span><span> </span><span>/dev/null</span><span> </span><span>-s</span><span> </span><span>-w</span><span> </span><span>"DNS解析: %{time_namelookup}s\nTCP连接: %{time_connect}s\nTLS握手: %{time_appconnect}s\n首字节: %{time_starttransfer}s\n总时间: %{time_total}s\n"</span><span> </span><span>https://你的域名</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这个命令会输出连接各阶段的耗时，比单纯 ping 更精确。重点看 <code>DNS解析</code>、<code>TCP连接</code> 和 <code>首字节</code> 这三项。</p><hr /></section></section>
<section><h2>常见问题和踩坑记录<a href="#常见问题和踩坑记录"><span>#</span></a></h2><section><h3>代理没关<a href="#代理没关"><span>#</span></a></h3><p>最常见的坑，没有之一。加了 CNAME 但 Cloudflare 那边代理还是橙色的，等于白加。一定要确认是灰色云朵。</p></section><section><h3>DNS 缓存<a href="#dns-缓存"><span>#</span></a></h3><p>改完 DNS 记录后，不是立刻生效的。本地电脑、浏览器、路由器都可能缓存了旧的 DNS 结果。</p><p>清除本地缓存：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># Windows</span></div></div><div><div><div>2</div></div><div><span>ipconfig</span><span> </span><span>/flushdns</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span># macOS</span></div></div><div><div><div>5</div></div><div><span>sudo</span><span> </span><span>dscacheutil</span><span> </span><span>-flushcache</span><span>; </span><span>sudo</span><span> </span><span>killall</span><span> </span><span>-HUP</span><span> </span><span>mDNSResponder</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span># Linux（看用的什么 DNS 服务）</span></div></div><div><div><div>8</div></div><div><span>sudo</span><span> </span><span>systemd-resolve</span><span> </span><span>--flush-caches</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>浏览器的话直接清缓存或者开无痕窗口试。</p><p>一般 DNS 传播要几分钟到几小时不等，Cloudflare 自己的 DNS 生效最快（因为就是他们家的），其他 DNS 服务商的传播慢一些。</p></section><section><h3>Workers 路由没生效<a href="#workers-路由没生效"><span>#</span></a></h3><p>添加路由后发现请求没走 Workers，检查：</p><ul>
<li>路由格式对不对（<code>域名/*</code> 不是 <code>域名/</code>）</li>
<li>域名是不是已经在 Cloudflare 账号里添加了</li>
<li>Workers 项目有没有部署成功</li>
<li>路由规则里域名跟 DNS 记录里的要一致</li>
</ul></section><section><h3>优选域名挂了<a href="#优选域名挂了"><span>#</span></a></h3><p>第三方维护的优选域名可能随时停更或失效。如果突然访问不了了，先确认优选域名本身还能 ping 通。ping 不通就换一个域名。</p></section><section><h3>CNAME 和 A 记录冲突<a href="#cname-和-a-记录冲突"><span>#</span></a></h3><p>同一个域名下不能同时有 CNAME 和 A 记录。报错提示重复的时候，检查是不是之前加了 CNAME 没删。</p></section><section><h3>运营商差异<a href="#运营商差异"><span>#</span></a></h3><p>同一个优选IP，电信测出来 80ms，移动测出来 300ms，很正常。不同运营商走的线路完全不同，没有一个 IP 能对所有运营商都好。如果你的用户覆盖三大运营商，可以考虑针对不同运营商用不同的优选IP（需要多个子域名分别解析）。</p><hr /></section></section>
<section><h2>进阶玩法<a href="#进阶玩法"><span>#</span></a></h2><section><h3>优选IP + Cloudflare Pages<a href="#优选ip--cloudflare-pages"><span>#</span></a></h3><p>Cloudflare Pages 也能用优选IP。原理跟 Workers 一样——Pages 本质上也是跑在 Cloudflare 边缘网络上的，给 Pages 绑定自定义域名后，同样可以 CNAME 指向优选域名。</p></section><section><h3>跟 Vercel 对比<a href="#跟-vercel-对比"><span>#</span></a></h3><p>Vercel 的 Edge Network 在国内也经常绕路，特别是免费计划。很多人选 Cloudflare Workers + 优选IP 而不是 Vercel，就是图个能优化链路。</p><p>不过 Vercel 也不是没路可走：官方自己就维护了一条 <code>cname-china.vercel-dns.com</code>，CNAME 指过去就完事；想更进一步，可以指向第三方维护的 Vercel 优选域名（<a href="https://www.byoip.top/" target="_blank">NB 优选服务</a> 就提供，形如 <code>*.vercel.19931110.xyz</code>），原理跟 CF 优选一样。</p><p>注意顺序：<strong>先把域名在 Vercel 里绑好、等 SSL 证书签发出来，再去改 CNAME</strong>，反了会验证失败。这一点跟 CF 那边不太一样。</p></section><section><h3>自己维护优选IP列表<a href="#自己维护优选ip列表"><span>#</span></a></h3><p>如果你特别在意效果，可以写个脚本定期从 Cloudflare IP 段里测速，自动更新 DNS 记录。Cloudflare API 支持程序化操作 DNS 记录，配合 cron 跑就行。这个比较折腾，但适合有自动化需求的场景。</p><hr /></section></section>
<section><h2>结语<a href="#结语"><span>#</span></a></h2><p>优选IP这事说白了就是在 Cloudflare 的路由上找捷径。CNAME 方式最省事，加一条 DNS 记录，几分钟就搞定了。A 记录方式更灵活但维护成本高，看你需不需要。</p><p>效果因人而异——电信用户大概率有明显改善，联通一般般，移动看运气。试一下又不亏，不喜欢改回来就是了。</p><p>最后说一句实话：<strong>这种方式本质上是蹭别人优选好的 IP</strong>。真想从头搞，得自己去测 IP、维护列表、定期更新。大多数人没必要折腾到那一步。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://www.9ll.uk/posts/ai/qwen35/</id>
      <title type="text">本地部署Qwen3.5完全指南：从下载到跑起来</title>
      <published>2026-03-28T00:00:00.000Z</published>
      <updated>2026-08-31T00:00:00.000Z</updated>
      <author><name>HY</name></author>
      <link rel="alternate" href="https://www.9ll.uk/posts/ai/qwen35/"/>
      <summary type="text">手把手教你用LM Studio在自己电脑上跑Qwen3.5，量化格式怎么选、显存不够怎么办、GPU卸载设多少，都讲清楚</summary>
      <content type="html"><![CDATA[<p>为什么要折腾本地大模型？原因其实就几个字：<strong>隐私、免费、离线能用</strong>。</p>
<p>把数据发给第三方 API，总归要担心泄露。本地跑的话，所有对话都留在你自己的硬盘上，谁也看不到。而且不花钱——没有 API 调用费，没有 token 计价，想怎么问就怎么问。断网也能用，出差坐高铁的时候特别香。</p>
<p>Qwen3.5 是通义千问团队出的开源模型，从 0.6B 到 72B 一堆尺寸可选。小的跑在集显笔记本上，大的需要专业显卡。这篇主要讲怎么在自己电脑上把 Qwen3.5 跑起来，从下载工具、选模型、到调设置，每一步都写明白。</p>
<hr />
<section><h2>一、你的电脑带得动吗？先看看硬件<a href="#一你的电脑带得动吗先看看硬件"><span>#</span></a></h2><p>跑本地模型最吃两样东西：<strong>显存（VRAM）和内存（RAM）</strong>。</p><p>模型能塞进显存就全塞进去，速度最快。塞不完的会溢出到内存，速度砍一大截但还能跑。内存也不够？那直接卡死或者根本加载不了。</p><p>给你一个大概的参考：</p>

<table><thead><tr><th>显存大小</th><th>能跑的模型</th><th>能跑的量化精度</th><th>体验</th></tr></thead><tbody><tr><td>6GB（比如 GTX 1660、RTX 3060 6G）</td><td>Qwen3.5-4B</td><td>Q4_K_M 或更低</td><td>能用，回复速度还行，复杂推理差点意思</td></tr><tr><td>8GB（RTX 3060 8G、RTX 4060）</td><td>Qwen3.5-4B 全精度 或 8B 量化版</td><td>4B 跑 Q8，8B 跑 Q4</td><td>4B 很流畅，8B 也跑得动</td></tr><tr><td>12GB（RTX 3060 12G、RTX 4070）</td><td>Qwen3.5-8B 甚至 14B 的量化版</td><td>8B 跑 Q8，14B 跑 Q4</td><td>甜点配置，8B 毫无压力</td></tr><tr><td>16GB+（RTX 4080、4090、A4000）</td><td>Qwen3.5-14B 或更大</td><td>高精度随便选</td><td>爽就完事了</td></tr><tr><td>24GB（RTX 4090、A5000）</td><td>Qwen3.5-30B 量化版</td><td>Q4 到 Q8</td><td>大模型也能本地跑</td></tr></tbody></table><p><strong>没有独显怎么办？</strong> 也能跑。纯 CPU 推理会慢很多，但 Qwen3.5-4B 用 Q4 量化在 16GB 内存的笔记本上是可以动的。就是等回复的时候得有点耐心，一秒蹦两三个字的节奏。</p><p><strong>内存方面</strong>，建议至少 16GB。模型文件本身就要占内存，加上推理时的临时开销，8GB 基本没戏。</p><hr /></section>
<section><h2>二、下载 LM Studio<a href="#二下载-lm-studio"><span>#</span></a></h2><p>LM Studio 是一个本地大模型的图形化运行工具，支持 Windows、Mac、Linux。不用折腾命令行，不用配 Python 环境，下载就能用。对新手来说是最省心的选择。</p><p>到官网 <a href="https://www.lm-studio.me/" target="_blank">lm-studio.me</a> 下载对应系统的安装包，装好就行。</p><p>装完第一次打开，界面是英文的，不过用起来很简单。后面的操作基本就是一个流程：<strong>下载模型 → 加载模型 → 开聊</strong>。</p><hr /></section>
<section><h2>三、下载模型<a href="#三下载模型"><span>#</span></a></h2><section><h3>去哪下？<a href="#去哪下"><span>#</span></a></h3><p>两个地方：</p><ul>
<li><a href="https://www.modelscope.cn/" target="_blank">魔搭社区</a>（国内直连，速度快）</li>
<li><a href="https://huggingface.co/" target="_blank">Hugging Face</a>（要魔法，但模型最全）</li>
</ul><p>两个站的模型是一样的，哪个方便用哪个。</p></section><section><h3>选什么格式？<a href="#选什么格式"><span>#</span></a></h3><p>LM Studio 只认 <strong>GGUF</strong> 格式的模型。去下载站搜 Qwen3.5 的时候，认准文件名里带 <code>.gguf</code> 后缀的。</p><p>推荐看看 <a href="https://www.modelscope.cn/organization/unsloth" target="_blank">Unsloth AI</a> 团队发的版本。他们搞了一种叫 <strong>UD（Unsloth Dynamic）</strong> 的高级量化，比普通量化聪明——不是所有层都压成同一个精度，而是关键层保留高精度，不那么重要的层压狠一点。同样的文件大小，UD 版本比普通版本聪明一丢丢。</p></section><section><h3>量化精度怎么选？<a href="#量化精度怎么选"><span>#</span></a></h3><p>这是新手最迷糊的地方。简单说：<strong>精度越高越聪明，但文件越大、越吃显存。精度越低越省资源，但模型会变”笨”。</strong></p><p>常见的量化格式从高到低排：</p>

<table><thead><tr><th>量化格式</th><th>位数</th><th>大概多大（4B 模型）</th><th>质量</th><th>适合谁</th></tr></thead><tbody><tr><td>FP16</td><td>16-bit</td><td>~8GB</td><td>原版质量</td><td>显存充足的土豪</td></tr><tr><td>Q8_0</td><td>8-bit</td><td>~4.5GB</td><td>接近原版</td><td>日常用已经很好了</td></tr><tr><td>Q6_K</td><td>6-bit</td><td>~3.5GB</td><td>略有下降</td><td>显存紧一点的折中方案</td></tr><tr><td>Q4_K_M</td><td>4-bit（中档）</td><td>~2.8GB</td><td>能用</td><td>显存紧张的首选</td></tr><tr><td>Q4_K_S</td><td>4-bit（低档）</td><td>~2.5GB</td><td>勉强能用</td><td>实在没显存才考虑</td></tr><tr><td>Q3 以下</td><td>3-bit</td><td>更小</td><td>明显变傻</td><td>别折腾了</td></tr></tbody></table><p><strong>我个人的建议</strong>：日常使用选 <strong>Q4_K_M</strong>，性价比最高。显存富裕的话上 Q8。低于 Q4 就别折腾了，模型明显变傻，回答质量下降得厉害。</p></section><section><h3>Unsloth 的 UD 版本和普通版有什么区别？<a href="#unsloth-的-ud-版本和普通版有什么区别"><span>#</span></a></h3><p>用表格说清楚：</p>

<table><thead><tr><th>特性</th><th>Qwen3.5-4B-Q8_0.gguf</th><th>Qwen3.5-4B-UD-Q8_K_XL.gguf</th></tr></thead><tbody><tr><td>量化类型</td><td>标准量化</td><td>高级混合量化（UD + K-quants）</td></tr><tr><td>精度策略</td><td>所有权重统一压成 8-bit</td><td>关键层高精度（16-bit），普通层 8-bit</td></tr><tr><td>文件后缀含义</td><td>Q8_0：传统 8-bit 量化</td><td>UD：Unsloth Dynamic 动态混合精度；XL：Extra Large 高精度；K：K-quant 分组量化策略</td></tr><tr><td>推理速度</td><td>较快（计算逻辑统一，硬件友好）</td><td>稍慢（高精度层计算量大一点）</td></tr><tr><td>智能程度</td><td>高（接近原版 FP16）</td><td>极高（理论上比 Q8_0 更接近原版）</td></tr><tr><td>适用场景</td><td>日常对话、代码编写、追求速度</td><td>复杂推理、高精度任务、不差那点显存</td></tr></tbody></table><p>简单说，UD 版本就是”花了更多的显存换更高的智能”。如果你显存够用，优先选 UD 版。</p></section><section><h3>视觉模型的 mmproj 文件<a href="#视觉模型的-mmproj-文件"><span>#</span></a></h3><p>Qwen3.5 有些版本支持看图（视觉能力）。但光下模型文件还不够，<strong>还得下一个叫 mmproj 的文件</strong>，和模型放在同一个文件夹里才行。</p><p>mmproj 也有精度区分，推荐下 <strong>F32</strong> 的。F16 和 F32 的视觉效果差距肉眼可见，F32 识别准确率明显高一些。</p><p></p><figure><img alt="Qwen3.5模型列表" loading="lazy" width="2048" height="1000" src="/_astro/models.CnchTZHm_Z2wypgX.webp" /><figcaption>Qwen3.5模型列表</figcaption></figure><p></p><hr /></section></section>
<section><h2>四、在 LM Studio 里配置模型路径<a href="#四在-lm-studio-里配置模型路径"><span>#</span></a></h2><p>打开 LM Studio，第一件事是告诉它你把模型存在哪了。</p><p>点左边栏的文件夹图标（或者 Settings 里的 Models Directory），改成你存放 gguf 文件的路径。比如：</p><p><code>D:\AI\models\qwen3.5\Qwen3.5-9B-UD-Q4_K_XL</code></p><p>有个坑要注意：<strong>LM Studio 读取模型的时候，路径得有两级以上的文件夹嵌套</strong>。你直接把 gguf 文件丢在 <code>D:\AI\models\</code> 根目录下，它可能识别不到。建个子文件夹把模型放进去就好了。</p><p></p><figure><img alt="修改LM读取大模型的路径" loading="lazy" width="2048" height="1189" src="/_astro/models_file_path.DvRhFyRK_ZWd07q.webp" /><figcaption>修改LM读取大模型的路径</figcaption></figure><p></p><p>配好路径后，左边栏应该能看到你的模型列出来了。</p><hr /></section>
<section><h2>五、加载模型和 GPU 设置<a href="#五加载模型和-gpu-设置"><span>#</span></a></h2><p>找到模型后，点右边的加载按钮。加载之前可以调参数，最重要的一项是 <strong>GPU Offload（GPU 卸载）</strong>。</p><section><h3>GPU Offload 是什么？<a href="#gpu-offload-是什么"><span>#</span></a></h3><p>大模型的运行靠”层”（layers）。GPU Offload 就是控制<strong>有多少层跑在显卡上，多少层跑在 CPU 和内存上</strong>。</p><ul>
<li>全部卸载到 GPU = 全层跑显卡，速度最快，但得有足够显存</li>
<li>卸载一部分 = 显存装不下的层退回 CPU，速度变慢但能跑</li>
<li>不卸载 = 纯 CPU 推理，最慢</li>
</ul></section><section><h3>设多少合适？<a href="#设多少合适"><span>#</span></a></h3><p><strong>原则：显存能装下多少层，就卸载多少层。</strong> 不确定的话，先把 GPU Offload 拉满，如果加载时报错或者显存爆了，再往回调。</p><p>LM Studio 界面右上角会显示预计占用多少显存/内存，看着那个数字来调就行。目标是<strong>模型全部装进显存</strong>，如果装不下，就尽量多塞几层到 GPU。</p><p>举几个例子帮你理解：</p>

<table><thead><tr><th>你的情况</th><th>GPU Offload 建议</th><th>说明</th></tr></thead><tbody><tr><td>12GB 显存跑 4B Q8（需要约 5GB）</td><td>全部拉满</td><td>绰绰有余，全塞 GPU</td></tr><tr><td>8GB 显存跑 8B Q4（需要约 5.5GB）</td><td>全部或接近全部</td><td>能塞下，注意别开太多后台程序</td></tr><tr><td>6GB 显存跑 4B Q8（需要约 5GB）</td><td>大部分卸载到 GPU</td><td>留一点空间给系统，卸载个 25-28 层左右</td></tr><tr><td>纯集显 / 无独显</td><td>0 或 1</td><td>只能走 CPU 推理，慢但能用</td></tr></tbody></table><p></p><figure><img alt="模型设置" loading="lazy" width="2048" height="1222" src="/_astro/models_setting.D93WqB4l_Z1OB7pW.webp" /><figcaption>模型设置</figcaption></figure><p></p><p>加载成功后，就可以在聊天界面直接跟模型对话了。</p><hr /></section></section>
<section><h2>六、推荐的模型尺寸<a href="#六推荐的模型尺寸"><span>#</span></a></h2><p>Qwen3.5 有很多尺寸：0.6B、1.7B、4B、8B、14B、30B、72B。不是越大越好，适合你硬件的才是最好的。</p><section><h3>怎么选？<a href="#怎么选"><span>#</span></a></h3><ul>
<li><strong>6GB 以下显存</strong>：老实选 4B。别贪心上 8B，跑不动硬跑体验极差。</li>
<li><strong>8-12GB 显存</strong>：4B 全精度或者 8B 量化版，看你对质量的要求。</li>
<li><strong>12-16GB 显存</strong>：8B 全精度首选，14B 量化版也行。</li>
<li><strong>24GB 以上</strong>：14B 全精度甚至 30B 量化版都可以试。</li>
<li><strong>笔记本没独显</strong>：4B Q4 量化，纯 CPU 慢慢跑。</li>
</ul><p>说实话，4B 模型日常聊天、写代码辅助已经够用了。没必要一味追求大模型——跑不动的大模型还不如跑得飞快的小模型好用。</p><hr /></section></section>
<section><h2>七、加载之后先试试<a href="#七加载之后先试试"><span>#</span></a></h2><p>模型加载成功后，别急着干正事。先丢几个问题试试水：</p><ol>
<li><strong>闲聊试试</strong>：随便问个问题，看看回复速度能不能接受，输出是否正常。</li>
<li><strong>中文能力</strong>：让模型用中文写一段东西，看看中文输出质量。</li>
<li><strong>代码能力</strong>：让它写个简单的函数，看看逻辑对不对。</li>
<li><strong>推理能力</strong>：给个需要多步推理的问题，比如”一个房间里有3个杀手，你杀了一个，还剩几个？”。</li>
</ol><p>如果回复速度慢到一秒蹦一两个字，那说明你的显存没装下模型，大量计算跑在 CPU 上了。回去调 GPU Offload，或者换个更小的量化版本。</p><p>如果回复乱码或者答非所问，大概率是模型文件损坏了，重新下载试试。</p><hr /></section>
<section><h2>八、常见问题<a href="#八常见问题"><span>#</span></a></h2><p><strong>Q：加载模型的时候报错，提示内存不够？</strong>
要么你的模型太大了，换个小点的量化版本。要么你电脑后台开着太多东西，关掉一些再试。</p><p><strong>Q：模型回复特别慢，一秒才蹦几个字？</strong>
检查 GPU Offload 是不是没设对，模型大概率跑在 CPU 上了。把更多层卸载到 GPU。</p><p><strong>Q：模型下载下来了但 LM Studio 里看不到？</strong>
检查路径对不对，确保有两级以上的文件夹嵌套。gguf 文件名别乱改。</p><p><strong>Q：Q4 和 Q8 到底差多少？</strong>
日常聊天差别不大。写代码、做数学题、复杂逻辑推理的时候，Q8 明显更靠谱。显存够就上 Q8，不够就 Q4_K_M。</p><p><strong>Q：能同时跑两个模型吗？</strong>
理论上可以，但需要双倍显存/内存。一般没这个必要，除非你在做对比测试。</p><p><strong>Q：CPU 推理是不是完全不能用？</strong>
也不是。Qwen3.5-4B Q4 在 16GB 内存的电脑上，纯 CPU 大概一秒能出 2-5 个 token，聊天勉强能用，写长文就别指望了。</p><hr /></section>
<section><h2>九、跑起来再说<a href="#九跑起来再说"><span>#</span></a></h2><p>本地部署大模型这事，说难不难，说简单也得折腾一下。LM Studio 已经把门槛拉到很低了——不用写代码，不用配环境，图形界面点几下就能跑。</p><p>真正折腾的地方在于<strong>选模型和调设置</strong>。模型太大跑不动，太小不够聪明；量化太高压缩太多，太低又吃不消显存。多试几次，找到自己硬件的甜点就好了。</p><p>Qwen3.5 整体质量不错，4B 在同级别里算是能打的。日常用用完全没问题，写代码辅助、翻译、问答都挺好。真要搞复杂任务，上 8B 或者 14B。</p><p>跑起来之后能玩的花样也不少。比如拿 <a href="/posts/recommend/sillytavern/">SillyTavern（酒馆）</a>当前端，连上 LM Studio 的本地接口搞 AI 角色对话，部署教程我写过一篇。</p><p>别纠结了，先跑起来。有问题再调，比对着参数表发呆强。</p></section>]]></content>
    </entry>
</feed>
