<?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-09-12T00: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/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-08T00: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/cover.WsvdOwkb_Z1js6A1.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-07T00: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> 拆出标题、链接、摘要、时间，每个站最多取 6 篇；</li>
<li>所有来源去重、按时间倒序，合流成一条时间线。</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 个探测成功进了时间线（含我自己），46 篇文章合流，<code>/moments/</code> 这页 HTML 约 550KB。这个体积只发生在朋友圈这一页，其他页面完全不受影响——首页、文章页该多快还多快，因为抓取是构建时的事，线上没有任何动态请求。</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>朋友圈这功能最打动我的地方是它把”友链”从一堆死链接变成了活的：谁更新了、什么时候更新的，一眼看过去就是整个圈子的近况。成本就一次构建多花个几十秒（每天定时任务跑，不占我手）——对静态博客来说，这买卖划算。</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-12T00: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 视口预取。</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></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>说个不太好看的：移动端那 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-04T00: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-08-30T00: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-08-29T00: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-10T00: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-23T00: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-23T00: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-10T00: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-10T00: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-07-25T00: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-07-24T00: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-07-24T00: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-08-31T00: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><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><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><p>所以这个结局我能接受。</p><p>如果真的接受不了，去玩游戏也是一个不错的选择。</p><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></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-08-31T00: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><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></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-07-19T00: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-07-18T00: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-08-31T00: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-07-15T00: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>
</ul><p>上面列出来的域名都是别人维护的优选 CNAME，比如 <code>vip.090227.xyz</code>、<code>cf.011011.xyz</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，就是图个能优化链路。Vercel 你没法选节点，Cloudflare 至少还有这条路。</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>
