<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://blog.amamo.top/</id>
  <title type="text">年华</title>
  <subtitle type="text">欢迎来到我的小破站</subtitle>
  <updated>2026-10-06T00:00:00.000Z</updated>
  <author><name>年华</name></author>
  <link rel="alternate" href="https://blog.amamo.top/"/>
  <link rel="self" href="https://blog.amamo.top/atom.xml"/>
  <generator uri="https://github.com/CuteLeaf/Firefly">Firefly v6.16.3</generator>
    <entry>
      <id>https://blog.amamo.top/posts/fk-ai-potboiler/</id>
      <title type="text">AI短剧越来越low，为什么？</title>
      <published>2026-10-06T00:00:00.000Z</published>
      <updated>2026-10-06T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/fk-ai-potboiler/"/>
      <summary type="text">从AI短剧的观感变化聊起，谈谈剧情和人物到底决定了什么。用《简·爱》当例子，说说情感主题和反套路怎么落地。</summary>
      <content type="html"><![CDATA[<p>最近在刷视频的时候，刷到了很多AI的粗制滥造视频，比较出名的就是“不烧心”了，这类视频剧情相似，人物的脸看着也都很像。</p>
<p>先简述一下，我接触AI短剧的历程吧，我能接触AI也算比较早的，但真正开始看恋爱短剧，是今年的事情，7月份看的比较多的是二次元向小说二创（应该是商单，做的水平是最高的），然后呢到了8月份，刷到的就是比较多的，真人形象的小说二创（这些比较有名的小说的二创）到了现在，就是啥都行“不烧心”这种真人形象的粗制滥造短剧，我很好奇为什么我刷的视频越来越低质了。</p>
<p>这篇不聊AI。我相信，在技术越来越发展的未来，这种视频不会变少，因为做差也变容易了。好的内容观众还是喜欢的，门槛也一起降下来了。</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>
<p>恨最好长在爱里面，仇要算在情里面。孤零零讲一种情绪，读者很快就觉得是套路。</p>
<p>拿《简·爱》对一下比较清楚。</p>
<p>简从没装过自己不爱罗切斯特，她想要他，这条挺要紧。 后来她走，是她自己走的，不是被人撵的。</p>
<p>里德舅妈折磨她那么多年，读者都等着看她报复。 她去了，回来了，把话讲完，就放下了。 不报复，也没跪下去求原谅。</p>
<p>婚礼上知道她已经嫁了人。 这个转折在全书正中间。 后半本写的不是她怎么崩溃，是她之后的日子。</p>
<p>罗切斯特要她留下来当情人，她没答应。 不是不爱，是她在那个关系里什么都没有，只有半个不体面的身份。 圣约翰的求婚条件倒是不错，体面，正当，是条正经路。 她也没答应，因为对方要的是她能干，不是她这个人。 一个有权，一个无礼，都给不了，她才成了简·爱。</p>
<p>最后她回来的时候自己有钱了，罗切斯特眼睛瞎了腿也瘸了。 两个人差不多齐平，这时候的婚姻才算数。</p>
<p>在说回AI视频创作，开头写的有点绝了，还有很多的创作者借助于AI，说了很多有意思，有价值的视频，比如来自B站的<a href="https://b23.tv/gqxBDAq" target="_blank">大圆镜科普</a>这是一位AI科普视频创作者，借助AI，他可以高频并且高质量的去产出科普物理学、医学、哲学等内容的视频，观众从有趣的视频中学习到知识非常好的形式。</p>
<p>我对于使用AI进行创作保持乐观的态度，但是这种，但对以量取胜的策略感到反感，期待未来有更多的优质内容。</p>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/firefly-aemeath-analytics-port/</id>
      <title type="text">把 Aemeath 的站点统计页移植到 Firefly</title>
      <published>2026-10-05T00:00:00.000Z</published>
      <updated>2026-10-05T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/firefly-aemeath-analytics-port/"/>
      <summary type="text">记录把 Aemeath 主题的公开站点统计页 /analytics 移植到 Firefly 博客主题的过程：Umami 公开分享接口、Waline 最新评论、GitHub 推送热力图与侧栏统计快照组件的适配记录。</summary>
      <content type="html"><![CDATA[<p>最近把 Aemeath 主题里的公开统计页搬到了这个 Firefly 主题里。Aemeath 是原作者的项目，站点地址：<a href="http://rainzt.cn" target="_blank">http://rainzt.cn</a>。它把 Umami 公开数据、Waline 最新评论、GitHub 推送热力图聚合在一个独立的 <code>/analytics</code> 页面里，做成了一块”会呼吸”的真实数据面板。这篇文章记录移植过程，顺便说明新加了哪些文件。</p>
<blockquote><p>Aemeath 的原页面地址是作者站点的 <code>/analytics</code>，思路来源与样式实现都值得细看；Firefly 这边做了本地适配。</p></blockquote>
<section><h2>一、新增的三个文件<a href="#一新增的三个文件"><span>#</span></a></h2><section><h3>1. <code>src/pages/analytics.astro</code><a href="#1-srcpagesanalyticsastro"><span>#</span></a></h3><p>独立的「站点统计」页面，访问 <code>/analytics/</code>。基于 Umami 的公开分享接口（只读鉴权，不需要管理员 Token），包含：</p><ul>
<li><strong>总览卡片</strong>：累计访客、累计浏览、今日访客、今日浏览</li>
<li><strong>访问脉冲</strong>：7 / 30 / 90 天切换的浏览量与访客趋势</li>
<li><strong>热门页面排行</strong>：按路径的 Top 页面</li>
<li><strong>访客分布</strong>：设备、浏览器、国内地域分布的环形图与图例</li>
<li><strong>流量来源</strong>：channel 维度来源渠道</li>
<li><strong>部署动态</strong>：GitHub main 分支推送热力图</li>
<li><strong>最新评论</strong>：读取 Waline 公开评论流</li>
</ul><p>页面重点做了容错：单个接口超时只影响对应模块，其他模块照常显示；加了 2 分钟级 localStorage 缓存，Swup 切页后重新挂载动画与数据。</p></section><section><h3>2. <code>src/components/widget/AnalyticsSnapshot.astro</code><a href="#2-srccomponentswidgetanalyticssnapshotastro"><span>#</span></a></h3><p>侧栏用的统计快照小组件，展示设备分布和浏览器偏好的环形图（donut），读取同一组 Umami 公开接口，适合挂在侧栏或首页。</p></section><section><h3>3. <code>src/pages/api/github-pushes.json.ts</code><a href="#3-srcpagesapigithub-pushesjsonts"><span>#</span></a></h3><p>构建期预渲染的 JSON 接口 <code>/api/github-pushes.json</code>：服务端执行 <code>git log HEAD --since=53 weeks ago</code> 读取提交时间，供统计页的部署热力图使用。移植时把硬编码的仓库名从 <code>Jarvis0227/Aemeath</code> 改成了本站的 <code>qwc-ch/Firefly</code>。</p></section></section>
<section><h2>二、配置上的改动<a href="#二配置上的改动"><span>#</span></a></h2><p>只动了配置，不破坏原有结构：</p><ul>
<li><code>src/config/analyticsConfig.ts</code>：给 Umami 配置补 <code>shareId</code>、<code>shareApiBase</code>（指向自建的 <code>https://umami.520781.xyz</code>）、<code>historicalStats</code>、<code>showPageViews</code>、<code>showSiteStats</code></li>
<li><code>src/types/analyticsConfig.ts</code>：同步补充对应类型字段</li>
<li><code>src/config/navBarConfig.ts</code>：「其他」下拉里原来指向 Umami 外部分享页的「统计」，改成本地 <code>/analytics/</code> 入口</li>
</ul><p>Waline 已经在 <code>src/config/commentConfig.ts</code> 里启用，统计页的最新评论模块会直接复用它的 <code>serverURL</code>，不用额外配置。</p></section>
<section><h2>三、验证方式<a href="#三验证方式"><span>#</span></a></h2><ul>
<li><code>pnpm astro check</code>：282 个文件，0 错误 0 警告</li>
<li><code>pnpm exec biome ci</code> 对新增/改动文件：通过</li>
</ul><p>构建流程没有跑完整 build（本地较慢），静态产物和接口在 <code>pnpm astro check</code> 的类型检查下已经通过；部署后访问 <code>/analytics/</code> 和 <code>/api/github-pushes.json</code> 即可看到效果。</p></section>
<section><h2>结语<a href="#结语"><span>#</span></a></h2><p>这套统计页的价值在于”公开可读”——不需要暴露 Umami 管理后台，访客也能看到站点的真实活力。移植到 Firefly 后，主题原有的 <code>UmamiStats</code> 侧栏小组件保持不变，<code>/analytics</code> 只是多了一个更完整的数据总览入口。感谢 Aemeath 作者的开源与分享。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/dsh-fs-link2symlink-fix/</id>
      <title type="text">dsh-fs-link2symlink-fix：一个插件修好 dsh 在 PRoot 下 write 丢文件</title>
      <published>2026-10-03T00:00:00.000Z</published>
      <updated>2026-10-03T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/dsh-fs-link2symlink-fix/"/>
      <summary type="text">dsh 的 write 工具在 Termux/PRoot 里新建文件只留一个悬空的 .l2s 符号链接，内容直接蒸发。排查到 PRoot 的 --link2symlink 把 link(2) 仿真坏了，于是写了个零依赖插件在发布原语上把它兜住。</summary>
      <content type="html"><![CDATA[<section><h2>症状：工具说写好了，文件没了<a href="#症状工具说写好了文件没了"><span>#</span></a></h2><p>这台机器上的 dsh 跑在 Termux + PRoot 容器里。某天开始，用 <code>write</code> 工具<strong>新建</strong>文件会变成这样：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>$</span><span> ls -la</span></div></div><div><div><div>2</div></div><div><span>lrwxrwxrwx hello.txt -&gt; .../.hello.txt.&lt;pid&gt;.&lt;uuid&gt;.tmpdir/.l2s.hello.txt.tmp0001</span></div></div><div><div><div>3</div></div><div><span>$</span><span> cat hello.txt</span></div></div><div><div><div>4</div></div><div><span>cat: hello.txt: No such file or directory</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>工具那边返回的是干干净净的 <code>Created file</code>，磁盘上却是一个<strong>悬空的 <code>.l2s.</code> 符号链接</strong>，内容已经不存在了。</p><p>几个很明确的特征：</p><ul>
<li><strong>只有新文件会丢</strong>。覆盖已有文件、<code>edit</code> 改文件都完全正常。</li>
<li>目录里会残留 <code>.l2s.*</code> 文件和 <code>.&lt;name&gt;.&lt;pid&gt;.&lt;uuid&gt;.tmpdir/</code> 目录。</li>
<li>全程没有任何报错，就是文件”凭空消失”。</li>
</ul><p>最黑色幽默的一次：我给这个修复插件写的 4 个源文件（<code>index.js</code>、<code>package.json</code>、<code>cordis.patch.yml</code>、<code>README.md</code>）<strong>就是用 <code>write</code> 工具创建的，全部变成了悬空链接</strong>。最后只能退回 <code>cat &gt; file &lt;&lt;'EOF'</code> 用 bash 重新写了一遍。这个自证比任何复现脚本都有说服力。</p></section>
<section><h2>定位：<code>.l2s</code> 到底是谁<a href="#定位l2s-到底是谁"><span>#</span></a></h2><p>顺着文件名查，dsh 自己的代码里根本搜不到 <code>l2s</code> 这个词。真正的来源是容器的启动方式：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>proot</span><span> </span><span>--change-id=1001:1001</span><span> </span><span>--pwd=/home/qwc</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>      </span><span>--rootfs=.../ubuntu-noble_arm64</span><span> </span><span>\</span></div></div><div><div><div>3</div></div><div><span>      </span><span>--link2symlink</span><span> </span><span>...</span><span>        </span><span># ← 就是它</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>--link2symlink</code> 是 Termux/TMOE 系 PRoot 的一个扩展：Android 的存储上经常建不了真硬链接，于是 PRoot 拦截 <code>link(2)</code>，用<strong>符号链接来仿真硬链接</strong>。</p><p>所以 <code>.l2s.</code> = <strong>link to symlink</strong>。这个名字既是线索也是答案。</p></section>
<section><h2>元凶：新建文件走的是硬链接<a href="#元凶新建文件走的是硬链接"><span>#</span></a></h2><p>dsh 的本地文件系统后端 <code>@deepseek-ai/dsh-fs-local</code>，在 <code>writeFileAtomic</code> 里对<strong>新建文件</strong>和<strong>覆盖文件</strong>用了两种不同的发布方式：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// dsh-fs-local/lib/index.js（节选）</span></div></div><div><div><div>2</div></div><div><span>const</span><span><span> </span><span>stagingDir</span><span> </span></span><span>=</span><span> </span><span>join</span><span>(</span><span>directory</span><span>,</span><span> </span><span>`</span><span>.</span><span>${</span><span>basename</span><span>(</span><span>absolutePath</span><span>)</span><span>}</span><span>.</span><span>${</span><span>pid</span><span>}</span><span>.</span><span>${</span><span>uuid</span><span>}</span><span>.tmpdir</span><span>`</span><span>);</span></div></div><div><div><div>3</div></div><div><span>const</span><span><span> </span><span>tempPath</span><span> </span></span><span>=</span><span> </span><span>join</span><span>(</span><span>stagingDir</span><span>,</span><span> </span><span>`${</span><span>basename</span><span>(</span><span>absolutePath</span><span>)</span><span>}</span><span>.tmp</span><span>`</span><span>);</span></div></div><div><div><div>4</div></div><div><span>// ...写入 tempPath、sync、chmod...</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>if</span><span> </span><span>(</span><span>createIfAbsent </span><span>!==</span><span> </span><span>void</span><span> </span><span>0</span><span>)</span><span> </span><span>await</span><span> </span><span>linkFile</span><span>(</span><span>tempPath</span><span>,</span><span> absolutePath</span><span>);</span><span> </span><span>// ← 新建文件：硬链接</span></div></div><div><div><div>7</div></div><div><span>else</span><span> </span><span>await</span><span> </span><span>rename</span><span>(</span><span>tempPath</span><span>,</span><span> absolutePath</span><span>);</span><span>                             </span><span>// ← 覆盖/编辑：rename</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>await</span><span> </span><span>removeStagingDir</span><span>(</span><span>stagingDir</span><span>);</span><span>   </span><span>// 删掉整个 staging 目录</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>设计意图很清楚：先在自己的 staging 目录里把内容写完整、同步好，然后用 <code>link()</code> 一步”无覆盖地”发布出去——如果目标已存在，<code>link()</code> 会抛 <code>EEXIST</code>，dsh 再把它翻译成 <code>FS_NOT_OBSERVED</code>。这是很标准的原子 no-replace 写法。</p><p>但在 <code>--link2symlink</code> 下，<code>link()</code> 不再是硬链接，而是变成了一个符号链接代理：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>link(temp, target)</span></div></div><div><div><div>2</div></div><div><span><span>   </span></span><span>└─ target -&gt; &lt;stagingDir&gt;/.l2s.temp0001 -&gt; &lt;stagingDir&gt;/.l2s.temp0001.0001</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>removeStagingDir(stagingDir)</span></div></div><div><div><div>5</div></div><div><span><span>   </span></span><span>└─ 代理文件被一起删掉 → target 悬空，内容不可恢复</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>代理文件就放在 staging 目录<strong>里面</strong>，而 dsh 紧接着就把整个 staging 目录删了。于是：写成功、文件”在”、内容没了。</p><p>覆盖面没事，是因为 <code>rename()</code> 是真的把文件移出 staging 目录，PRoot 处理得好好的。<strong>所以这个 bug 精确地只发生在”新建”这一条路径上。</strong></p></section>
<section><h2>为什么不能”事后修复悬空链接”<a href="#为什么不能事后修复悬空链接"><span>#</span></a></h2><p>我一开始的想法也是写个脚本扫描 <code>.l2s</code> 悬空链接去还原。但看清楚链路后就知道这条路走不通：内容在 <code>removeStagingDir</code> 那一刻就已经随着代理文件被删除了，等你在文件系统上看到悬空链接时，字节早没了。</p><p><strong>修复必须发生在”发布”这一步之前</strong>，而不是之后。</p></section>
<section><h2>修复：换掉发布原语，而不是改 dsh<a href="#修复换掉发布原语而不是改-dsh"><span>#</span></a></h2><p>顺着代码往下看，<code>writeFileAtomic</code> 其实留了一个口子：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span><span> </span><span>linkFile</span><span> </span></span><span>=</span><span> internals</span><span>.</span><span>linkFile </span><span>??</span><span> link</span><span>;</span><span>   </span><span>// internals 优先，否则用内置 link</span></div></div><div><div><div>2</div></div><div><span>...</span></div></div><div><div><div>3</div></div><div><span>await</span><span> </span><span>linkFile</span><span>(</span><span>tempPath</span><span>,</span><span> absolutePath</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>internals</code> 是 <code>LocalFileSystem</code> 实例上的一个对象，而 <code>dsh-fs-sandbox</code> 的 <code>SandboxedFileSystem</code> 直接继承它——也就是说，只要拿到 <code>ctx.fs</code> 这个服务实例，把 <code>internals.linkFile</code> 换掉，两种后端就一起覆盖了，<strong>dsh 本体一个字节都不用改</strong>。</p><p>插件的做法分三步：</p><ol>
<li>
<p><strong>加载时探测</strong>。在临时目录里原样复刻 dsh 的发布序列，看结果：</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>await</span><span> </span><span>writeFile</span><span>(</span><span>source</span><span>,</span><span><span> </span><span>PROBE_CONTENT</span></span><span>,</span><span> </span><span>{</span><span> </span><span>mode</span><span>:</span><span> </span><span>0o600</span><span>,</span><span> </span><span>flag</span><span>:</span><span> </span><span>"</span><span>wx</span><span>"</span><span> </span><span>});</span></div></div><div><div><div>2</div></div><div><span>await</span><span> </span><span>nativeLink</span><span>(</span><span>source</span><span>,</span><span> target</span><span>);</span></div></div><div><div><div>3</div></div><div><span>await</span><span> </span><span>rm</span><span>(</span><span>staging</span><span>,</span><span> </span><span>{</span><span> </span><span>recursive</span><span>:</span><span> </span><span>true</span><span>,</span><span> </span><span>force</span><span>:</span><span> </span><span>true</span><span> </span><span>});</span><span>   </span><span>// dsh 也会删 staging</span></div></div><div><div><div>4</div></div><div><span>const</span><span><span> </span><span>observed</span><span> </span></span><span>=</span><span> </span><span>await</span><span> </span><span>readFile</span><span>(</span><span>target</span><span>,</span><span> </span><span>"</span><span>utf8</span><span>"</span><span>);</span><span>        </span><span>// PRoot 下这里 ENOENT</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
</li>
<li>
<p><strong>可信就完全不动</strong>。普通 Linux/macOS 上硬链接是好的，探测通过，保留原生 <code>link()</code>，行为零变化。</p>
</li>
<li>
<p><strong>不可信才替换</strong>。用”独占创建 + 内容拷贝”来等价实现硬链接的语义：</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span><span> </span><span>handle</span><span> </span></span><span>=</span><span> </span><span>await</span><span> </span><span>open</span><span>(</span><span>targetPath</span><span>,</span><span> </span><span>"</span><span>wx</span><span>"</span><span>,</span><span> mode </span><span>&amp;</span><span> </span><span>0o7777</span><span>);</span><span> </span><span>// EEXIST = 竞争失败，语义不变</span></div></div><div><div><div>2</div></div><div><span>await</span><span> handle</span><span>.</span><span>writeFile</span><span>(</span><span>await</span><span> </span><span>readFile</span><span>(</span><span>sourcePath</span><span>));</span></div></div><div><div><div>3</div></div><div><span>await</span><span> handle</span><span>.</span><span>sync</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<ul>
<li><code>open(..., "wx")</code> 保住了 no-replace：并发创建者依然会拿到 <code>EEXIST</code>，dsh 依然把它翻成 <code>FS_NOT_OBSERVED</code>，和原生行为一致；</li>
<li>内容从 staging 文件里拷出来，不再依赖 staging 目录活到发布之后；</li>
<li>staging 文件的权限位被保留，新文件落地的权限和 dsh 原本的选择一样（实测都是 <code>0600</code>）。</li>
</ul>
</li>
</ol><p>探测只跑一次，之后每次发布都 <code>await</code> 同一个 promise；安装又是同步完成的，所以不存在”探测还没结束就漏一次写”的窗口。</p></section>
<section><h2>安装<a href="#安装"><span>#</span></a></h2><p>插件零运行时依赖，<code>index.js</code> 只用 Node 内置模块。两种装法：</p><p><strong>方式 A：绝对路径（推荐给容器/离线环境）</strong>——在 <code>~/.dsh/profiles/web/cordis.patch.yml</code> 里插一行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>-</span><span> </span><span>insert</span><span>:</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>-</span><span> </span><span>id</span><span>:</span><span> </span><span>fs-link2symlink-fix</span></div></div><div><div><div>3</div></div><div><span>      </span><span>name</span><span>:</span><span> </span><span>/absolute/path/to/dsh-fs-link2symlink-fix/index.js</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>profile 配了 <code>patchReload: "live"</code>，<strong>保存即热重载</strong>，不用重启 dsh；也不经过 pnpm，<code>pnpm install</code> 不会把它清掉。</p><p><strong>方式 B：当 bundle 装</strong>：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>dsh</span><span> </span><span>plugin</span><span> </span><span>--profile</span><span> </span><span>web</span><span> </span><span>add</span><span> </span><span>github:qwc-ch/dsh-fs-link2symlink-fix</span></div></div><div><div><div>2</div></div><div><span># 再把它加进 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>验证<a href="#验证"><span>#</span></a></h2><p>新建文件之后：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>ls</span><span> </span><span>-la</span><span>  </span><span>&lt;new-file&gt;</span><span>     </span><span># 应该是 -rw-------，不是 lrwxrwxrwx</span></div></div><div><div><div>2</div></div><div><span>cat</span><span>     </span><span>&lt;new-file&gt;</span><span>     </span><span># 内容完整</span></div></div><div><div><div>3</div></div><div><span>find</span><span> </span><span>.</span><span> </span><span>-name</span><span> </span><span>'</span><span>.l2s.*</span><span>'</span><span>  </span><span># 没有残留</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>插件加载时会打一行日志说明走了哪条分支，比如这台机器上是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>fs-link2symlink-fix: link() unusable (ENOTEMPTY); publishing new files by exclusive-create copy</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>健康主机上则是 <code>native hard link survived staging removal; native link publication retained</code>。</p><p>我这边除了新建，还顺手验证了嵌套目录、并发新建、覆盖回归、<code>edit</code> 回归，以及补充的单测（真实文件 / 非 symlink / 内容 / <code>0600</code> 权限 / 竞争时 <code>EEXIST</code> / 停用回滚），都过了。</p></section>
<section><h2>几个注意点<a href="#几个注意点"><span>#</span></a></h2><ul>
<li><strong><code>internals.linkFile</code> 是测试接缝，不是正式公开 API。</strong> 这是”不 fork dsh 又要修部署环境问题”的务实选择。插件对”接缝不存在”的情况做了安全降级（直接什么都不做），但如果哪天上游把它删了，插件会静默失效——那时候最好去上游提个 issue。</li>
<li><strong>探测失败一律保守兜底。</strong> 连临时目录都建不了、或者 <code>link()</code> 直接报错，都会被判定为”不可信”，改用拷贝发布。对”文件创建正确性”来说，这是更安全的一侧。</li>
<li><strong>会话日志的 <code>.l2s</code> 垃圾不在覆盖范围。</strong> <code>dsh-session-persistence-jsonl</code> 落盘时也用了 <code>link()</code>，会在 <code>~/.dsh/sessions/**</code> 留 <code>.l2s.*</code> 文件。但它<strong>不会丢日志</strong>（日志随后会被 <code>rename</code> 落成普通文件），只是碍眼，所以插件没去动会话目录。</li>
<li><strong>覆盖/编辑故意不动。</strong> 那两条路径本来就正常，没必要扩大改动面。</li>
</ul></section>
<section><h2>仓库<a href="#仓库"><span>#</span></a></h2><p>源码在 GitHub：<a href="https://github.com/qwc-ch/dsh-fs-link2symlink-fix" target="_blank">qwc-ch/dsh-fs-link2symlink-fix</a>，MIT 协议，就 <code>index.js</code> / <code>package.json</code> / <code>cordis.patch.yml</code> 加一份 README，看完就知道没藏东西。</p><p>如果你也在 Termux/PRoot 里跑 dsh，而且发现”新建的文件过一会儿就没了”，八成是同一个坑——<code>ls -la</code> 看一眼是不是 <code>lrwxrwxrwx</code> 指向 <code>.l2s.*</code> 就知道了。</p><p>说真的，我挺想在手机上认真玩一玩 vibe coding，但折腾下来还是觉得 opencode 这类项目更完善。不过手机终归只能玩一玩，不适合拿来实战。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/firefly-random-cover/</id>
      <title type="text">随机封面修复记：同一篇文章，列表和详情页竟然两张图</title>
      <published>2026-10-03T00:00:00.000Z</published>
      <updated>2026-10-03T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/firefly-random-cover/"/>
      <summary type="text">博客列表页和文章详情页的随机封面每次都不一样，排查后发现是图床 /random 接口不支持种子参数。记录用&quot;构建期固定封面&quot;彻底解决这个问题的过程。</summary>
      <content type="html"><![CDATA[<section><h2>起因<a href="#起因"><span>#</span></a></h2><p>一直有个强迫症级别的问题困扰我：博文列表卡片上的封面图，点进文章页之后<strong>变成了另一张图</strong>。随机封面本来就是 <code>image: 'api'</code> 配的随机图，我一直以为是主题的正常行为，直到有天仔细一看——不对，这已经不是”好不好看”的问题了，列表页预览的图和文章实际的封面完全对不上。</p><p>更糟的是刷新一次列表页，所有封面又全变了。等于同一篇文章，每次访问、每个位置看到的都是随机结果。</p></section>
<section><h2>排查：seed 参数是个摆设？<a href="#排查seed-参数是个摆设"><span>#</span></a></h2><p>主题的封面处理逻辑其实考虑到了这个问题。<code>src/utils/image-utils.ts</code> 里对每篇文章按 id 生成一个哈希，拼成 <code>?v=123456</code> 这样的查询参数附加到随机图 API 后面，意图很明显：<strong>让同一篇文章每次都拿到同一张图</strong>。</p><p>列表卡片和文章页生成的 URL 完全一致。理论上浏览器该拿到同一张图才对。</p><p>那问题多半出在 API 端。直接用 curl 验证——用同一个 <code>v</code> 值连续请求三次：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>for</span><span> i </span><span>in</span><span> </span><span>1</span><span> </span><span>2</span><span> </span><span>3</span><span>;</span><span> </span><span>do</span></div></div><div><div><div>2</div></div><div><span>  </span><span>curl</span><span> </span><span>-s</span><span> </span><span>"</span><span>https://tu.520781.xyz/random?type=img&amp;dir=cover&amp;v=12345</span><span>"</span><span> </span><span>-o</span><span> </span><span>seed_</span><span>$i</span></div></div><div><div><div>3</div></div><div><span>done</span></div></div><div><div><div>4</div></div><div><span>md5sum</span><span> </span><span>seed_</span><span>*</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>三个文件的 md5 完全不同，甚至 <code>Content-Type</code> 都在 <code>image/jpeg</code>、<code>image/webp</code>、<code>image/avif</code> 之间横跳。<strong><code>v</code> 参数根本没有被处理</strong>，每次请求都是纯随机。</p><p>再翻图床的 API 文档（图床是我自建的 <a href="https://cfbed.sanyue.de/api/random.html" target="_blank">CloudFlare ImgBed</a>），<code>/random</code> 接口的参数只有 <code>content</code> / <code>type</code> / <code>form</code> / <code>dir</code> / <code>orientation</code> 五个——压根没有种子参数。主题代码和 API 能力之间，隔着一厢情愿。</p><p>也就是说：<strong>只要封面 URL 指向 <code>/random</code> 接口，就不可能做到每篇文章固定一张图</strong>。这是接口层面的能力缺失，不是主题代码的 bug。</p></section>
<section><h2>转机：/random 的隐藏用法<a href="#转机random-的隐藏用法"><span>#</span></a></h2><p>文档里有个细节救了我：<code>/random</code> 默认直接返回图片，但加上 <code>type=url&amp;form=text</code> 后，返回的是一张<strong>确定图片的完整链接</strong>：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>"</span><span>https://tu.520781.xyz/random?type=url&amp;form=text&amp;dir=cover</span><span>"</span></div></div><div><div><div>2</div></div><div><span># https://a.520781.xyz/file/cover/绘梨衣/1788687100738_xxx.webp</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>返回的是 <code>/file/...</code> 下的静态文件地址——随机性在返回链接这一刻就”结算”完了，之后这个链接永远指向同一张图，还能吃满 CDN 缓存。</p><p>思路立刻清晰：<strong>构建时调一次接口，把每篇文章的封面链接钉死存下来，页面渲染时直接用存好的链接。</strong></p></section>
<section><h2>实现<a href="#实现"><span>#</span></a></h2><section><h3>构建脚本 generate-random-covers.ts<a href="#构建脚本-generate-random-coversts"><span>#</span></a></h3><p>新增 <code>scripts/generate-random-covers.ts</code>，插在构建流水线的 <code>astro build</code> 之前：</p><ol>
<li>扫描 <code>src/content/posts/</code>，找出 frontmatter 里 <code>image: 'api'</code> 的文章</li>
<li>每篇调一次 <code>type=url&amp;form=text</code>，拿到确定的图片链接</li>
<li>以 <code>{ "entry.id": "图片URL" }</code> 写入 <code>src/constants/random-covers.json</code>（提交进 git，和 <code>lqips.json</code> 同一套模式）</li>
</ol><p>几个设计要点：</p><ul>
<li><strong>只增不改</strong>：已在映射表里的文章不再请求 API。没有这条，每次构建封面照样漂移，等于白做</li>
<li><strong>自动清理</strong>：文章删了或改回固定封面，映射条目同步移除</li>
<li><strong>失败容忍</strong>：单篇请求失败重试 3 次，仍失败就跳过，渲染端自动回退旧的随机行为，构建不会挂</li>
</ul><p>最容易踩的坑是映射表的 key。最初我用文件名当 key，结果对不上——Astro Content Layer 的 <code>entry.id</code> 并不总是”文件名去扩展名”：它<strong>优先取 frontmatter 的 <code>slug</code> 字段</strong>，没有 slug 才回退到”路径去扩展名后每段过一遍 github-slugger”（中文和空格会被转换，大写字母会小写化）。脚本里照抄了这套规则才对齐：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>computeEntryId</span><span>(</span><span>fileRelPath</span><span>:</span><span> </span><span>string</span><span>,</span><span><span> </span><span>frontmatter</span></span><span>:</span><span> </span><span>string</span><span><span>)</span><span>:</span></span><span> </span><span>string</span><span> </span><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span><span> </span><span>explicitSlug</span><span> </span></span><span>=</span><span> </span><span>extractFrontmatterField</span><span>(</span><span>frontmatter</span><span>,</span><span> </span><span>"</span><span>slug</span><span>"</span><span>);</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>explicitSlug</span><span>)</span><span> </span><span>return</span><span> explicitSlug</span><span>;</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span><span> </span><span>withoutExt</span><span> </span></span><span>=</span><span> fileRelPath</span><span>.</span><span>replace</span><span>(</span><span>/</span><span>\.</span><span>(</span><span>md</span><span>|</span><span>mdx</span><span>|</span><span>markdown</span><span><span>)</span><span>$</span><span>/</span></span><span>i</span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>5</div></div><div><span>  </span><span>return</span><span> withoutExt</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>.</span><span>split</span><span>(</span><span>"</span><span>/</span><span>"</span><span>)</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>.</span><span>map</span><span>((</span><span>segment</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>githubSlug</span><span>(</span><span>segment</span><span>))</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>.</span><span>join</span><span>(</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>replace</span><span>(</span><span>/</span><span><span>\/</span><span>index</span></span><span><span>$</span><span>/</span></span><span>,</span><span> </span><span>""</span><span>);</span></div></div><div><div><div>10</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>渲染端改造<a href="#渲染端改造"><span>#</span></a></h3><p><code>image-utils.ts</code> 的改动很轻：模块顶层 import 映射表，<code>processCoverImageSync</code> 在走 API 拼接之前先查一次固定映射：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> randomCoversData </span><span>from</span><span> </span><span>"</span><span>@constants/random-covers.json</span><span>"</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>// ...</span></div></div><div><div><div>4</div></div><div><span>const</span><span><span> </span><span>pinned</span><span> </span></span><span>=</span><span> </span><span>getPinnedCover</span><span>(</span><span>seed</span><span>);</span></div></div><div><div><div>5</div></div><div><span>if</span><span> </span><span>(</span><span>pinned</span><span>)</span><span> </span><span>return</span><span> pinned</span><span>;</span></div></div><div><div><div>6</div></div><div><span>// 未固定的文章仍走原来的 API + 随机兜底</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>getApiUrlList</code> 把固定链接排在重试列表首位，随机 API 殿后——万一哪天图床文件被删、固定链接 404，还能退回旧行为。</p></section><section><h3>顺手加的防重复<a href="#顺手加的防重复"><span>#</span></a></h3><p>第一版跑完发现 39 篇文章里有几张图撞车了——两张卡片显示同一张封面，看起来像个 bug。加了去重分配：每篇新文章最多重取 5 次直到不与其他文章重复，超过上限才接受重复。重跑一次后 39 篇全部唯一。</p></section></section>
<section><h2>验证<a href="#验证"><span>#</span></a></h2><p>脚本是幂等的（重复执行不产生 API 请求、文件字节级不变），但真正要回答的问题是：<strong>产物里列表和详情到底是不是同一张图？</strong></p><p>跑完 <code>pnpm build</code> 后写了个小脚本扫 <code>dist/</code>，对每篇同时在列表卡片和文章页出现封面的文章，比对两处 <code>&lt;img src&gt;</code> 属性——27 篇全部一致，0 处不匹配。</p></section>
<section><h2>效果与使用<a href="#效果与使用"><span>#</span></a></h2><ul>
<li>列表页、文章页、分享海报引用的封面从此完全一致</li>
<li>封面变成静态文件链接，CDN 可缓存，刷新不再换图</li>
<li>日常无感知：新文章写 <code>image: 'api'</code>，下次构建自动分配</li>
</ul><p>想给某篇文章换封面，把 <code>random-covers.json</code> 里对应条目删掉再 <code>pnpm covers</code> 即可；也可以直接手动填任意图片 URL。</p></section>
<section><h2>小结<a href="#小结"><span>#</span></a></h2><p>这次的问题链条挺典型：主题代码的”种子”设计看起来天衣无缝，但种子要生效得靠 API 端配合，而大多数随机图 API 并不支持。排查的关键一步是用 curl 剥掉所有中间层直接验证接口行为——**两次请求返回的字节一样吗？**一样才谈得上稳定。</p><p>解法也没有在渲染层纠缠（前端缓存、localStorage 同步都是歪路），而是把”随机”这件事挪到构建期一次性结算：随机只发生在构建时，发布出去的永远是确定结果。这和 SSG 的哲学意外地合拍。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/zen-me-rang-dsh-sou-suo-yong-shang-exa-cha-jian/</id>
      <title type="text">怎么让 dsh 搜索用上 Exa 插件</title>
      <published>2026-10-02T00:00:00.000Z</published>
      <updated>2026-10-05T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/zen-me-rang-dsh-sou-suo-yong-shang-exa-cha-jian/"/>
      <summary type="text">dsh 基础包把 web 行的 searchProvider 钉死为 deepseek-official，导致搜索插件的&quot;零配置自动接管&quot;不会生效；本文记录排查过程与 profile 补丁层的修复方法。2026-10-05 勘误：profile 补丁层是整值替换而非合并，web 行必须把 fetchProvider 一并写上。</summary>
      <content type="html"><![CDATA[<section><h2>背景<a href="#背景"><span>#</span></a></h2><p>dsh（<a href="https://github.com/deepseek-ai/deepseek-harness" target="_blank">DeepSeek Harness</a>，本机版本 <code>@deepseek-ai/dsh@0.1.5-rc.3</code>）内置了 <code>web_search</code> 工具，官方搜索 provider（<code>dsh-web-search-deepseek</code>）需要 <code>DEEPSEEK_API_KEY</code>，而我没配这个 key，所以搜索一直是坏的。</p><p>于是我装了社区的搜索插件 <a href="https://github.com/TonyDua/dsh-web-search-exa" target="_blank">@tonydua/dsh-web-search-exa</a>（npm 包 <code>@tonydua/dsh-web-search-exa@0.1.5</code>）：它为 <code>ctx.web</code> seam 提供一个 <a href="https://exa.ai" target="_blank">Exa</a> 的 <code>WebSearchProvider</code>，<strong>零配置、无需 API key</strong>——无 key 时走匿名 MCP 兜底（<code>mcp.exa.ai/mcp</code>），有 key 时走 REST。</p><p>插件 README 里说得很清楚：</p><blockquote><p>Selection: with no API key the official deepseek provider is unavailable, so the seam auto-selects this provider (zero-config).</p></blockquote><p>意思是没有 key 时官方 provider 不可用，seam 会自动选中它。然而装好之后，搜索还是报错。</p></section>
<section><h2>问题现象<a href="#问题现象"><span>#</span></a></h2><p>插件安装成功（market 日志显示 <code>@tonydua/dsh-web-search-exa@0.1.5 exit=0</code>），但 <code>web_search</code> 依然报：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>DeepSeek search has no API key for "DEEPSEEK_API_KEY";</span></div></div><div><div><div>2</div></div><div><span>store it through the credentials service ..., export it in the</span></div></div><div><div><div>3</div></div><div><span>launching environment, or set a literal "apiKey" in the</span></div></div><div><div><div>4</div></div><div><span>web-search-deepseek config</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>报错来自官方的 DeepSeek provider——也就是说搜索<strong>根本没走 Exa 插件</strong>。</p></section>
<section><h2>排查过程<a href="#排查过程"><span>#</span></a></h2><p>按排除法一步步来：</p><section><h3>1. 插件安装正确吗？✓<a href="#1-插件安装正确吗"><span>#</span></a></h3><p>profile（<code>~/.dsh/profiles/web/</code>）的 <code>package.json</code> 里 <code>dsh.profile.bundles</code> 包含 <code>@tonydua/dsh-web-search-exa</code>，它自带的 <code>cordis.patch.yml</code> 会向加载器插入 <code>web-search-exa</code> 行，<code>apply()</code> 是无条件注册的，不依赖任何设置项。</p></section><section><h3>2. 模块能加载吗？✓<a href="#2-模块能加载吗"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>$</span><span> </span><span>node</span><span> </span><span>-e</span><span> </span><span>"</span><span>import('@tonydua/dsh-web-search-exa').then(m =&gt; console.log(Object.keys(m)))</span><span>"</span></div></div><div><div><div>2</div></div><div><span>Config,</span><span> </span><span>DEFAULT_MCP_URL,</span><span> </span><span>ExaSearchProvider,</span><span> </span><span>apply,</span><span> </span><span>inject,</span><span> </span><span>...</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>3. 网络可达吗？✓<a href="#3-网络可达吗"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>$</span><span> </span><span>curl</span><span> </span><span>-o</span><span> </span><span>/dev/null</span><span> </span><span>-w</span><span> </span><span>"</span><span>%{http_code}\n</span><span>"</span><span> </span><span>https://mcp.exa.ai/mcp</span><span>      </span><span># → 405（GET 的预期响应）</span></div></div><div><div><div>2</div></div><div><span>$</span><span> </span><span>curl</span><span> </span><span>-o</span><span> </span><span>/dev/null</span><span> </span><span>-w</span><span> </span><span>"</span><span>%{http_code}\n</span><span>"</span><span> </span><span>https://api.exa.ai/search</span><span>   </span><span># → 404（同上）</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>4. 用插件同款请求直连实测<a href="#4-用插件同款请求直连实测"><span>#</span></a></h3><p>模仿插件的匿名 MCP 请求（POST + <code>tools/call</code>）：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>$</span><span> </span><span>node</span><span> </span><span>-e</span><span> </span><span>"</span><span>fetch('https://mcp.exa.ai/mcp', { method: 'POST', headers: {...}, body })</span><span>"</span></div></div><div><div><div>2</div></div><div><span>→</span><span> </span><span>HTTP</span><span> </span><span>200，text/event-stream，直接返回了真实搜索结果（J.K.</span><span> </span><span>罗琳）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>结论：网络没问题、插件没问题，问题出在 provider 的选择上。</strong></p></section></section>
<section><h2>根因：<code>web</code> 行把 <code>searchProvider</code> 钉死在 <code>deepseek-official</code><a href="#根因web-行把-searchprovider-钉死在-deepseek-official"><span>#</span></a></h2><p>翻 dsh-base 基础包的 <code>cordis.patch.yml</code>，<code>web</code> 行长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>-</span><span> </span><span>id</span><span>:</span><span> </span><span>web</span></div></div><div><div><div>2</div></div><div><span>  </span><span>name</span><span>:</span><span> </span><span>'</span><span>@deepseek-ai/dsh-web</span><span>'</span></div></div><div><div><div>3</div></div><div><span>  </span><span>config</span><span>:</span></div></div><div><div><div>4</div></div><div><span>    </span><span>searchProvider</span><span>:</span><span> </span><span>deepseek-official</span><span>   </span><span># ← 钉死了</span></div></div><div><div><div>5</div></div><div><span>    </span><span>fetchProvider</span><span>:</span><span> </span><span>http</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>WebRuntime</code> 构造时读 <code>config.searchProvider ?? process.env.DSH_WEB_SEARCH_PROVIDER</code>，于是 <code>searchProviderId = "deepseek-official"</code>。再看搜索时 seam 的选择规则（<code>dsh-web</code> 源码，<code>0.1.5-rc.3</code>）：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>resolveProvider</span><span>(</span><span>selection</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span> </span><span>{</span><span><span> </span><span>configuredId</span></span><span>,</span><span><span> </span><span>providers</span><span> </span></span><span>}</span><span> </span><span>=</span><span> selection</span><span>;</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>configuredId </span><span>!==</span><span> </span><span>void</span><span> </span><span>0</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>// 配置了 id：直接返回这个 provider，没注册/不可用才报错</span></div></div><div><div><div>5</div></div><div><span>    </span><span>const</span><span><span> </span><span>provider</span><span> </span></span><span>=</span><span> providers</span><span>.</span><span>get</span><span>(</span><span>configuredId</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>return</span><span> provider</span><span>;</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>// 没配置 id：过滤出"可用"的 provider</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>// 恰好 1 个 → 用它；&gt;1 个 → 报 AMBIGUOUS；0 个 → 报 UNAVAILABLE</span></div></div><div><div><div>11</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>关键就在这里：</p><ul>
<li>插件宣称的”零配置自动接管”依赖<strong>第二条路</strong>——没配置 id 时，官方 provider 因无 key 不可用，过滤后只剩 Exa，自动选中。</li>
<li>但 dsh-base 把 <code>searchProvider</code> 钉死为 <code>deepseek-official</code>，选择器拿到配置的 id 就<strong>直接返回</strong>，永远走不到第二条路。</li>
<li>更微妙的是，DeepSeek provider 的 <code>available()</code> 检查的是 <code>options.resolveApiKey !== void 0</code>——这个 thunk 恒为定义，所以它<strong>永远”可用”</strong>，只在真正 <code>search()</code> 解析 key 时才抛凭据错误。</li>
</ul><p>于是完整的链路是：每次搜索 → 配置 id <code>deepseek-official</code> → 命中 DeepSeek provider → 无 <code>DEEPSEEK_API_KEY</code> → 报错。Exa 插件从头到尾没被咨询过。</p></section>
<section><h2>修复：profile 补丁层按 id 覆盖<a href="#修复profile-补丁层按-id-覆盖"><span>#</span></a></h2><p>用户自己的 <code>~/.dsh/profiles/web/cordis.patch.yml</code> 应用在所有 bundle 层<strong>之后</strong>，可以按 id 覆盖行：</p><div><figure><figcaption><span>~/.dsh/profiles/web/cordis.patch.yml</span></figcaption><pre><code><div><div><div>1</div></div><div><span>-</span><span> </span><span>id</span><span>:</span><span> </span><span>web</span></div></div><div><div><div>2</div></div><div><span>  </span><span>config</span><span>:</span></div></div><div><div><div>3</div></div><div><span>    </span><span>searchProvider</span><span>:</span><span> </span><span>exa</span></div></div><div><div><div>4</div></div><div><span>    </span><span>fetchProvider</span><span>:</span><span> </span><span>http</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>注意 <code>fetchProvider: http</code> <strong>必须一并写上</strong>。这是本文初版的一个勘误：初版写的是”加载器用 <code>Object.assign</code> 合并 config override，没写的字段保留原值”，这是错的。实际查 <code>cordis-plugin-include</code> 的 <code>applyEntryPatches</code> 源码，覆盖语义是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>for</span><span> </span><span>(</span><span>const</span><span> </span><span>[</span><span>key</span><span>,</span><span><span> </span><span>value</span></span><span>]</span><span> </span><span>of</span><span> Object</span><span>.</span><span>entries</span><span>(</span><span>overrides</span><span>))</span><span> </span><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>key </span><span>===</span><span> </span><span>"</span><span>id</span><span>"</span><span>)</span><span> </span><span>continue</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>target</span><span>[</span><span>key</span><span>]</span><span> </span><span>=</span><span> value</span><span>;</span><span>   </span><span>// ← 整值替换，没有深合并</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>config</code> 作为一个整体被替换掉，只写 <code>searchProvider</code> 会把 <code>fetchProvider</code> 丢掉。而 <code>WebRuntime.Config</code> 的 zod schema 里 <code>searchProvider</code> / <code>fetchProvider</code> 都是必填（<code>z.string()</code>），构造时 <code>config.fetchProvider ?? process.env.DSH_WEB_FETCH_PROVIDER</code> 会回退到环境变量——没配就是 <code>undefined</code>，<code>web_fetch</code> 会直接报”未配置 provider”。dsh-base 自家注释也写明了：“A patch replaces the targeted row’s whole <code>config</code> rather than merging into it”。按 id 覆盖行时，正确姿势是<strong>把整行 config 完整重述一遍</strong>，只改你想改的字段。</p><p>另外 profile 配了 <code>patchReload: "live"</code>，这个改动<strong>保存即热重载生效，无需重启</strong>。</p><p>之后 <code>web_search</code> 就走 Exa 插件了——搜”哈利波特作者是谁”直接返回真实结果（J.K. 罗琳），已在 2026-10-05 用上述两字段写法实测验证通过。</p></section>
<section><h2>注意事项<a href="#注意事项"><span>#</span></a></h2><ul>
<li><strong>熔断器</strong>：Exa provider 连续 3 次瞬时失败（5xx、429、网络错误）会跳闸，5 分钟内 <code>available()</code> 为 false；冷却结束后下一次搜索会重新探测，成功一次即重置。因为现在配置了显式 id，跳闸期间 seam 会报 <code>configured web provider "exa" is registered but unavailable</code>——至少是个清晰的错误，而不是误导人的 DeepSeek 凭据错误。</li>
<li><strong>为什么没提 PR</strong>：这个修复本质是用户本地的 profile 配置（<code>cordis.patch.yml</code> 是用户的补丁层，不属于任何仓库），插件代码本身没有 bug——端点通、模块能加载、注册也无条件。问题只是 dsh-base 钉死了 <code>searchProvider</code>，而插件按约定不能（也不应）在自己的 bundle patch 里覆盖官方的行。所以写成博客记录，算是给插件作者一个”文档该补充一句”的素材。</li>
</ul></section>
<section><h2>小结<a href="#小结"><span>#</span></a></h2><ul>
<li><strong>装插件 ≠ 会生效</strong>：dsh 基础包把 <code>web</code> 行的 <code>searchProvider</code> 钉死为 <code>deepseek-official</code>，无 key 时插件的零配置自动接管不会发生，搜索只会撞上凭据错误。</li>
<li><strong>覆盖是整值替换</strong>：profile 补丁层按 id 覆盖 <code>web</code> 行时，<code>config</code> 整体被替换，必须把 <code>fetchProvider: http</code> 一并写上，只写 <code>searchProvider</code> 会丢抓取配置。</li>
<li><strong>热生效</strong>：<code>patchReload: "live"</code> 下保存即生效，无需重启 dsh。</li>
<li><strong>仓库地址</strong>：
<ul>
<li>搜索插件：<a href="https://github.com/TonyDua/dsh-web-search-exa" target="_blank">TonyDua/dsh-web-search-exa</a>（<code>@tonydua/dsh-web-search-exa@0.1.5</code>）</li>
<li>DeepSeek Harness：<a href="https://github.com/deepseek-ai/deepseek-harness" target="_blank">deepseek-ai/deepseek-harness</a>（<code>@deepseek-ai/dsh@0.1.5-rc.3</code>）</li>
</ul>
</li>
</ul><p>排查这类问题的关键思路：先证明”网络通、模块能加载、端点可用”，把怀疑范围收敛到 provider 选择，再去翻 seam 的源码。祝玩得开心！</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/back-end_testing/</id>
      <title type="text">测试</title>
      <published>2026-10-01T00:00:00.000Z</published>
      <updated>2026-10-01T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/back-end_testing/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>用新的后台发一篇文章测试</p>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/%E8%AF%BE%E8%A1%A8%E6%98%AF%E6%80%8E%E4%B9%88%E5%81%9A%E7%9A%84/</id>
      <title type="text">课表是怎么做的</title>
      <published>2026-09-19T00:00:00.000Z</published>
      <updated>2026-09-19T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/%E8%AF%BE%E8%A1%A8%E6%98%AF%E6%80%8E%E4%B9%88%E5%81%9A%E7%9A%84/"/>
      <summary type="text">记录一下给 Firefly 博客主题添加课表组件的过程</summary>
      <content type="html"><![CDATA[<section><h2>起因<a href="#起因"><span>#</span></a></h2><p>这学期课表比较复杂，周一到周五每天十几节课，还有晚自习，周五下午单双周还轮着上不同科目。手动记容易出错，于是想着能不能把这个课表挂到博客上，随时随地就能查。</p></section>
<section><h2>数据结构<a href="#数据结构"><span>#</span></a></h2><p>课表的核心是 <code>src/data/timetable.ts</code>，两个数组：</p><ul>
<li><strong><code>periods</code></strong>：作息时间，定义每一大节的起止时间和标签</li>
<li><strong><code>courses</code></strong>：课程列表，每条记录包含课程名、星期几、第几大节、上课周次规则、地点、教师</li>
</ul><p>周次规则通过 <code>weeks</code> 字段控制，支持三种模式：</p><ul>
<li><code>{ start: 1, end: 21 }</code> — 全部周次</li>
<li><code>{ start: 1, end: 21, parity: "odd" }</code> — 单周</li>
<li><code>{ start: 1, end: 21, parity: "even" }</code> — 双周</li>
</ul><p>这周五下午就是靠 <code>parity</code> 实现的：单周第一节化学，双周第一节体育，其余课程不变。</p></section>
<section><h2>排课过程<a href="#排课过程"><span>#</span></a></h2><p>按时间表逐天录入，<code>day: 1-5</code> 对应周一到周五，<code>slot</code> 对应 <code>periods</code> 的下标。13 个时段分别是：</p>

<table><thead><tr><th>早读1</th><th>早读2</th><th>上午1-4</th><th>中午1-2</th><th>下午1-4</th><th>晚自习</th></tr></thead><tbody><tr><td>07:00-07&lt;25&gt;</td><td>07:25-07&lt;50&gt;</td><td>08:00-11&lt;41&gt;</td><td>12:00-13&lt;30&gt;</td><td>13:40-17&lt;20&gt;</td><td>17:45-20&lt;30&gt;</td></tr></tbody></table><p>周二到周五的课程顺序和周一不同，逐一核对后填入对应 slot。地点和教师目前留空，后续补上。</p></section>
<section><h2>学期信息<a href="#学期信息"><span>#</span></a></h2><p><code>semester</code> 配置了学期的基本信息，<code>startDate</code> 设成 2026-09-01，<code>totalWeeks</code> 设为 21 周。组件会根据当前日期和开学第一天自动计算现在是第几周，高亮当前周的课程。</p></section>
<section><h2>效果预览<a href="#效果预览"><span>#</span></a></h2><p>课表页面在 <code>/timetable</code> 路由下，桌面端是表格形式展示，手机端切换为按天列表。课程卡片会自动分配颜色，不同科目不同颜色，一眼就能分辨。</p></section>
<section><h2>后续<a href="#后续"><span>#</span></a></h2><ul>
<li>补上教室和教师信息</li>
<li>如果以后有科目调整，直接改 <code>timetable.ts</code> 即可，不需要动代码</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/mobi%E8%BD%ACpdf%E6%8C%87%E5%8D%97/</id>
      <title type="text">Mobi 转 PDF 完整指南</title>
      <published>2026-09-12T00:00:00.000Z</published>
      <updated>2026-09-12T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/mobi%E8%BD%ACpdf%E6%8C%87%E5%8D%97/"/>
      <summary type="text">用 Calibre 把 MOBI 电子书转换成 PDF 的详细教程，解决排版错乱问题</summary>
      <content type="html"><![CDATA[<section><h2>前言<a href="#前言"><span>#</span></a></h2><p>MOBI 是 Kindle 的经典格式，但有时候需要转成 PDF 方便在其他设备上阅读。直接转换往往会出现<strong>图片被挤压、页面大片空白</strong>的问题。本文记录了我从实战中总结出的正确转换方法。</p></section>
<section><h2>问题：直接转 PDF 为什么会乱？<a href="#问题直接转-pdf-为什么会乱"><span>#</span></a></h2><p>用 <code>ebook-convert</code> 直接 MOBI → PDF 时，Calibre 会用 QtWebEngine 渲染 HTML 内容，再输出为 PDF。MOBI 文件里的图片通常是窄长的竖版（比如 900×1280），但默认的 PDF 页面是 Letter 尺寸（612×792），渲染时图片就被挤到页面一侧，另一边全是空白。</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></div></div><div><div><div>3</div></div><div><span>│              │</span></div></div><div><div><div>4</div></div><div><span>│   [图片]     │  ← 图片只占右边，左边大片空白</span></div></div><div><div><div>5</div></div><div><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><h2>解决方案：MOBI → EPUB → PDF<a href="#解决方案mobi--epub--pdf"><span>#</span></a></h2><p>核心思路是分两步走：先转 EPUB 提取原始图片，再用 Python/PIL 从图片生成 PDF，这样可以保持原始宽高比。</p><section><h3>第一步：安装 Calibre<a href="#第一步安装-calibre"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sudo</span><span> </span><span>apt-get</span><span> </span><span>install</span><span> </span><span>-y</span><span> </span><span>calibre</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>如果你遇到依赖问题，可以先修复：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sudo</span><span> </span><span>apt-get</span><span> </span><span>install</span><span> </span><span>-f</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>第二步：MOBI → EPUB<a href="#第二步mobi--epub"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>QTWEBENGINE_DISABLE_SANDBOX</span><span>=</span><span>1</span><span> </span><span>ebook-convert</span><span> </span><span>"</span><span>输入.mobi</span><span>"</span><span> </span><span>"</span><span>/tmp/输出.epub</span><span>"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><blockquote><p><strong>注意</strong>：加上 <code>QTWEBENGINE_DISABLE_SANDBOX=1</code> 可以避免在无头环境中崩溃。</p></blockquote></section><section><h3>第三步：从 EPUB 提取图片生成 PDF<a href="#第三步从-epub-提取图片生成-pdf"><span>#</span></a></h3><p>EPUB 本质是一个 ZIP 包，里面包含所有图片。用 Python 脚本逐页提取：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> zipfile</span><span>,</span><span> os</span></div></div><div><div><div>2</div></div><div><span>from</span><span> </span><span>PIL</span><span> </span><span>import</span><span> Image</span></div></div><div><div><div>3</div></div><div><span>import</span><span> io</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>with</span><span> zipfile</span><span>.</span><span>ZipFile</span><span>(</span><span>"</span><span>输出.epub</span><span>"</span><span>)</span><span> </span><span>as</span><span> z</span><span>:</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>imgs </span><span>=</span><span> </span><span>sorted</span><span>([</span><span>n </span><span>for</span><span> n </span><span>in</span><span> z</span><span>.</span><span>namelist</span><span>()</span><span> </span><span>if</span><span> n</span><span>.</span><span>endswith</span><span>((</span><span>'</span><span>.jpg</span><span>'</span><span>,</span><span>'</span><span>.jpeg</span><span>'</span><span>,</span><span>'</span><span>.png</span><span>'</span><span>))])</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>pil_images </span><span>=</span><span> </span><span>[</span><span>Image</span><span>.</span><span>open</span><span>(</span><span>io</span><span>.</span><span>BytesIO</span><span>(</span><span>z</span><span>.</span><span>read</span><span>(</span><span>n</span><span>))).</span><span>convert</span><span>(</span><span>'</span><span>RGB</span><span>'</span><span>)</span><span> </span><span>for</span><span> n </span><span>in</span><span> imgs</span><span>]</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>pil_images</span><span>[</span><span>0</span><span>].</span><span>save</span><span>(</span><span>"</span><span>输出.pdf</span><span>"</span><span>,</span><span> </span><span>'</span><span>PDF</span><span>'</span><span>,</span><span><span> </span><span>save_all</span></span><span>=</span><span>True</span><span>,</span><span><span> </span><span>append_images</span></span><span>=</span><span>pil_images</span><span>[</span><span>1</span><span>:],</span><span><span> </span><span>resolution</span></span><span>=</span><span>150.0</span><span>)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这样生成的 PDF 每一页都对应一张原始图片，<strong>比例不会变形，不会出现空白边距</strong>。</p></section><section><h3>批量处理脚本<a href="#批量处理脚本"><span>#</span></a></h3><p>如果有多个文件，可以写个循环：</p><div><div><div><figure><figcaption><span></span><span>Terminal window</span><span>展开</span><span>收起</span></figcaption><pre><code><div><div><div>1</div></div><div><span>cd</span><span> </span><span>"</span><span>mobi文件夹</span><span>"</span></div></div><div><div><div>2</div></div><div><span>for</span><span> f </span><span>in</span><span> </span><span>*.mobi</span><span>;</span><span> </span><span>do</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>QTWEBENGINE_DISABLE_SANDBOX</span><span>=</span><span>1</span><span> </span><span>ebook-convert</span><span> </span><span>"</span><span>$f</span><span>"</span><span> </span><span>"</span><span>/tmp/</span><span>$(</span><span>basename</span><span> </span><span>"</span><span>$f</span><span>"</span><span> .mobi</span><span>)</span><span>.epub</span><span>"</span><span> </span><span>2&gt;</span><span>/dev/null</span></div></div><div><div><div>4</div></div><div><span>  </span><span>python3</span><span> </span><span>-c</span><span> </span><span>"</span></div></div><div><div><div>5</div></div><div><span>import zipfile, os</span></div></div><div><div><div>6</div></div><div><span>from PIL import Image</span></div></div><div><div><div>7</div></div><div><span>import io</span></div></div><div><div><div>8</div></div><div><span>epub = '/tmp/</span><span>$(</span><span>basename</span><span> </span><span>"</span><span>$f</span><span>"</span><span> .mobi</span><span>)</span><span>.epub'</span></div></div><div><div><div>9</div></div><div><span>pdf = '</span><span>$f</span><span>'.replace('.mobi', '.pdf')</span></div></div><div><div><div>10</div></div><div><span>with zipfile.ZipFile(epub) as z:</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>imgs = sorted([n for n in z.namelist() if n.endswith(('.jpg','.jpeg','.png'))])</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>pil = [Image.open(io.BytesIO(z.read(n))).convert('RGB') for n in imgs]</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>pil[0].save(pdf, 'PDF', save_all=True, append_images=pil[1:], resolution=150.0)</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>print(f'{pdf}: {len(pil)} pages')</span></div></div><div><div><div>15</div></div><div><span>"</span></div></div><div><div><div>16</div></div><div><span>done</span></div></div><div><div><div>17</div></div><div><span>rm</span><span> </span><span>-f</span><span> </span><span>/tmp/</span><span>*</span><span>.epub</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><p>我用这套方法转换了《金牌得主》的 12 本 MOBI（共约 2300 页），每本 PDF 都完美保持了原图比例，没有变形也没有空白边距。</p>

<table><thead><tr><th>转换方式</th><th>效果</th></tr></thead><tbody><tr><td>ebook-convert MOBI→PDF</td><td>图片挤在一边，大片空白</td></tr><tr><td>MOBI→EPUB→PDF（图片提取）</td><td>原比例呈现，排版正常</td></tr></tbody></table></section>
<section><h2>总结<a href="#总结"><span>#</span></a></h2><ol>
<li><strong>不要直接用 <code>ebook-convert</code> 转 PDF</strong>，渲染引擎会破坏图片比例</li>
<li><strong>走 MOBI → EPUB → 提取图片 → PDF</strong> 的路线</li>
<li>安装 Calibre 时加上 <code>QTWEBENGINE_DISABLE_SANDBOX=1</code> 环境变量</li>
<li>用 Python PIL 库从 EPUB 提取图片生成 PDF，保留原始分辨率</li>
</ol><p>这个方法适用于漫画、杂志等以图片为主的 MOBI 文件。如果是纯文字 MOBI，直接转换也问题不大。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/dsh-toolcall-id/</id>
      <title type="text">dsh-toolcall-id：一个插件修好 dsh 的历史加载失败</title>
      <published>2026-08-21T00:00:00.000Z</published>
      <updated>2026-08-21T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/dsh-toolcall-id/"/>
      <summary type="text">dsh Web GUI 重开多步会话时报 &quot;received more than one start Match&quot;，排源发现是工具调用 id 每步重号撞车。写了个开源插件挂在官方钩子上修掉它，升级 dsh 不会被覆盖</summary>
      <content type="html"><![CDATA[<section><h2>症状<a href="#症状"><span>#</span></a></h2><p>在 dsh 的 Web GUI 里重新打开一个跑过多步工具调用的会话，顶部会冒出一行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>历史加载失败：conversation Context 9:tool-callread:0 received more than one start Match（internal）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>会话本身当时是跑好的，消息也都在，就是重新加载历史的时候整个回放失败。触发条件很明确：<strong>同一个会话里有多个 step 调了工具</strong>，比如 AI 连续 read 了 3 个文件。</p></section>
<section><h2>元凶：工具 id 每一步都重新编号<a href="#元凶工具-id-每一步都重新编号"><span>#</span></a></h2><p>把会话存档（<code>~/.dsh/sessions/</code> 下的 jsonl）解压看事件流，原因很简单：</p><ul>
<li>我用的 NVIDIA（openai-completions 协议）这条链路，工具调用 id 是<strong>每个 step 重新从 <code>read:0</code> 开始编</strong>的。step 3 有个 <code>read:0</code>，step 4 又来一个 <code>read:0</code>，step 5 还是 <code>read:0</code>。</li>
<li>而 dsh 的回放器（<code>dsh-client-ui-conversation</code> 里的 <code>toolDefinition</code>）拿裸 <code>callId</code> 当上下文的 key：</li>
</ul><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span> </span><span>(</span><span>event</span><span>.</span><span>type </span><span>===</span><span> </span><span>"</span><span>tool/call</span><span>"</span><span>)</span></div></div><div><div><div>2</div></div><div><span>  </span><span>return</span><span> </span><span>{</span><span> </span><span>id</span><span>:</span><span> </span><span>String</span><span>(</span><span>event</span><span>.</span><span>data</span><span>.</span><span>callId</span><span>),</span><span> </span><span>role</span><span>:</span><span> </span><span>"</span><span>start</span><span>"</span><span> </span><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>key 里<strong>不带 turn/step 隔离</strong>。回放时第二个 step 的 <code>read:0</code> start 撞上同一个 key，<code>dsh-client-runtime</code> 直接抛 <code>received more than one start Match</code>。</p><p>我下载对比了最新的 <code>0.1.1-rc.1</code>，这段代码一模一样，上游还没修；项目也不收 issue，那就自己解决。</p></section>
<section><h2>为什么做成插件，而不是改源码<a href="#为什么做成插件而不是改源码"><span>#</span></a></h2><p>之前让 AI 改 <code>node_modules</code> 里的文件翻过车（改了半天发现改的是死代码，没被执行，而且一升级就丢）。所以这次的要求是：</p><ol>
<li><strong>不碰 dsh 自身代码</strong>，升级 dsh 后修复要还在；</li>
<li>用官方留的口子，不 hack。</li>
</ol><p>dsh 正好满足：profile 目录（<code>~/.dsh/profiles/web/</code>）支持自己的 bundle 插件，由 pnpm 管理，而且 LLM 流上有一个 <code>llm/stream</code> 瀑布钩子——官方的 <code>session-checkpoint-policy</code> 就是用这个钩子在流前面插队的。插件连 <code>dsh</code> 本体的一个字节都不用改。</p></section>
<section><h2>插件怎么修的<a href="#插件怎么修的"><span>#</span></a></h2><p>思路一句话：<strong>让 id 在流进会话之前就唯一</strong>。挂在 <code>llm/stream</code> 上包住下游流，按会话记录已经用过的工具 id，撞号时重写：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>第 1 个 step:  read:0  →  read:0    （原样放行）</span></div></div><div><div><div>2</div></div><div><span>第 2 个 step:  read:0  →  read:0~1  （发现本会话用过，加后缀）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>改写发生在<strong>流层</strong>，而会话里的所有东西——拼好的 assistant 消息、持久化的 <code>tool/call</code> / <code>tool/result</code> 事件、下一次发给模型的请求——全都是从这条流往下游组装的，所以<strong>一处改写，全链路一致</strong>，不存在改写只改了显示层的半吊子状态。</p><p>核心代码就这么点：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>ctx</span><span>.</span><span>on</span><span>(</span><span>"</span><span>llm/stream</span><span>"</span><span>,</span><span> </span><span>(</span><span>options</span><span>,</span><span><span> </span><span>next</span></span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span><span> </span><span>state</span><span> </span></span><span>=</span><span> </span><span>stateFor</span><span>(</span><span>options</span><span>.</span><span>sessionId</span><span>);</span><span>      </span><span>// 每个 session 一份已用 id 表</span></div></div><div><div><div>3</div></div><div><span>  </span><span>return</span><span> </span><span>wrapStream</span><span>(</span><span>state</span><span>,</span><span> </span><span>next</span><span>());</span><span>               </span><span>// yield* 包一层，撞号就改名</span></div></div><div><div><div>4</div></div><div><span>},</span><span> </span><span>{</span><span> </span><span>global</span><span>:</span><span> </span><span>true</span><span> </span><span>});</span><span>                              </span><span>// global: true 连 subagent 一起覆盖</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><code>tool-call-delta</code> chunk：撞号时浅拷贝一份换掉 id</li>
<li><code>block-end</code> 里的组装好的 block：同样换 id</li>
<li>其他 chunk：原样透传</li>
<li>id 本身唯一的（比如 Anthropic 链路的 <code>toolu_xxx</code>）：零改动，零开销</li>
</ul></section>
<section><h2>安装<a href="#安装"><span>#</span></a></h2><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 1. 装到 web profile</span></div></div><div><div><div>2</div></div><div><span>dsh</span><span> </span><span>plugin</span><span> </span><span>--profile</span><span> </span><span>web</span><span> </span><span>add</span><span> </span><span>github:qwc-ch/dsh-toolcall-id</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span># 2. 把它加进 bundle 列表：编辑 ~/.dsh/profiles/web/package.json，</span></div></div><div><div><div>5</div></div><div><span>#    dsh.profile.bundles 数组末尾加上 "dsh-toolcall-id"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>然后重启 <code>dsh web</code> 就生效了。配置对不对可以随时用 <code>dsh --profile web --dump-config</code> 确认树里有它。</p><p>要卸载的话：<code>dsh plugin --profile web uninstall dsh-toolcall-id</code>，再把 bundle 列表里那行删掉。</p></section>
<section><h2>注意点<a href="#注意点"><span>#</span></a></h2><ul>
<li><strong>只防新坑</strong>：修复对插件安装之后产生的会话生效。已经被写坏的旧会话存档里重号已经落盘，救它需要回头改存档（把旧 jsonl 里的重复 id 一次性改写再重新压缩），不是插件的活。</li>
<li>插件会让重号 id 变成 <code>read:0~1</code> 这种形式，这个 id 同样会进下一次请求的上下文，模型侧不受任何影响（OpenAI 协议本就是靠 id 自匹配的，格式随便）。</li>
<li>如果哪天上游修了（key 带上 turn/step），这个插件可以直接卸掉，行为完全等效。</li>
</ul></section>
<section><h2>仓库<a href="#仓库"><span>#</span></a></h2><p>源码在 GitHub：<a href="https://github.com/qwc-ch/dsh-toolcall-id" target="_blank">qwc-ch/dsh-toolcall-id</a>，MIT 协议，就三个文件（<code>index.js</code> / <code>package.json</code> / <code>cordis.patch.yml</code>），看完就知道没藏东西。</p><p>碰到一样报错的，装完记得重启 dsh。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/zen-me-rang-dsh-wu-xian-zhong-shi/</id>
      <title type="text">怎么让 dsh 无限重试</title>
      <published>2026-08-21T00:00:00.000Z</published>
      <updated>2026-08-21T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/zen-me-rang-dsh-wu-xian-zhong-shi/"/>
      <summary type="text">dsh 默认只会重试 5 次且间隔很快；本文展示通过配置 retryPolicy 为 always，实现指数退避的无限重试。</summary>
      <content type="html"><![CDATA[<section><h2>背景<a href="#背景"><span>#</span></a></h2><p>dsh（DeepSeek Harness）是我常用的 AI 助手 CLI。默认情况下它的 <strong>llm-retry</strong> 插件采用 <code>normal</code> 模式，<code>maxRetries</code> 为 5，<code>baseDelayMs</code> 为 500，导致：</p><ul>
<li>前几次重试仅间隔 0.5 s、1 s、2 s…</li>
<li>第 5 次仍然失败后直接返回错误，整个会话结束。</li>
</ul><p>在模型频繁出现网络抖动或临时限流时，这种“快速五次”往往不够。</p></section>
<section><h2>错误示例<a href="#错误示例"><span>#</span></a></h2><p>（省略实际运行日志，示例截图或文字）</p><blockquote><p>provider “openai” failed, retry 5/5, backoff 2 s → abort</p></blockquote></section>
<section><h2>正确的做法：配置 <code>retryPolicy</code><a href="#正确的做法配置-retrypolicy"><span>#</span></a></h2><section><h3>1. 不要修改 <code>node_modules</code><a href="#1-不要修改-node_modules"><span>#</span></a></h3><p>AI 之前误改了 <code>@earendil-works/pi-ai/dist/utils/retry.js</code>，但该文件 <strong>并未被 dsh 调用</strong>，真正的重试策略在 <strong><code>@deepseek-ai/dsh-llm-retry</code></strong> 中。直接改源码会在升级后被覆盖，且容易产生副作用。</p></section><section><h3>2. 在 <code>~/.dsh/settings.yaml</code> 中为每个 Provider 添加 <code>retryPolicy</code><a href="#2-在-dshsettingsyaml-中为每个-provider-添加-retrypolicy"><span>#</span></a></h3><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>llm-pi-ai</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>providers</span><span>:</span></div></div><div><div><div>3</div></div><div><span>    </span><span>nvm</span><span>:</span></div></div><div><div><div>4</div></div><div><span>      </span><span>displayName</span><span>:</span><span> </span><span>英伟达</span></div></div><div><div><div>5</div></div><div><span>      </span><span>apiKeyEnv</span><span>:</span><span> </span><span>NVM_API_KEY</span></div></div><div><div><div>6</div></div><div><span>      </span><span>api</span><span>:</span><span> </span><span>openai-completions</span></div></div><div><div><div>7</div></div><div><span>      </span><span>baseURL</span><span>:</span><span> </span><span>https://integrate.api.nvidia.com/v1</span></div></div><div><div><div>8</div></div><div><span>      </span><span>retryPolicy</span><span>:</span></div></div><div><div><div>9</div></div><div><span>        </span><span>mode</span><span>:</span><span> </span><span>always</span><span>          </span><span># 无限重试</span></div></div><div><div><div>10</div></div><div><span>        </span><span>backoff</span><span>:</span></div></div><div><div><div>11</div></div><div><span>          </span><span>initialDelayMs</span><span>:</span><span> </span><span>2000</span><span> </span><span># 第一次重试 2 s</span></div></div><div><div><div>12</div></div><div><span>          </span><span>maxDelayMs</span><span>:</span><span> </span><span>30000</span><span>   </span><span># 最大 30 s</span></div></div><div><div><div>13</div></div><div><span>          </span><span>jitterRatio</span><span>:</span><span> </span><span>0.1</span><span>    </span><span># ±10% 抖动避免“同步雨点”</span></div></div><div><div><div>14</div></div><div><span>    </span><span>modelscope1</span><span>:</span></div></div><div><div><div>15</div></div><div><span>      </span><span>displayName</span><span>:</span><span> </span><span>魔搭</span></div></div><div><div><div>16</div></div><div><span>      </span><span>apiKeyEnv</span><span>:</span><span> </span><span>MODELSCOPE1_API_KEY</span></div></div><div><div><div>17</div></div><div><span>      </span><span>api</span><span>:</span><span> </span><span>openai-completions</span></div></div><div><div><div>18</div></div><div><span>      </span><span>baseURL</span><span>:</span><span> </span><span>https://api-inference.modelscope.cn/v1</span></div></div><div><div><div>19</div></div><div><span>      </span><span>retryPolicy</span><span>:</span></div></div><div><div><div>20</div></div><div><span>        </span><span>mode</span><span>:</span><span> </span><span>always</span></div></div><div><div><div>21</div></div><div><span>        </span><span>backoff</span><span>:</span></div></div><div><div><div>22</div></div><div><span>          </span><span>initialDelayMs</span><span>:</span><span> </span><span>2000</span></div></div><div><div><div>23</div></div><div><span>          </span><span>maxDelayMs</span><span>:</span><span> </span><span>30000</span></div></div><div><div><div>24</div></div><div><span>          </span><span>jitterRatio</span><span>:</span><span> </span><span>0.1</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><blockquote><p><strong>mode 说明</strong></p><ul>
<li><code>normal</code>：受 <code>maxRetries</code> 限制，默认 5 次。</li>
<li><code>always</code>：不受次数限制，只受 <code>backoff</code> 最大延迟控制，直至手动中止。</li>
</ul></blockquote><blockquote><p><strong>backoff 计算</strong>
<code>delay = min(initialDelayMs * 2^(retry‑1) * jitter, maxDelayMs)</code>
其中 <code>jitter</code> 为 <code>[1‑ratio, 1+ratio]</code> 的随机因子，防止多个实例同步重试。</p></blockquote></section><section><h3>3. 保存后直接生效<a href="#3-保存后直接生效"><span>#</span></a></h3><p><code>dsh</code> 会在下次请求时读取 <code>settings.yaml</code>，无需重新安装或重启。如果你已经运行了 <code>dsh</code>，只需要重新发起会话即可看到日志：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>$</span><span> </span><span>dsh</span><span> </span><span>conversation</span><span> </span><span>-m</span><span> </span><span>openai/gpt-4</span></div></div><div><div><div>2</div></div><div><span>...</span></div></div><div><div><div>3</div></div><div><span>[</span><span>retry</span><span>]</span><span> attempt 1/∞, wait 2.1s</span></div></div><div><div><div>4</div></div><div><span>[</span><span>retry</span><span>]</span><span> attempt 2/∞, wait 4.3s</span></div></div><div><div><div>5</div></div><div><span>...</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>
<section><h2>验证<a href="#验证"><span>#</span></a></h2><ol>
<li><strong>故意让请求超时</strong>（如关闭网络或使用错误的 <code>baseURL</code>）。</li>
<li>观察终端输出，确认重试次数持续递增，且延迟随 <code>2x</code> 增长直至 30 s。</li>
<li>按 <code>Ctrl+C</code> 中断会话，<code>dsh</code> 会立刻停止后续重试。</li>
</ol></section>
<section><h2>注意事项<a href="#注意事项"><span>#</span></a></h2><ul>
<li><strong>无限重试会消耗配额</strong>。对 <strong>Quota/ Billing</strong> 错误（如 <code>QUOTA</code>、<code>INVALID_CREDENTIAL</code>）<code>always</code> 模式仍会重试，可能导致无限请求并触发封禁。请根据实际需要决定是否使用 <code>always</code>，或在 <code>normal</code> 模式下自行调高 <code>maxRetries</code>。</li>
<li>如果只想延长间隔而不想无限循环，可以保留 <code>mode: normal</code>，只调大 <code>maxRetries</code> 或 <code>maxDelayMs</code>。</li>
<li>配置错误会在启动时抛出 <strong>Schema validation</strong> 错误，记得检查 <code>pnpm dev</code> 或 <code>pnpm build</code> 输出的报错信息。</li>
</ul></section>
<section><h2>小结<a href="#小结"><span>#</span></a></h2><ul>
<li><strong>别改源码</strong>：dsh 的重试逻辑在 <code>dsh-llm-retry</code>，通过 <code>settings.yaml</code> 完全可配置。</li>
<li><strong>用 <code>retryPolicy.mode: always</code></strong> 实现无限重试，配合 <code>backoff</code> 参数得到指数退避。</li>
<li><strong>手动中止</strong>：<code>Ctrl+C</code>（或发送中断信号）随时停止重试，避免不必要的费用。</li>
</ul><p>这样就完成了让 dsh 无限重试的配置，你可以把它当作日常的容错手段，特别是在不稳定网络或临时限流的场景下。祝玩得开心！</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/seo-indexing-fix-journey/</id>
      <title type="text">记一次博客收录优化：从 1 个页面到全部可收录</title>
      <published>2026-08-19T00:00:00.000Z</published>
      <updated>2026-08-19T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/seo-indexing-fix-journey/"/>
      <summary type="text">分享为博客排查 Google/Bing 收录问题的全过程：canonical 缺失、重复页面判定、meta description 雷同，以及 IndexNow 接入时踩过的各种坑。</summary>
      <content type="html"><![CDATA[<section><h2>起因<a href="#起因"><span>#</span></a></h2><p>在 Google Search Console 里看了一眼站点的收录情况，发现数据不太乐观：</p><ul>
<li>已索引页面：<strong>1 个</strong>（只有首页）</li>
<li>未编入索引：<strong>23 个</strong></li>
</ul><p>未索引的原因分三类：</p>

<table><thead><tr><th>原因</th><th>数量</th><th>说明</th></tr></thead><tbody><tr><td>Crawled - currently not indexed</td><td>21</td><td>已抓取但未收录，新站常见</td></tr><tr><td>Duplicate without user-selected canonical</td><td>1</td><td>没有指定规范网页的重复内容</td></tr><tr><td>Not found (404)</td><td>1</td><td><code>/posts/</code> 返回 404</td></tr></tbody></table><p>「Duplicate without user-selected canonical」这条最扎眼——<strong>全站没有任何 <code>&lt;link rel="canonical"&gt;</code> 标签</strong>。没有 canonical，Google 无法区分 <code>https://blog.amamo.top/posts/xxx/</code> 与不带斜杠的版本、分页页、搜索参数变体，于是只收录一个代表页（首页），其余全部被当作重复内容。</p></section>
<section><h2>修复一：补上 canonical<a href="#修复一补上-canonical"><span>#</span></a></h2><p>在布局组件的 <code>&lt;head&gt;</code> 里加一行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>link</span><span> </span><span>rel</span><span>=</span><span>"</span><span>canonical</span><span>"</span><span> </span><span>href</span><span>=</span><span>{Astro.url}</span><span> /&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>配合 Astro 的 <code>trailingSlash: "always"</code>，输出的就是带斜杠的完整 URL，与 sitemap 保持一致。这是一个纯声明式的修复，不影响页面显示，只影响搜索引擎对 URL 的判断。</p><p>修复后无斜杠版本（如 <code>/posts/astbot</code>）虽然在 Vercel 上同样返回 200，但页面内的 canonical 指向带斜杠版本，Google 会自动剔除重复 URL。</p></section>
<section><h2>修复二：robots.txt 屏蔽参数页<a href="#修复二robotstxt-屏蔽参数页"><span>#</span></a></h2><p><code>/archive/?tag=xxx</code> 这类带查询参数的过滤页会被反复抓取，但永远不会被收录，纯浪费抓取配额。在 robots.txt 里加上：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Disallow: /*?tag=</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>修复三：消除雷同的 meta description<a href="#修复三消除雷同的-meta-description"><span>#</span></a></h2><p>Bing 后台提示「太多页面使用相同的 meta description」。原因是我之前用的兜底逻辑：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>description || siteConfig.description || pageTitle</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>所有没写 description 的页面（归档、搜索、标签、分类页）全部退回到同一个站点描述。修复方案：</p><ol>
<li><strong>文章页</strong>：没有手写 description 时，兜底用正文第一段作为摘要（项目里本来就有 remark-excerpt 插件在生成 excerpt，只是没人用）</li>
<li><strong>归档/搜索/标签/分类页</strong>：各自补充独立的 description（走 i18n，六种语言都加了翻译）</li>
</ol></section>
<section><h2>修复四：接入 IndexNow<a href="#修复四接入-indexnow"><span>#</span></a></h2><p>IndexNow 是一个开放协议，Bing、Yandex、Naver、Seznam 都支持：网站根目录放一个 <code>&lt;key&gt;.txt</code> 文件作为”地盘证明”，发布内容后主动向搜索引擎提交 URL，通常几小时到一两天内就会被抓取，不需要等爬虫慢慢来。</p><p>我做了三件事：</p><ol>
<li>生成一个 UUID 作为 key，写入 <code>public/&lt;key&gt;.txt</code>（注意：<strong>文件内容不能有多余的换行符</strong>，Bing 校验非常严格，这是第一个坑）</li>
<li>写了一个构建后脚本 <code>scripts/indexnow.ts</code>：读 sitemap 里的 URL，逐个端点提交</li>
<li>接入 <code>pnpm build</code> 最后一步，以后每次部署自动提交</li>
</ol><section><h3>踩坑记录<a href="#踩坑记录"><span>#</span></a></h3><p><strong>坑一：key 文件不能有换行符。</strong> <code>printf</code> 写入文件时默认带 <code>\n</code>，Bing 验证 key 文件内容时严格比对，多余的换行符会导致验证失败（403）。</p><p><strong>坑二：POST 接口 403，GET 接口正常。</strong> 相同的 key、相同的 URL，<code>POST https://api.indexnow.org/indexnow</code> 稳定返回 403，改用 GET 请求则返回 202。最终脚本改成对多个端点逐一提交：</p><ul>
<li><code>api.indexnow.org</code>（Bing 聚合）</li>
<li><code>www.bing.com</code></li>
<li><code>yandex.com</code></li>
<li><code>searchadvisor.naver.com</code></li>
<li><code>search.seznam.cz</code></li>
</ul><p>实测同一 key：Yandex/Naver/Seznam 全部接受，只有 Bing 拒绝。</p><p><strong>坑三：Bing 会把主机标记为”未授权”并缓存。</strong> Bing 的验证器会异步验证 key 文件，一旦判定主机”未授权”（比如 key 文件还没上线就去提交），这个状态会被缓存，<strong>换新 key 也绕不过去</strong>，只能等缓存过期（最长 24 小时）或等待域名验证方式修正。</p><p><strong>坑四：用”从 Google Search Console 导入”验证的域名，Bing 的 IndexNow 会拒绝。</strong> 我在 Bing Webmaster Tools 里重新添加了站点，改用 XML 文件方式验证，之后 Bing 的 IndexNow 面板就能看到提交记录了（353 条）。</p><p><strong>坑五：搜索引擎不解析 sitemap 索引文件。</strong> 先是在 Bing 上发现提交 <code>sitemap-index.xml</code> 只发现 1 个 URL；后来发现 Google Search Console 也一样——8/1 读过一次索引后 “Sitemaps read” 一直为 0。两个搜索引擎都改成<strong>直接提交 <code>sitemap-0.xml</code></strong> 后，Bing 和 Google 均成功发现 39 个 URL。</p></section></section>
<section><h2>现状与总结<a href="#现状与总结"><span>#</span></a></h2><ul>
<li>Google 侧：canonical、meta description、robots.txt 均已修复；sitemap-0.xml 已成功处理（39 个 URL）。剩下的交给时间（新站收录慢是正常的）</li>
<li>Bing 侧：网站地图 ✅（39 个 URL）、IndexNow ✅（353 条提交记录），Yandex/Naver/Seznam 也已收到提交</li>
<li>后续发新文章，部署时自动提交 IndexNow，无需手动操作</li>
</ul><p>这次排查最大的感悟：<strong>SEO 里 90% 的问题都是”少了一行标签”</strong>。canonical、description、robots.txt 这些基础项做好，剩下的就是内容质量和耐心。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/%E5%BC%80%E6%BA%90%E6%8E%A8%E8%8D%90-astrbot-napcat-%E9%83%A8%E7%BD%B2%E5%88%B0huggingface/</id>
      <title type="text">开源推荐：把 QQ 机器人搬到 Hugging Face 免费跑（AstrBot + NapCat）</title>
      <published>2026-08-19T00:00:00.000Z</published>
      <updated>2026-08-19T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/%E5%BC%80%E6%BA%90%E6%8E%A8%E8%8D%90-astrbot-napcat-%E9%83%A8%E7%BD%B2%E5%88%B0huggingface/"/>
      <summary type="text">开源推荐：把 QQ 机器人搬到 Hugging Face 免费跑（AstrBot + NapCat）</summary>
      <content type="html"><![CDATA[<section><h2>先说痛点<a href="#先说痛点"><span>#</span></a></h2><p>想跑一个 QQ 机器人，通常得面对三座大山：</p><ul>
<li><strong>服务器</strong>：便宜的国内鸡性能拉胯，性能好的一个月小几十块；</li>
<li><strong>备案</strong>：想用国内云搭建需要备案域名，流程漫长；</li>
<li><strong>数据</strong>：配置、会话、QQ 登录态随便丢一个，都得重新折腾半天。</li>
</ul><p>如果你只是想让机器人「活着」，不想伺候服务器 —— 下面这个项目就是为这个场景设计的。</p></section>
<section><h2>项目是干什么的<a href="#项目是干什么的"><span>#</span></a></h2><p>把 <strong>AstrBot</strong>（开源 AI 聊天机器人框架，可视化面板配置 OpenAI / Claude / Gemini / 本地 Ollama 等模型）+ <strong>NapCat</strong>（QQ / OneBot 协议桥接，扫码登录）打包进一个 Docker 镜像，<code>git push</code> 到 Hugging Face Space 就能跑。</p><p>HF 的 <strong>CPU Basic 免费档</strong>就够了，不需要服务器，不需要备案。</p></section>
<section><h2>亮点在哪<a href="#亮点在哪"><span>#</span></a></h2><p><strong>1. 数据全量备份，重启不丢</strong></p><p>这是我觉得最值钱的部分。Space 每次重建容器都会被清空，所以项目内置了一个 sync 守护进程：</p><ul>
<li>启动时从远端恢复数据（配置、会话、QQ 登录态）；</li>
<li>运行期间每 3 分钟自动 <code>commit + push</code> 一次；</li>
<li>大于 60MB 的文件自动走 LFS（git-lfs 协议），不会把仓库撑爆。</li>
</ul><p>备份目标可以选 <strong>GitHub 仓库</strong>或 <strong>Hugging Face 仓库</strong>，<code>GIT_BACKEND</code> 一行切换。想把所有数据都放 HF 也行。</p><p><strong>2. 三个开关，改一行配置就生效</strong></p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># config.env</span></div></div><div><div><div>2</div></div><div><span>ENABLE_NAPCAT</span><span>=</span><span>true</span><span>    </span><span># 是否启动 NapCat</span></div></div><div><div><div>3</div></div><div><span>ENABLE_GIT_PUSH</span><span>=</span><span>true</span><span>  </span><span># 是否推送备份到远端</span></div></div><div><div><div>4</div></div><div><span>ENABLE_HF_PUSH</span><span>=</span><span>true</span><span>   </span><span># 是否上传大文件到 LFS</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>改完推上去自动重新构建，不用改代码。</p><p><strong>3. 单端口网关，路由可视化</strong></p><p>所有服务（AstrBot 面板、NapCat WebUI、文件管理）都挂在同一个 7860 端口下，路由规则在 <code>/admin/ui/</code> 页面里可视化编辑，不用懂 nginx。</p></section>
<section><h2>部署就三步<a href="#部署就三步"><span>#</span></a></h2><ol>
<li>Hugging Face 上建一个 Space（SDK 选 Docker，建议 Private），再建一个空的 model 仓库当备份仓库；</li>
<li>Secrets 里填 <code>HF_REPO</code> 和 <code>HF_TOKEN</code>（写权限 token）；</li>
<li><code>git push</code> 上去，等构建完成，打开 Space 地址。</li>
</ol><p>初始账号是 <code>astrbot</code>，密码在启动日志里打印一次，记得去 <code>/admin/ui/</code> 改掉默认路由密码 <code>admin</code>。</p></section>
<section><h2>项目地址<a href="#项目地址"><span>#</span></a></h2><ul>
<li>GitHub：<a href="https://github.com/qwc-ch/Astrbot-Napcat-huggingface" target="_blank">qwc-ch/Astrbot-Napcat-huggingface</a>（MIT 协议）</li>
</ul><p>我自己的部署就跑在 HF 免费档上，目前体验：构建一次约 10 分钟，日常占用很低，Bot 常驻不掉线。如果你正好在找地方安家你的 AstrBot，可以试试。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/serverless-astbot-huggingface/</id>
      <title type="text">无服务器部署 AstrBot：Hugging Face 免费方案</title>
      <published>2026-08-18T00:00:00.000Z</published>
      <updated>2026-08-18T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/serverless-astbot-huggingface/"/>
      <summary type="text">把 AstrBot + NapCat 整套机器人塞进 Hugging Face 免费 Docker Space，零服务器成本，配合 GitHub 自动备份防止数据丢失，再加一个 Actions 定时请求防休眠</summary>
      <content type="html"><![CDATA[<p>之前写过一篇在 Linux 上装 AstrBot 的，那是跑在自己机器上的路子。但不是每个人都有台长期开机的服务器，为一个小机器人掏 VPS 的钱也有点舍不得。后来试了试把整套东西塞进 Hugging Face 的免费 Docker Space，跑通之后感觉还挺合适，顺手记录一下。</p>
<p>用的还是社区现成的方案：<a href="https://github.com/MoYangking/Astrbot-Napcat-huggingface" target="_blank">Astrbot-Napcat-huggingface</a>，把 AstrBot（聊天机器人框架）和 NapCat（QQ 桥接）打包在一个 OpenResty 网关下面，supervisord 管进程，适配 HF 的 Docker SDK。我也是第一次搞 HF Space，中间踩了几个坑，都写在最后了。</p>
<p>动手之前有两个事得先想清楚，不然后面容易后悔：</p>
<p>一是免费版的 Space 四十八小时没人访问就会休眠，休眠的时候机器人是离线的。后面有办法用 GitHub Actions 定时”续命”，但你要接受偶尔冷启动慢几秒到几分钟。</p>
<p>二是容器本身的磁盘是临时的，一重启就什么都没了。所以数据备份不能靠容器，得靠项目自带的 Sync 守护进程，把数据定时推到你的 GitHub 仓库。这个配好了才谈得上不丢数据。</p>
<section><h2>第一步，建个 Space<a href="#第一步建个-space"><span>#</span></a></h2><p>打开 <a href="https://huggingface.co/new-space%EF%BC%8C%E5%90%8D%E5%AD%97%E9%9A%8F%E4%BE%BF%E8%B5%B7%EF%BC%8CSDK" target="_blank">https://huggingface.co/new-space，名字随便起，SDK</a> 记得选 Docker，不想被别人看到就勾 Private。建好之后可以去 Settings 里看一眼，免费的 CPU 档有个自动休眠的选项。</p></section>
<section><h2>第二步，把代码推上去<a href="#第二步把代码推上去"><span>#</span></a></h2><p>如果你本地已经有这个仓库，不用 clone 过去再拷文件，直接加一个远端就能推：</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/MoYangking/Astrbot-Napcat-huggingface.git</span></div></div><div><div><div>2</div></div><div><span>cd</span><span> </span><span>Astrbot-Napcat-huggingface</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>git</span><span> </span><span>remote</span><span> </span><span>add</span><span> </span><span>hf</span><span> </span><span>https://&lt;你的HF用户名&gt;:&lt;HF_TOKEN&gt;@huggingface.co/spaces/&lt;用户名&gt;/&lt;Space名&gt;</span></div></div><div><div><div>5</div></div><div><span>git</span><span> </span><span>push</span><span> </span><span>--force</span><span> </span><span>hf</span><span> </span><span>main</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Token 在 <a href="https://huggingface.co/settings/tokens" target="_blank">https://huggingface.co/settings/tokens</a> 生成，权限选 Write 就行。</p><p>推完之后去 Space 页面点 Build，第一次构建大概二十分钟到四十分钟，别急着刷新，等它自己跑完。</p></section>
<section><h2>第三步，配置备份<a href="#第三步配置备份"><span>#</span></a></h2><p>在 Space 的 Settings → Variables and secrets 里加两个变量：</p>

<table><thead><tr><th>变量</th><th>值</th><th>干什么用的</th></tr></thead><tbody><tr><td><code>GITHUB_REPO</code></td><td><code>你的GitHub用户名/备份仓库名</code></td><td>数据推到哪</td></tr><tr><td><code>GITHUB_PAT</code></td><td><code>ghp_...</code></td><td>推数据用的凭据</td></tr></tbody></table><p>前提是你得先在 GitHub 建一个空的 Private 仓库，比如叫 <code>astrbot-backup</code>，PAT 在 <a href="https://github.com/settings/tokens" target="_blank">https://github.com/settings/tokens</a> 生成，勾上 repo 权限。</p><p>填完之后 Space 会自动重启。Sync 守护进程会把 <code>AstrBot/data/</code> 每三分钟往备份仓库推一次，先 pull 再 commit 再 push，两边不会打架。这一对变量不配也能跑，但容器一重启数据就没了，到时候懂的都懂。</p></section>
<section><h2>第四步，第一次登录<a href="#第四步第一次登录"><span>#</span></a></h2><p>访问 <code>https://&lt;space名&gt;.hf.space/</code>，有几个入口：</p><ul>
<li><code>/</code> 是 AstrBot 控制台。新版首次登录账号固定是 <code>astrbot</code>，密码是随机生成打印在日志里的，去运行日志里搜 <code>Initial password</code> 就能看到。</li>
<li><code>/admin/ui/</code> 是路由管理，默认密码 <code>admin</code>，建议进去改掉。</li>
<li><code>/webui/</code> 是 NapCat 的界面，个人 QQ 号扫码登录用的，只用腾讯官方机器人可以跳过。</li>
</ul></section>
<section><h2>第五步，防休眠<a href="#第五步防休眠"><span>#</span></a></h2><p>免费档四十八小时没访问就休眠，所以要定时去”戳”它一下。在仓库里加个 workflow，GitHub Actions 会替你每天戳三次：</p><div><figure><figcaption><span>.github/workflows/keep-alive.yml</span></figcaption><pre><code><div><div><div>1</div></div><div><span>name</span><span>:</span><span> </span><span>Keep HF Space Alive</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> </span><span>cron</span><span>:</span><span> </span><span>"</span><span>0 */8 * * *</span><span>"</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><span>jobs</span><span>:</span></div></div><div><div><div>7</div></div><div><span>  </span><span>ping</span><span>:</span></div></div><div><div><div>8</div></div><div><span>    </span><span>runs-on</span><span>:</span><span> </span><span>ubuntu-latest</span></div></div><div><div><div>9</div></div><div><span>    </span><span>steps</span><span>:</span></div></div><div><div><div>10</div></div><div><span><span>      </span></span><span>-</span><span> </span><span>run</span><span>:</span><span> </span><span>curl -sS -o /dev/null -H "Authorization</span><span>:</span><span> </span><span>Bearer ${{ secrets.HF_TOKEN }}" https://&lt;你的space名&gt;.hf.space/</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>然后在仓库的 Settings → Secrets → Actions 里加一个 <code>HF_TOKEN</code> 就行。一天三次请求，Actions 的免费额度完全够用。</p></section>
<section><h2>一些要提前知道的局限<a href="#一些要提前知道的局限"><span>#</span></a></h2>

<table><thead><tr><th>方面</th><th>实际情况</th></tr></thead><tbody><tr><td>数据</td><td>有 GitHub 兜底，但两次备份之间的三分钟如果正好被硬杀，会丢一点东西</td></tr><tr><td>在线率</td><td>免费版休眠后唤醒要一两分钟；想真正 7×24 在线得花钱，大概七美元多一个月</td></tr><tr><td>资源</td><td>CPU Basic 是 2 vCPU / 16GB 单副本，别指望它扛并发</td></tr><tr><td>构建</td><td>改一次 Dockerfile 就要重新构建，二十分钟起步</td></tr></tbody></table></section>
<section><h2>踩过的坑<a href="#踩过的坑"><span>#</span></a></h2><p>第一个是 <code>FROM ubuntu:latest</code>，六月份之后 latest 已经指向 26.04 了，OpenResty 的 apt 源还没跟上，构建直接 404 失败。改成 <code>FROM ubuntu:24.04</code> 就好了。</p><p>第二个是浏览器打开 404。Space 第一次启动会下载两百来兆的 AstrBot 前端资源，下载没完成之前 <code>/</code> 就是 404，等下载到 100% 刷新一下就好，不是坏了。</p><p>第三个是没配备份那一对变量的话，AstrBot 和 NapCat 会卡三十分钟起不来——Sync 守护进程拿不到配置会崩，标记文件永远不写，其他服务就干等超时。我给守护进程加了”没配置就直接放行”的处理，已经合到仓库里了。</p><p>总的来说，无服务器方案的核心矛盾就是拿在线率换零成本。这套路子数据有 GitHub 兜底，唯一的妥协是休眠后的冷启动，对个人机器人来说这个妥协还算能接受。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/headless-chrome-webgl-black-block/</id>
      <title type="text">headless Chrome 截图 WebGL 黑块问题排查</title>
      <published>2026-08-14T00:00:00.000Z</published>
      <updated>2026-08-14T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/headless-chrome-webgl-black-block/"/>
      <summary type="text">友链截图工具在 headless Chrome 下遇到 Spline/Live2D 站点渲染失败、出现带报错信息的黑块，通过启用 swiftshader 软件渲染解决。</summary>
      <content type="html"><![CDATA[<section><h2>起因<a href="#起因"><span>#</span></a></h2><p>维护友链检测工具 <a href="https://github.com/fqzlr/check-flink" target="_blank">check-flink</a>（Selenium + headless Chrome 截取友链主页并上传图床）时发现：部分友链站点使用了 <a href="https://spline.design/" target="_blank">Spline</a> 或 Live2D 这类 WebGL 内容，截出来的图对应区域是一块纯黑色，黑块里还带着 WebGL 初始化失败的报错信息，非常难看。</p></section>
<section><h2>原因分析<a href="#原因分析"><span>#</span></a></h2><p>headless Chrome 在无 GPU 环境下默认禁用 WebGL 渲染。站点通过 WebGL 绘制的 3D/动画内容无法初始化，页面就只剩下一块黑色区域（有时带报错提示）。之前截图工具的 Chrome 启动参数里是 <code>--disable-gpu</code>，等于直接关掉了 GPU 相关的所有能力。</p></section>
<section><h2>解决方案<a href="#解决方案"><span>#</span></a></h2><p>Chrome 支持通过 <strong>SwiftShader</strong> 做 WebGL 的软件渲染——没有 GPU 也能用 CPU 模拟渲染出 WebGL 内容。改动 <code>screenshot.py</code> 里的 Chrome 启动参数：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>options </span><span>=</span><span> Options</span><span>()</span></div></div><div><div><div>2</div></div><div><span>options</span><span>.</span><span>add_argument</span><span>(</span><span>"</span><span>--headless=new</span><span>"</span><span>)</span></div></div><div><div><div>3</div></div><div><span>options</span><span>.</span><span>add_argument</span><span>(</span><span>"</span><span>--no-sandbox</span><span>"</span><span>)</span></div></div><div><div><div>4</div></div><div><span>options</span><span>.</span><span>add_argument</span><span>(</span><span>"</span><span>--disable-dev-shm-usage</span><span>"</span><span>)</span></div></div><div><div><div>5</div></div><div><span># WebGL 软件渲染（Spline/Live2D 等 WebGL 内容在 headless 下默认不渲染，产生黑块）</span></div></div><div><div><div>6</div></div><div><span># 新版 Chrome 需要 --enable-unsafe-swiftshader 才允许软件 WebGL</span></div></div><div><div><div>7</div></div><div><span>options</span><span>.</span><span>add_argument</span><span>(</span><span>"</span><span>--use-gl=angle</span><span>"</span><span>)</span></div></div><div><div><div>8</div></div><div><span>options</span><span>.</span><span>add_argument</span><span>(</span><span>"</span><span>--use-angle=swiftshader</span><span>"</span><span>)</span></div></div><div><div><div>9</div></div><div><span>options</span><span>.</span><span>add_argument</span><span>(</span><span>"</span><span>--enable-unsafe-swiftshader</span><span>"</span><span>)</span></div></div><div><div><div>10</div></div><div><span>options</span><span>.</span><span>add_argument</span><span>(</span><span>"</span><span>--ignore-gpu-blocklist</span><span>"</span><span>)</span></div></div><div><div><div>11</div></div><div><span>options</span><span>.</span><span>add_argument</span><span>(</span><span>"</span><span>--disable-extensions</span><span>"</span><span>)</span></div></div><div><div><div>12</div></div><div><span>options</span><span>.</span><span>add_argument</span><span>(</span><span>"</span><span>--disable-software-rasterizer</span><span>"</span><span>)</span></div></div><div><div><div>13</div></div><div><span>options</span><span>.</span><span>add_argument</span><span>(</span><span>f</span><span>"--window-size=</span><span><span>{</span><span>WINDOW_WIDTH</span><span>}</span></span><span>,</span><span><span>{</span><span>WINDOW_HEIGHT</span><span>}</span></span><span>"</span><span>)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>关键点：</p><ul>
<li>移除 <code>--disable-gpu</code>，它会把 GPU 相关能力整个关掉</li>
<li><code>--use-gl=angle</code> + <code>--use-angle=swiftshader</code>：ANGLE 层之上跑 SwiftShader 软件实现</li>
<li><code>--enable-unsafe-swiftshader</code>：<strong>新版 Chrome 的必需参数</strong>，没有它软件 WebGL 依然不允许启用</li>
<li><code>--ignore-gpu-blocklist</code>：忽略对软件渲染器的黑名单限制</li>
</ul></section>
<section><h2>验证<a href="#验证"><span>#</span></a></h2><p>改完后对含 WebGL 内容的友链站点重新截图，Spline / Live2D 内容可以正常渲染，黑块消失。磁盘没有独立 GPU 的 CI 环境（GitHub Actions / 国内流水线）同样适用。</p></section>
<section><h2>备注<a href="#备注"><span>#</span></a></h2><ul>
<li>该修复已通过 PR 提交到上游项目：<a href="https://github.com/fqzlr/check-flink" target="_blank">check-flink</a></li>
<li>此方案是”尽力”渲染：个别站点可能仍有兼容性问题，必要时可再叠加截图后黑区检测兜底</li>
<li>如果遇到 WebGL 内容加载慢，可适当调大截图前的等待时间（<code>PAGE_LOAD_WAIT</code>）</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/vercel%E4%BC%98%E5%8C%96%E5%86%B7%E7%9F%A5%E8%AF%86/</id>
      <title type="text">vercel优化冷知识</title>
      <published>2026-08-14T00:00:00.000Z</published>
      <updated>2026-08-14T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/vercel%E4%BC%98%E5%8C%96%E5%86%B7%E7%9F%A5%E8%AF%86/"/>
      <summary type="text">vercel优化冷知识</summary>
      <content type="html"><![CDATA[<section><h2>引言<a href="#引言"><span>#</span></a></h2><p>本文基于博主 <strong>Upxuu</strong> <a href="https://upxuu.com/posts/vercel-youxuan-cache/" target="_blank">什么？你的vercel还没深绿？一文研究透vercel优选|缓存配置</a>这期文章写成。</p></section>
<section><h2>ip优选<a href="#ip优选"><span>#</span></a></h2><p>Vercel官方支持ip优选，并维维护了一条中国优化的 CNAME</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>cname-china.vercel-dns.com</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>只需替换为此cname，既可完成优选。</p></section>
<section><h2>地区优选<a href="#地区优选"><span>#</span></a></h2><p>Vercel默认的函数的运行区域是美国东部，更改很简单只需 <strong>进入已部署的项目 → Settings → Functions → Functions Region</strong> 然后取消勾选美国东部，勾选香港，日本，新加坡等亚太地区，这里有一个坑免费计划只能选一个地区。</p></section>
<section><h2>CDN缓存配置<a href="#cdn缓存配置"><span>#</span></a></h2><p>Vercel 默认情况下，你的主 HTML 根本命中不了缓存。vercel.json中可以配置。</p><section><h3>我的vercel.json<a href="#我的verceljson"><span>#</span></a></h3><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>"</span><span>buildCommand</span><span>"</span><span>:</span><span> </span><span>"</span><span>pnpm run build</span><span>"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"</span><span>outputDirectory</span><span>"</span><span>:</span><span> </span><span>"</span><span>dist</span><span>"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"</span><span>installCommand</span><span>"</span><span>:</span><span> </span><span>"</span><span>pnpm install</span><span>"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>  </span><span>"</span><span>framework</span><span>"</span><span>:</span><span> </span><span>"</span><span>astro</span><span>"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>  </span><span>"</span><span>headers</span><span>"</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>"</span><span>source</span><span>"</span><span>:</span><span> </span><span>"</span><span>/(.*)</span><span>"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>      </span><span>"</span><span>headers</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>key</span><span>"</span><span>:</span><span> </span><span>"</span><span>X-Content-Type-Options</span><span>"</span><span>,</span></div></div><div><div><div>12</div></div><div><span>          </span><span>"</span><span>value</span><span>"</span><span>:</span><span> </span><span>"</span><span>nosniff</span><span>"</span></div></div><div><div><div>13</div></div><div><span><span>        </span></span><span>},</span></div></div><div><div><div>14</div></div><div><span><span>        </span></span><span>{</span></div></div><div><div><div>15</div></div><div><span>          </span><span>"</span><span>key</span><span>"</span><span>:</span><span> </span><span>"</span><span>X-Frame-Options</span><span>"</span><span>,</span></div></div><div><div><div>16</div></div><div><span>          </span><span>"</span><span>value</span><span>"</span><span>:</span><span> </span><span>"</span><span>SAMEORIGIN</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></span><span>{</span></div></div><div><div><div>19</div></div><div><span>          </span><span>"</span><span>key</span><span>"</span><span>:</span><span> </span><span>"</span><span>X-XSS-Protection</span><span>"</span><span>,</span></div></div><div><div><div>20</div></div><div><span>          </span><span>"</span><span>value</span><span>"</span><span>:</span><span> </span><span>"</span><span>1; mode=block</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></span><span>{</span></div></div><div><div><div>23</div></div><div><span>          </span><span>"</span><span>key</span><span>"</span><span>:</span><span> </span><span>"</span><span>Referrer-Policy</span><span>"</span><span>,</span></div></div><div><div><div>24</div></div><div><span>          </span><span>"</span><span>value</span><span>"</span><span>:</span><span> </span><span>"</span><span>strict-origin-when-cross-origin</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></span><span>{</span></div></div><div><div><div>27</div></div><div><span>          </span><span>"</span><span>key</span><span>"</span><span>:</span><span> </span><span>"</span><span>Cache-Control</span><span>"</span><span>,</span></div></div><div><div><div>28</div></div><div><span>          </span><span>"</span><span>value</span><span>"</span><span>:</span><span> </span><span>"</span><span>public, max-age=0, s-maxage=31536000, must-revalidate</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></span><span>]</span></div></div><div><div><div>31</div></div><div><span><span>    </span></span><span>},</span></div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>33</div></div><div><span>      </span><span>"</span><span>source</span><span>"</span><span>:</span><span> </span><span>"</span><span>/_astro/(.*)</span><span>"</span><span>,</span></div></div><div><div><div>34</div></div><div><span>      </span><span>"</span><span>headers</span><span>"</span><span>:</span><span> </span><span>[</span></div></div><div><div><div>35</div></div><div><span><span>        </span></span><span>{</span></div></div><div><div><div>36</div></div><div><span>          </span><span>"</span><span>key</span><span>"</span><span>:</span><span> </span><span>"</span><span>Cache-Control</span><span>"</span><span>,</span></div></div><div><div><div>37</div></div><div><span>          </span><span>"</span><span>value</span><span>"</span><span>:</span><span> </span><span>"</span><span>public, max-age=31536000, immutable</span><span>"</span></div></div><div><div><div>38</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>39</div></div><div><span><span>      </span></span><span>]</span></div></div><div><div><div>40</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>41</div></div><div><span><span>  </span></span><span>]</span></div></div><div><div><div>42</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>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/vercel-deploy-404/</id>
      <title type="text">Vercel 部署后全站 404 排查记</title>
      <published>2026-08-07T00:00:00.000Z</published>
      <updated>2026-08-07T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/vercel-deploy-404/"/>
      <summary type="text">博客部署到 Vercel 后所有 HTML 页面都返回 404，静态资源却正常。本文记录了从现象、链路分析到定位根因（Vercel CLI cleanUrls 冲突与 l2s 上传 bug）的全过程，以及最终通过固定 CLI 版本解决的方案。</summary>
      <content type="html"><![CDATA[<section><h2>引言<a href="#引言"><span>#</span></a></h2><p>本博客同时部署在 Vercel、Cloudflare Workers、EdgeOne Pages 等多个平台。某天发现：</p><ul>
<li>本地开发、Cloudflare、EdgeOne 访问全部正常；</li>
<li>唯独 <strong>Vercel 上的域名（blog.amamo.top）所有 HTML 页面都返回 404</strong>，而非 HTML 资源（<code>_astro/*</code>、图片、pagefind、rss.xml 等）全部正常。</li>
</ul><p>CI 构建日志显示构建、部署、Alias 全部成功。这是一个”部署成功但内容缺失”的诡异问题，折腾了很久，记录下完整的排查思路和根因。</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>/</code>、<code>/about/</code>、<code>/posts/xxx/</code></td><td>404（响应体是站点自定义 404 页）</td></tr><tr><td><code>/_astro/*.css</code>、<code>*.js</code>、图片</td><td>200</td></tr><tr><td><code>robots.txt</code>、<code>rss.xml</code>、<code>pagefind/*</code></td><td>200</td></tr><tr><td><code>/index.html</code>、<code>/about.html</code></td><td>308 重定向到去扩展名路径</td></tr></tbody></table><p>关键信息：</p><ol>
<li>非 HTML 静态资源 200，说明部署确实上传了文件；</li>
<li>所有 HTML 页面 404，说明 HTML 部分出了问题；</li>
<li><code>*.html</code> 请求被 308 重定向，说明存在 <strong>cleanUrls 风格的路由规则</strong>。</li>
</ol></section>
<section><h2>排查过程<a href="#排查过程"><span>#</span></a></h2><section><h3>第一步：确认是部署产物问题而非缓存<a href="#第一步确认是部署产物问题而非缓存"><span>#</span></a></h3><p>本地 <code>pnpm dev</code> 正常，<code>dist/</code> 里 37 个 <code>index.html</code> 都在。为了排除中间层 CDN 缓存，带随机 query 重新请求，仍然是 404。查看响应头：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>server: Vercel</span></div></div><div><div><div>2</div></div><div><span>x-vercel-cache: HIT</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>404 是<strong>源站（Vercel 边缘）自己返回的</strong>，不是 CDN 缓存了旧响应。</p></section><section><h3>第二步：对比「部署文件列表」与「本地产物」<a href="#第二步对比部署文件列表与本地产物"><span>#</span></a></h3><p>通过 Vercel 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>-H</span><span> </span><span>"</span><span>Authorization: Bearer </span><span>$TOKEN</span><span>"</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"</span><span>https://api.vercel.com/v13/deployments/&lt;id&gt;/files</span><span>"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>结果出乎意料：<strong>37 个 HTML 文件全部在部署里</strong>，路径也都正确（<code>posts/astbot/index.html</code>、<code>about/index.html</code> 等）。文件在、路径对，线上却 404 —— 说明<strong>文件内容在服务端无效</strong>。</p></section><section><h3>第三步：找到 cleanUrls 冲突<a href="#第三步找到-cleanurls-冲突"><span>#</span></a></h3><p>本地执行 <code>vercel build --prod</code> 后检查生成的 <code>.vercel/output/config.json</code>，发现 CLI 生成了大量 <code>overrides</code> 和 308 重定向规则：</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>overrides</span><span>"</span><span>:</span><span> </span><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>"</span><span>index.html</span><span>"</span><span>:</span><span> </span><span>{</span><span> </span><span>"</span><span>path</span><span>"</span><span>:</span><span> </span><span>"</span><span>index</span><span>"</span><span> </span><span>},</span></div></div><div><div><div>4</div></div><div><span>    </span><span>"</span><span>about/index.html</span><span>"</span><span>:</span><span> </span><span>{</span><span> </span><span>"</span><span>path</span><span>"</span><span>:</span><span> </span><span>"</span><span>about/index</span><span>"</span><span> </span><span>}</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>6</div></div><div><span>  </span><span>"</span><span>routes</span><span>"</span><span>:</span><span> </span><span>[</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>{</span><span> </span><span>"</span><span>src</span><span>"</span><span>:</span><span> </span><span>"</span><span>^/(?:(.+)/)?index(?:</span><span>\\</span><span>.html)?/?$</span><span>"</span><span>,</span><span> </span><span>"</span><span>headers</span><span>"</span><span>:</span><span> </span><span>{</span><span> </span><span>"</span><span>Location</span><span>"</span><span>:</span><span> </span><span>"</span><span>/$1</span><span>"</span><span> </span><span>},</span><span> </span><span>"</span><span>status</span><span>"</span><span>:</span><span> </span><span>308</span><span> </span><span>}</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>]</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>这就是问题一</strong>：<code>vercel.json</code> 里的 <code>"cleanUrls": true</code> 会让 CLI 把所有 HTML 文件”改名为无扩展名”（<code>about/index.html</code> → <code>about/index</code>）。于是：</p><ul>
<li>请求 <code>/about/</code> 走目录解析，找 <code>about/index.html</code> —— 不存在（已被改名）；</li>
<li>请求 <code>/about/index</code> —— 被 308 重定向规则切到 <code>/about</code>；</li>
<li><code>/about</code> 又找不到文件 —— 404。</li>
</ul><p>而 <code>/404</code> 之所以能 200，是因为它不在重定向规则里、能直接命中改名后的 <code>404</code> 文件。</p><p>修复后，404 页面本身回归正确 404，<code>/</code>、<code>/about/</code> 等一级页面全部 200。<strong>但 <code>/posts/</code>、<code>/gallery/</code>、<code>/dynamic/</code> 依旧 404</strong> —— 还有第二个问题。</p></section><section><h3>第四步：定位真正的元凶 —— l2s 上传 bug<a href="#第四步定位真正的元凶--l2s-上传-bug"><span>#</span></a></h3><p>继续对比，发现一个奇怪规律：<strong>有一级子目录的「父目录」全部 404，纯叶子目录全部 200</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/about 200    /archive 200    /search 200</span></div></div><div><div><div>2</div></div><div><span>/posts 404    /gallery 404    /dynamic 404     （这三个目录里还有子目录）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>此时检查本地 <code>.vercel/output/static/</code> 里的文件类型，真相大白：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>output/static/about/index.html      # 普通文件 529060 字节（正常）</span></div></div><div><div><div>2</div></div><div><span>output/static/posts/astbot/index.html -&gt; /home/qwc/Firefly/dist/posts/astbot/.l2s..l2s.index.html0001.00010001   （符号链接！）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>Vercel CLI 58.7.1 的 l2s 上传优化在处理大文件时，把 <code>index.html</code> 替换成了指向失效路径的符号链接（symlink），真实数据被写进了 <code>.l2s.index.html0001.0002</code> 分片文件</strong>。上传时 Vercel 把这些符号链接原样记录（目标路径在服务器上不存在），于是:</p><ul>
<li>HTML 在文件列表里”存在”，但服务端读取时解析不到 → 404；</li>
<li><code>about/index.html</code> 是硬链接（保留真实文件）→ 正常；</li>
<li><code>wrangler</code>（Cloudflare）上传时会跟随符号链接读到真实内容 → CF 完全正常。</li>
</ul><p>这也解释了为什么 CI 部署时上传只有 19.8MB（大量符号链接被跳过/去重），而实际产物有 52MB。</p></section></section>
<section><h2>为什么新版本 CLI 有问题，旧版本没问题<a href="#为什么新版本-cli-有问题旧版本没问题"><span>#</span></a></h2><p>这次问题的核心在 Vercel CLI 58.x 引入的 <strong>l2s（large-file storage）上传优化</strong>。它的本意是把大文件切分成约 1MB 的小块（<code>.l2s.&lt;文件名&gt;0001</code>、<code>0002</code>…），以便内容寻址去重、并发上传、断点续传，服务器端再重组。<strong>57 和 58 都用了分片，但实现方式截然不同</strong>：</p>

<table><thead><tr><th></th><th>分片后的原文件</th><th>上传结果</th></tr></thead><tbody><tr><td><strong>vercel@57</strong></td><td>保留为<strong>真实文件</strong>（与分片文件互为硬链接，link count = 2），<code>readFile</code> 随时能读到完整内容</td><td>正常</td></tr><tr><td><strong>vercel@58.7.1</strong></td><td>被替换成<strong>符号链接</strong>，指向本地绝对路径（<code>/home/qwc/.../.l2s...</code>），且出现嵌套分片（<code>.l2s..l2s.</code> 双层前缀）</td><td>服务端解析不到 → 404</td></tr></tbody></table><p>58.7.1 的具体问题有两个：</p><ol>
<li><strong>symlink 目标写死成本机绝对路径</strong>：<code>posts/astbot/index.html -&gt; /home/qwc/Firefly/dist/posts/astbot/.l2s..l2s.index.html0001.00010001</code>。这个路径只在本机存在，上传到 Vercel 后无法解析，文件”在列表里、读不出内容”。</li>
<li><strong>对已分片的文件再次分片</strong>：正常的 l2s 分片命名是 <code>.l2s.index.html0001.0001</code>，而 58.7.1 在部分文件上生成了<strong>嵌套前缀</strong> <code>.l2s..l2s.index.html0001.00010001</code>，说明分片逻辑对”分片产物”又跑了一遍，symlink 指向了错误的目标。</li>
</ol><p>而 57 的处理方式是<strong>硬链接</strong>：<code>index.html</code> 和 <code>.l2s.index.html0001.0002</code> 是同一个 inode 的两个目录项，<code>index.html</code> 始终是真实文件、内容完整。上传时 CLI 读到的就是完整数据，服务器端重组无需依赖符号链接。</p><p><strong>为什么”以前正常、某一天突然全站 404”</strong>：CI 里用的是 <code>npx vercel</code>（不带版本号），npx 每次拉取的都是<strong>最新版</strong>。以前最新版还是 57.x（实现正确），某次升级到 58.7.1（实现有 bug）后，CI 的每一次部署都带着坏掉的符号链接上传——所以在 GitHub Actions 部署日志全部成功的情况下，线上依旧全站 404。</p><section><h3>为什么 Cloudflare / EdgeOne 不受影响<a href="#为什么-cloudflare--edgeone-不受影响"><span>#</span></a></h3><p>同一份 dist，三家平台的部署工具走的是完全不同的上传模型：</p>

<table><thead><tr><th>部署工具</th><th>上传模型</th><th>遇到 symlink</th><th>结果</th></tr></thead><tbody><tr><td><code>wrangler</code>（Cloudflare）</td><td>逐文件 <code>readFile</code> 读取内容上传</td><td>自动跟随链接，读到真实字节</td><td>正常</td></tr><tr><td><code>edgeone pages deploy</code></td><td>逐文件读取内容上传</td><td>自动跟随链接</td><td>正常</td></tr><tr><td><code>vercel deploy</code>（CLI 58）</td><td>本地预处理 + 内容寻址分片上传</td><td>把 symlink 当路径记录，不上传内容</td><td>404</td></tr></tbody></table><p>wrangler / edgeone 是朴素的”文件内容上传”模型，没有本地预处理这一层，符号链接在读文件时就被解析掉了，自然不受影响。Vercel CLI 因为多了 l2s 预处理层，才把 bug 带进了部署产物。<strong>多平台部署（CF / EdgeOne / Vercel）在这种场景下相当于交叉验证：同一份 dist，别人正常、只有 Vercel 异常，问题几乎可以锁定在 Vercel 的部署链路上。</strong></p></section></section>
<section><h2>根因总结<a href="#根因总结"><span>#</span></a></h2><p>两个问题叠加：</p><ol>
<li><strong><code>cleanUrls: true</code> 与 Astro 的 <code>trailingSlash: "always"</code> 冲突</strong> —— HTML 被改名导致带尾斜杠的路由全部 404；</li>
<li><strong>Vercel CLI 58.x 的 l2s 上传 bug</strong> —— 更大的问题，大文件被替换成失效符号链接，导致部分目录的 HTML 无法读取。</li>
</ol><p>两个问题都只在 Vercel 这边出现，Cloudflare / EdgeOne 的部署链路不受影响。</p></section>
<section><h2>解决方案<a href="#解决方案"><span>#</span></a></h2><section><h3>直接修复<a href="#直接修复"><span>#</span></a></h3><ol>
<li>
<p><strong>从 <code>vercel.json</code> 删除 <code>cleanUrls: true</code></strong>，HTML 文件保持原名，目录路由恢复正常；</p>
</li>
<li>
<p><strong>固定 Vercel CLI 版本到 57</strong>：</p>
<div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>npx</span><span> </span><span>-y</span><span> </span><span>vercel@57</span><span> </span><span>build</span><span> </span><span>--prod</span></div></div><div><div><div>2</div></div><div><span>npx</span><span> </span><span>-y</span><span> </span><span>vercel@57</span><span> </span><span>deploy</span><span> </span><span>--prebuilt</span><span> </span><span>--prod</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p><code>vercel@57</code> 同样会生成 l2s 分片，但采用<strong>硬链接</strong>（<code>index.html</code> 保持真实文件），上传到 Vercel 后数据完整。58.7.1 则会被坏符号链接“阉割”掉大文件。</p>
</li>
<li>
<p>CI workflow 中同步把 <code>npx vercel</code> 改为 <code>npx vercel@57</code>，防止自动升级到带 bug 的版本。</p>
</li>
</ol></section></section>
<section><h2>经验总结<a href="#经验总结"><span>#</span></a></h2><ul>
<li>部署”成功”不等于内容完整，排查时<strong>对比线上返回值与本地产物</strong>而不是只看状态码；</li>
<li>对于知名 PaaS 的 CLI，<strong>锁定版本</strong>是避免”莫名其妙” bug 的最有效手段（<code>npx vercel</code> → <code>npx vercel@57</code>）；</li>
<li>多平台部署（CF / EdgeOne / Vercel）有交叉验证的作用：同一份 dist，wrangler 正常、vercel CLI 异常，直接指向 CLI 本身的问题。</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/%E6%80%8E%E4%B9%88%E7%94%A8netlify%E5%8F%8D%E4%BB%A3/</id>
      <title type="text">怎么用Netlify反代你的源站</title>
      <published>2026-08-05T00:00:00.000Z</published>
      <updated>2026-08-05T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/%E6%80%8E%E4%B9%88%E7%94%A8netlify%E5%8F%8D%E4%BB%A3/"/>
      <summary type="text">怎么用Netlify反代你的源站</summary>
      <content type="html"><![CDATA[<section><h2>怎么反代<a href="#怎么反代"><span>#</span></a></h2><p>首先去<a href="https://app.netlify.com/" target="_blank">Netlify官网</a> 注册账号。再去你的github新建一个仓库，只要写一个netlify.toml（在根目录下），内容如下</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[[redirects]]</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>from = "/*"</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>to = "http://反代域名:反代端口/:splat"</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>status = 200</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>force = true</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>你只要替换<a href="http://%E5%8F%8D%E4%BB%A3%E5%9F%9F%E5%90%8D:%E5%8F%8D%E4%BB%A3%E7%AB%AF%E5%8F%A3/:splat" target="_blank">http://反代域名:反代端口/:splat</a>
如果你实际要反代的url没有端口就不填，但一定要有端口后的 <strong>/</strong>
最后回netlify创建一个新项目，选择你刚创建的Github项目，部署即可 最后绑定一下你的域名。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/%E7%95%99%E8%A8%80%E9%A1%B5%E7%9A%84%E7%BE%A4%E8%81%8A%E9%A3%8E%E6%A0%BC%E6%94%B9%E9%80%A0/</id>
      <title type="text">留言页的群聊风格改造</title>
      <published>2026-07-27T00:00:00.000Z</published>
      <updated>2026-07-27T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/%E7%95%99%E8%A8%80%E9%A1%B5%E7%9A%84%E7%BE%A4%E8%81%8A%E9%A3%8E%E6%A0%BC%E6%94%B9%E9%80%A0/"/>
      <summary type="text">把传统评论区改成即时通讯风格的聊天界面，聊聊这个改造过程中的思路和实现。</summary>
      <content type="html"><![CDATA[<p>博客的留言板应该是什么样子的？传统的评论区一层一层嵌套回复，看起来有点像论坛。但如果把它做成聊天软件那种气泡风格，会不会更有意思一点？</p>
<p>这篇文章就聊聊这个改造过程。</p>
<section><h2>整体思路<a href="#整体思路"><span>#</span></a></h2><p>改造的核心是用一个自定义的 Svelte 组件替换掉原来的 Twikoo 评论挂件。组件直接与 Twikoo 的 API 通信，获取留言数据，然后以聊天气泡的形式渲染出来。</p><p>这样做的几个好处：</p><ul>
<li>视觉上更统一，可以完全控制样式</li>
<li>交互更灵活，可以加一些原版挂件没有的功能</li>
<li>不需要额外引入第三方 UI，自己写更容易控制体积</li>
</ul></section>
<section><h2>API 通信<a href="#api-通信"><span>#</span></a></h2><p>组件通过 fetch 直接调用 Twikoo 的 API 接口，主要就两个操作：</p><ul>
<li><code>COMMENT_GET</code>：获取指定路径（这里是 <code>/guestbook/</code>）下的所有留言</li>
<li><code>COMMENT_SUBMIT</code>：提交新的留言</li>
</ul><p>Twikoo 返回的数据是嵌套结构的——每条留言下可能有若干条回复。但聊天界面不适合树形展示，所以组件把整个回复树拍平成一个按时间排序的列表，回复内容前面加上 <code>@昵称</code> 的前缀，这样上下文就很清晰了。</p></section>
<section><h2>聊天气泡<a href="#聊天气泡"><span>#</span></a></h2><p>整个界面是最像聊天软件的部分。每条消息是一行，包含头像、昵称、时间和气泡内容。</p><p>头像 36 像素的圆形，优先显示 Gravatar 头像，没有的话显示昵称首字。自己的消息靠右对齐，其他人的靠左。</p><p>气泡的样式靠 border-radius 的不对称处理来实现——别人的气泡是左下角直角、其他角圆角；自己的气泡反过来，右下角直角。这种不对称感就是聊天气泡的视觉特征。</p><p>自己的气泡使用主题色作为背景，白色文字，在视觉上突出区分。管理员的消息也使用相同的样式。</p></section>
<section><h2>区分自己与他人<a href="#区分自己与他人"><span>#</span></a></h2><p>组件通过 localStorage 存储用户的留言档案（昵称、邮箱、网址），提交时会记录这条留言的 ID。再次加载时，如果某条留言的邮箱或 ID 匹配，就把它标记为”自己的消息”，显示在右侧。</p></section>
<section><h2>自动刷新<a href="#自动刷新"><span>#</span></a></h2><p>留言板每隔 30 秒会自动请求一次新数据，新留言会即时出现，不需要手动刷新页面。但如果用户已经向上滚动浏览历史消息，自动滚动会暂停，只有停留在底部时才会自动滚到最新消息。</p></section>
<section><h2>个人资料弹窗<a href="#个人资料弹窗"><span>#</span></a></h2><p>首次留言时会弹出一个资料填写框，输入昵称和邮箱后才会显示发送栏。这些信息存在 localStorage 里，下次访问就不用再填了。</p></section>
<section><h2>回复功能<a href="#回复功能"><span>#</span></a></h2><p>每条消息悬浮时会出现”回复”按钮，点击后会在输入框上方显示”回复 @某某”的提示条，发送时会将这条回复关联到原消息上。点击提示条上的叉号可以取消回复。</p></section>
<section><h2>一些样式细节<a href="#一些样式细节"><span>#</span></a></h2><p>所有样式都写在 Svelte 组件的 <code>&lt;style&gt;</code> 标签里，使用 <code>gb__</code> 前缀来避免污染全局样式。</p><p>发送按钮是圆形的，不可用时有降低透明度。消息列表限制了最大高度（520px），超出可以滚动。每条新消息出现时有一个轻微的淡入动画，让感觉更自然。</p><p>深色模式的支持没有额外处理，因为组件直接使用了 CSS 变量 <code>var(--primary)</code>、<code>var(--card-bg)</code> 这些主题变量，切换主题时颜色会自动适配。</p></section>
<section><h2>最后<a href="#最后"><span>#</span></a></h2><p>这个改造其实不复杂，核心就是”把树形评论拍平成聊天记录”。但效果还是挺明显的——留言板不再是冷冰冰的评论区，更像一个可以聊天的空间。如果你也用的 Twikoo，不妨试试类似的改造，让访客感觉更轻松一些。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/%E8%BF%99%E4%B8%AA%E5%8D%9A%E5%AE%A2%E7%9A%84%E6%9C%8B%E5%8F%8B%E5%9C%88%E6%98%AF%E6%80%8E%E4%B9%88%E5%81%9A%E7%9A%84/</id>
      <title type="text">这个博客的朋友圈是怎么做的</title>
      <published>2026-07-27T00:00:00.000Z</published>
      <updated>2026-07-27T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/%E8%BF%99%E4%B8%AA%E5%8D%9A%E5%AE%A2%E7%9A%84%E6%9C%8B%E5%8F%8B%E5%9C%88%E6%98%AF%E6%80%8E%E4%B9%88%E5%81%9A%E7%9A%84/"/>
      <summary type="text">一篇关于博客朋友圈功能的实现思路分享，从 RSS 抓取到前端展示，聊聊背后的设计。</summary>
      <content type="html"><![CDATA[<p>前几天有朋友问我，博客里的”朋友圈”页面是怎么做的。仔细一想，这个功能确实挺有意思的，它把友链和内容聚合结合在了一起。今天就来聊聊它的实现思路。</p>
<section><h2>什么是朋友圈<a href="#什么是朋友圈"><span>#</span></a></h2><p>这里的”朋友圈”不是微信那个朋友圈，而是博客圈里的一种内容聚合页。它会抓取友链列表中各位博友的最新文章，汇总展示在一个页面里。效果类似于一个迷你 RSS 阅读器，但更轻量，也更有人情味。</p><p>你可以在导航栏找到”钓鱼”入口（之所以叫钓鱼，是因为逛朋友们的博客就像钓鱼一样，你永远不知道下一秒会钓上来什么有趣的内容）。</p></section>
<section><h2>核心思路<a href="#核心思路"><span>#</span></a></h2><p>实现思路其实不复杂：每个友链条目都可以配置一个 RSS 地址，系统定期去抓取这些 RSS 源，把文章信息整理后展示出来。</p><p>整个流程可以分为三步：</p><ol>
<li>配置友链时附带 RSS 地址</li>
<li>服务端抓取并解析所有 RSS 源</li>
<li>前端展示为卡片列表</li>
</ol></section>
<section><h2>数据源：friendsConfig<a href="#数据源friendsconfig"><span>#</span></a></h2><p>所有的友链信息集中管理在 <code>friendsConfig.ts</code> 里。每个友链除了常规的站点名、头像、网址外，还可以配置一个 <code>rss</code> 字段：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>title</span><span>:</span><span> </span><span>"</span><span>友人A</span><span>"</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>siteurl</span><span>:</span><span> </span><span>"</span><span>https://example.com</span><span>"</span><span>,</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>imgurl</span><span>:</span><span> </span><span>"</span><span>https://example.com/avatar.png</span><span>"</span><span>,</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>rss</span><span>:</span><span> </span><span>"</span><span>https://example.com/feed.xml</span><span>"</span><span>,</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>enabled</span><span>:</span><span> </span><span>true</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>没有配置 RSS 的友链不会出现在朋友圈里，但会正常显示在友情链接页面。这样设计的好处是，每个人可以自由选择是否参与内容聚合。</p></section>
<section><h2>数据抓取：rss-utils<a href="#数据抓取rss-utils"><span>#</span></a></h2><p>抓取逻辑集中在 <code>rss-utils.ts</code> 里。它同时支持 RSS 2.0 和 Atom 两种格式，通过 <code>Promise.allSettled</code> 并行抓取所有友链的 Feed，单个源失败不会影响其他源。</p><p>每个 Feed 最多取 5 篇文章，超时时间设为 8 秒。抓取到的文章会解析标题、链接、发布时间、摘要等信息，摘要会被截断到 200 字以内。</p></section>
<section><h2>API 端点<a href="#api-端点"><span>#</span></a></h2><p><code>/api/circle.json</code> 这个接口负责整合数据。它在请求时调用抓取逻辑，然后将所有文章按发布时间排序，同时返回一些统计数据：</p><ul>
<li>好友总数</li>
<li>活跃好友数（成功抓取到文章的数量）</li>
<li>文章总数</li>
<li>最后更新时间</li>
</ul><p>接口会设置一小时的缓存，避免频繁请求给朋友们的服务器造成压力。</p></section>
<section><h2>前端展示：CircleFeed.svelte<a href="#前端展示circlefeedsvelte"><span>#</span></a></h2><p>前端是一个完全客户端渲染的 Svelte 组件。页面加载时会请求上述接口，拿到数据后以卡片网格的形式展示。</p><p>每张卡片包含：</p><ul>
<li>文章标题（最多两行，超出省略）</li>
<li>作者头像和名称</li>
<li>发布时间</li>
<li>文章摘要</li>
</ul><p>卡片排列采用自适应网格，<code>auto-fill</code> 配合 <code>minmax(260px, 1fr)</code>，在不同屏幕宽度下都能合理展示。鼠标悬停时作者头像会高亮，增加一点交互的趣味。</p><p>分页逻辑也在客户端完成，默认每页 20 条，点击”加载更多”逐步展示。排序支持按发布时间和最近更新两种方式，切换后即时生效，不需要重新请求接口。</p></section>
<section><h2>一些细节<a href="#一些细节"><span>#</span></a></h2><p>页面顶部会随机展示一条最新的文章通知（取最近 6 篇中的随机一篇），算是一个小彩蛋。统计栏可以显示好友总数、活跃数和文章数，也可以选择关闭。</p><p>评论区在朋友圈页面是可选的，如果配置了评论系统且没有禁用，文章列表下方会显示评论区，方便朋友们留言互动。</p><p>最后想说的是，这个功能的初衷很简单——希望博客之间能有更多的连接。友链不只是单向的跳转，它也可以变成一种内容的流动。如果你也有自己的博客，不妨试试在你的站点上搭建类似的功能，感受一下博客圈的温度。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/26-07-21/</id>
      <title type="text">今天是伟大的0721日</title>
      <published>2026-07-21T00:00:00.000Z</published>
      <updated>2026-07-21T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/26-07-21/"/>
      <summary type="text">今天居然是0721日</summary>
      <content type="html"><![CDATA[<p>今天居然是0721日</p>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/cf-vs-vercel/</id>
      <title type="text">Cloudflare VS Vercel？！</title>
      <published>2026-07-03T00:00:00.000Z</published>
      <updated>2026-07-03T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/cf-vs-vercel/"/>
      <summary type="text">简单介绍Cloudflare和vercel的服务，以及对比双方优缺点。</summary>
      <content type="html"><![CDATA[<section><h2>引言<a href="#引言"><span>#</span></a></h2><p>Vercel 和 Cloudflare 是目前较为常见的两个 Web 部署平台。Vercel 偏向前端生态和开发体验，Cloudflare 则依托其全球边缘网络提供底层计算和存储能力。两者都在往”全栈应用交付平台”的方向发展，但实现路径和适用场景有所不同。</p><p>本文将介绍双方的服务和异同点，希望能帮读者理清其中的差别，从而根据自身需求做出合适的选择。</p></section>
<section><h2>Cloudflare<a href="#cloudflare"><span>#</span></a></h2><p>Cloudflare 依托其全球边缘网络，将计算和存储部署在离用户较近的节点上。以下是其四个核心服务。</p><section><h3>Workers<a href="#workers"><span>#</span></a></h3><p>Workers 是一个在 Cloudflare 全球网络上运行的无服务器计算平台，基于 Service Workers API，运行在 V8 隔离环境中，启动速度快。</p><p><strong>能跑什么后端代码：</strong>
Workers 运行在 V8 引擎上，不兼容完整的 Node.js API（没有 <code>fs</code>、<code>net</code> 等模块），但提供了 <code>fetch</code>、<code>Request</code>、<code>Response</code> 等 Web 标准 API。支持的编程语言包括 JavaScript、TypeScript、Rust（编译为 WASM）和 Python（通过 Pyodide）。</p><p><strong>能运行什么框架：</strong></p><ul>
<li>Web 框架：Hono、Itty Router</li>
<li>全栈框架：Astro（SSR）、SvelteKit、Remix、SolidStart</li>
<li>静态站点：Hugo、Jekyll、Zola、Astro（静态模式）</li>
</ul><p>免费计划每天 10 万次请求，128 MB 内存。</p></section><section><h3>D1<a href="#d1"><span>#</span></a></h3><p>D1 是 Cloudflare 的无服务器关系型数据库，基于 SQLite，可通过 Workers 直接访问或通过 HTTP API 操作，无需管理连接池。免费计划 5 GB 存储。</p></section><section><h3>KV<a href="#kv"><span>#</span></a></h3><p>Workers KV 是一个全局键值存储，数据被复制到 Cloudflare 的边缘节点，适合存储配置、缓存数据等。免费计划 10 万个键。</p></section><section><h3>R2<a href="#r2"><span>#</span></a></h3><p>R2 是 Cloudflare 的对象存储服务，兼容 S3 API，特点是<strong>不收取出口流量费</strong>。阿里云 OSS 属于同一类。它主要用于存储非结构化数据，所以可以存很多东西，甚于像本站一样的博客。免费计划 10 GB 存储。</p></section></section>
<section><h2>Vercel<a href="#vercel"><span>#</span></a></h2><p>Vercel 与前端的框架整合较好，提供了从开发到部署的一体化体验。</p><section><h3>Node.js 运行时<a href="#nodejs-运行时"><span>#</span></a></h3><p>Vercel 的核心无服务器函数基于 Node.js 运行时，也支持 Python、Go、Ruby、Bun。函数在 AWS Lambda 上执行，可以使用完整的 Node.js API。</p><p><strong>能跑什么后端代码：</strong>
函数运行在标准的 Node.js 环境中，可以访问 <code>fs</code>、<code>net</code>、<code>http</code>、<code>child_process</code> 等所有 Node.js 内置模块，也可以安装任何 npm 包。因此能跑大多数 Node.js 后端代码，包括 Express、Fastify、Apollo Server 等。注意函数有超时限制（Hobby 计划 60 秒），不适合长时间运行的后台任务。</p><p><strong>能运行什么框架：</strong></p><ul>
<li>React 框架：Next.js（原生支持最好）、Remix</li>
<li>Vue 框架：Nuxt</li>
<li>Svelte 框架：SvelteKit</li>
<li>通用框架：Astro、VitePress、Docusaurus、Hexo</li>
<li>几乎所有前端框架都能自动检测并选择最佳构建配置</li>
</ul><p>免费计划每月 100 小时函数执行时间，单函数最大 50 MB。</p></section><section><h3>数据库（PostgreSQL）<a href="#数据库postgresql"><span>#</span></a></h3><p>Vercel 与 Neon 合作提供了 Vercel Postgres（现为 Vercel Storage 的一部分），是标准的 PostgreSQL 数据库。也可以用 <code>pg</code> 或 <code>@vercel/postgres</code> 连接外部 PG 实例，如 Supabase、AWS RDS 等。</p></section><section><h3>构建系统<a href="#构建系统"><span>#</span></a></h3><p>Vercel 的构建系统会为每次 <code>git push</code> 自动触发构建和部署，自动检测框架并生成预览环境。免费计划每月 6,000 分钟构建时长。</p></section></section>
<section><h2>D1（SQLite）与 PG 的差异<a href="#d1sqlite与-pg-的差异"><span>#</span></a></h2><p>D1 基于 SQLite，PostgreSQL（Neon）是一个独立的关系型数据库，两者差异不小：</p><div><div><img alt="@startuml
skinparam backgroundColor transparent
skinparam shadowing false

title D1 (SQLite) vs PostgreSQL

rectangle &quot;D1&quot; as D1 #87CEEB {
  (单写者模型，并发写性能一般)
  (基础 SQL，无存储过程)
  (简单类型：INTEGER / TEXT / B" src="https://www.plantuml.com/plantuml/svg/NP1TJzfG6CVl-ocUo8rS5QwSTqgfX0KL1soxxQHZ12YcBTl5OjAmMPcykMKeBnk82zfihc2Y1gYExxBqd5El_0fBssLpkplFoUzvyl_zboIP4-L2Baj9cRImnObS3fvncvcKc2y8IIQVpOiWYvmWRN4YByZtAEa5byo_IWifUCvb9PwYvBISvI7eX_dOHYGjyrvuAK4qByafaOzjH2XAv3TbJaXbUPWB-kU0aspOys21OTa0lAO0vb6rWTIMfUnWhO-wvRlh2ffSeVfxfBQmScgzkJ77YbKwypfiRqhw2iGs8dVN5NnuXButKL6pPhj4Api0qHLKRP1pmxdK3g_5sH3x26Y8iq_ZG4CWiXvmmSu5hWtGPGNj3E19Nipm8bYp3j7Arj10QilxEne-s3MDApjKjM7j5KbnugMs_z7wQ-leHKFWMLbPzYqiktghZna6RhyrNIdth5x1ZGlNecrEosYl3pGmSHPei4vFRFUM1ZIW7GqVxE8l2hauTXRDyGWDAt_i7iRMrm90Go8H3W8Dk34Ynbi7Z3DHi6PTN1lWyoFtuVnRUf_eXsvhgAII4yCsgmzjvtT3_EdiVxbRfOZsIxVz2UdeZbue4Gwou5dqiUoYppKC-k6d-W7CyLTpEaNLDjAFxV2ZEkgMOJaQjeTQpnvei7vCo7NJlAaXuygQzPnGqP2pJpvUeePgC_eKVUxOZQp6N2rSrL59nTrzeEzNkKqjyKAoaCl-0W00" loading="lazy" /></div></div><p><strong>什么项目适合什么：</strong></p><p>D1 适合个人博客、小型 API、配置管理、原型项目、边缘地理位置需要低延迟的场景。不需要复杂查询和高并发，追求简单和免运维。</p><p>PostgreSQL 适合需要事务完整性、复杂查询、高并发的项目：如电商系统、论坛、内容管理系统、数据分析后台等。如果已经有现成的 PG 应用迁移过来也更合适。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/admin-panel/</id>
      <title type="text">给博客写了个后台管理</title>
      <published>2026-06-27T00:00:00.000Z</published>
      <updated>2026-06-27T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/admin-panel/"/>
      <summary type="text">一个简单顺手的博客后台，从零到一的实践记录</summary>
      <content type="html"><![CDATA[<p>一边写博客一边觉得，要是能有个顺手的管理界面就好了。于是趁周末动手写了一个，前后端都搭在自己能掌控的基础设施上。</p>
<p>前端是 Firefly 主题的一部分，嵌在博客项目里，用 Svelte 5 的 runes 语法写的几个组件；后端单独部署在 Cloudflare Workers 上，框架是 Hono。</p>
<p>文章存在 GitHub 仓库里，通过 Contents API 读写，所以每次保存都会自动产生一次 Git 提交，版本历史天然就跟着走了。站点配置存在 Cloudflare D1 里，查询和保存都很快。图片走的是自建的 CloudFlare-ImgBed，上传时通过后端代理转发到图床，返回的 URL 直接插入 Markdown 正文。</p>
<p>后台主要分三块：文章编辑器带 Markdown 工具栏和实时预览，站点配置页管理社交链接和友链，还有一个图片管理器可以浏览和删除已上传的图片。编辑器支持拖拽和剪贴板上传图片，前端用 XHR 做了进度条。</p>
<p></p><figure><img alt="后台示例" loading="lazy" width="1080" height="2412" src="/_astro/firefly-background.C-IMuNjZ_Z1dzT5T.webp" /><figcaption>后台示例</figcaption></figure><p></p>
<p>前后端都已开源：</p>
<a href="https://github.com/qwc-ch/Firefly" target="_blank"><div><div><div><div></div><div>qwc-ch</div></div><div>/</div><div>Firefly</div></div><div></div></div><div>Description not set</div><div><div>—</div><div>—</div><div>MIT</div><span>Astro</span></div></a>
<a href="https://github.com/qwc-ch/firefly-admin-cfworkers" target="_blank"><div><div><div><div></div><div>qwc-ch</div></div><div>/</div><div>firefly-admin-cfworkers</div></div><div></div></div><div>基于 Hono 的 Cloudflare Workers 博客 管理 API，为 "Firefly" 博客系统提供后端支持</div><div><div>—</div><div>—</div><div>MIT</div><span>TypeScript</span></div></a>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/summer_plan/</id>
      <title type="text">待办</title>
      <published>2026-06-21T00:00:00.000Z</published>
      <updated>2026-06-21T00:00:00.000Z</updated>
      <author><name>Admin</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/summer_plan/"/>
      <summary type="text">关于我在暑期对博客添加新功能的计划</summary>
      <content type="html"><![CDATA[<section><h2>功能<a href="#功能"><span>#</span></a></h2><ul>
<li>[√] 后台</li>
<li>[√] AI摘要</li>
<li>[√] AI聊天页面</li>
<li>[√] 说说/朋友圈</li>
</ul></section>
<section><h2>想法<a href="#想法"><span>#</span></a></h2><p>这几个功能里，后头没想法用伪后，还是做真后端。AI聊天页面我打算用Cloudflare Workers 不存任何记录，所以并不依赖任何数据库。其他的想到了在加……</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/automatic-construction/</id>
      <title type="text">GitHub Actions 自动构建并部署 Cloudflare Workers 实现思路</title>
      <published>2026-06-04T00:00:00.000Z</published>
      <updated>2026-06-04T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/automatic-construction/"/>
      <summary type="text">讲解如何用 GitHub Actions 实现推送代码后自动构建 Astro 项目，将产物推送到 build 分支，并部署到 Cloudflare Workers</summary>
      <content type="html"><![CDATA[<div><div><div></div><div>Warning</div></div><div><p>这个项目在 Cloudflare Workers 上构建时可能会翻车。Astro 构建过程依赖 <code>sharp</code>（图片处理）和一些 Node.js 原生模块，Cloudflare Workers 的构建环境对这些支持有限。如果你在 Workers 上构建失败，别慌，这是正常操作。解决方案是：在 GitHub Actions 里构建好，只把产物丢过去，让 Workers 只管托管静态文件，别让它干重活。</p></div></div>
<section><h2>为什么要搞这个<a href="#为什么要搞这个"><span>#</span></a></h2><p>每次手动构建、再部署，累不累啊？反正我累了。目标是：推送代码到 <code>main</code> 分支后，GitHub Actions 自动帮你搞定三件事：</p><ol>
<li>构建 Astro 项目（让它在 CI 里卷）</li>
<li>把构建产物推送到 <code>build</code> 分支（留个备份）</li>
<li>部署到 Cloudflare Workers（上线，收工）</li>
</ol><p>一条龙服务，你只需要 <code>git push</code>，剩下的交给机器人。</p></section>
<section><h2>整体流程<a href="#整体流程"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>push main → checkout → 安装依赖 → pnpm build → 推送 dist 到 build 分支 → wrangler deploy</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>实现思路<a href="#实现思路"><span>#</span></a></h2><section><h3>1. 触发条件<a href="#1-触发条件"><span>#</span></a></h3><p>监听 <code>main</code> 分支的 push 事件，同时支持手动触发（<code>workflow_dispatch</code>）。手动触发的好处是：万一自动部署翻车了，你可以点一下重试，不用再 push 一个空提交。</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> </span><span>[</span><span>main</span><span>]</span></div></div><div><div><div>4</div></div><div><span>  </span><span>workflow_dispatch</span><span>:</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>2. 环境准备<a href="#2-环境准备"><span>#</span></a></h3><p>GitHub Actions 的 Ubuntu 镜像里没有 pnpm，得自己装。顺序有讲究：先装 pnpm，再装 Node.js，这样 Node.js 的缓存机制才能识别 pnpm。搞反了缓存就废了。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Setup pnpm</span></div></div><div><div><div>2</div></div><div><span>  </span><span>uses</span><span>:</span><span> </span><span>pnpm/action-setup@v4</span></div></div><div><div><div>3</div></div><div><span>  </span><span>with</span><span>:</span></div></div><div><div><div>4</div></div><div><span>    </span><span>version</span><span>:</span><span> </span><span>9.14.4</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Setup Node.js</span></div></div><div><div><div>7</div></div><div><span>  </span><span>uses</span><span>:</span><span> </span><span>actions/setup-node@v4</span></div></div><div><div><div>8</div></div><div><span>  </span><span>with</span><span>:</span></div></div><div><div><div>9</div></div><div><span>    </span><span>node-version</span><span>:</span><span> </span><span>'</span><span>24</span><span>'</span></div></div><div><div><div>10</div></div><div><span>    </span><span>cache</span><span>:</span><span> </span><span>'</span><span>pnpm</span><span>'</span><span>  </span><span># 必须在 pnpm 安装之后</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>3. 构建项目<a href="#3-构建项目"><span>#</span></a></h3><p><code>pnpm run build</code> 其实是个三连：先生成图标，再用 Astro 构建，最后用 Pagefind 做全文搜索索引。这三步缺一不可，少了哪个搜索功能都会出问题。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Install Dependencies</span></div></div><div><div><div>2</div></div><div><span>  </span><span>run</span><span>:</span><span> </span><span>pnpm install --frozen-lockfile</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Build</span></div></div><div><div><div>5</div></div><div><span>  </span><span>run</span><span>:</span><span> </span><span>pnpm run build</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>4. 推送到 build 分支<a href="#4-推送到-build-分支"><span>#</span></a></h3><p>用 <code>peaceiris/actions-gh-pages</code> 这个 action，把 <code>dist/</code> 目录的内容推到 <code>build</code> 分支。它每次都会暴力覆盖，不会保留历史。不过也没关系，反正构建产物每次都一样。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Push to build branch</span></div></div><div><div><div>2</div></div><div><span>  </span><span>uses</span><span>:</span><span> </span><span>peaceiris/actions-gh-pages@v4</span></div></div><div><div><div>3</div></div><div><span>  </span><span>with</span><span>:</span></div></div><div><div><div>4</div></div><div><span>    </span><span>github_token</span><span>:</span><span> </span><span>${{ secrets.GITHUB_TOKEN }}</span></div></div><div><div><div>5</div></div><div><span>    </span><span>publish_dir</span><span>:</span><span> </span><span>./dist</span></div></div><div><div><div>6</div></div><div><span>    </span><span>publish_branch</span><span>:</span><span> </span><span>build</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>5. 部署到 Cloudflare Workers<a href="#5-部署到-cloudflare-workers"><span>#</span></a></h3><p>最后一步，用 <code>wrangler deploy</code> 把构建好的静态文件丢到 Workers 上。这一步需要两个 secrets：API Token 和 Account ID，没这俩东西.wrangler 连不上 Cloudflare。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Deploy to Cloudflare Workers</span></div></div><div><div><div>2</div></div><div><span>  </span><span>run</span><span>:</span><span> </span><span>npx wrangler deploy</span></div></div><div><div><div>3</div></div><div><span>  </span><span>env</span><span>:</span></div></div><div><div><div>4</div></div><div><span>    </span><span>CLOUDFLARE_API_TOKEN</span><span>:</span><span> </span><span>${{ secrets.CLOUDFLARE_API_TOKEN }}</span></div></div><div><div><div>5</div></div><div><span>    </span><span>CLOUDFLARE_ACCOUNT_ID</span><span>:</span><span> </span><span>${{ secrets.CLOUDFLARE_ACCOUNT_ID }}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>
<section><h2>完整源码<a href="#完整源码"><span>#</span></a></h2><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># 构建项目并将产物推送到 build 分支，然后部署到 Cloudflare Workers</span></div></div><div><div><div>2</div></div><div><span>name</span><span>:</span><span> </span><span>Build to Branch</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span># 当推送到 main 分支时触发，也支持手动触发</span></div></div><div><div><div>5</div></div><div><span>on</span><span>:</span></div></div><div><div><div>6</div></div><div><span>  </span><span>push</span><span>:</span></div></div><div><div><div>7</div></div><div><span>    </span><span>branches</span><span>:</span><span> </span><span>[</span><span>main</span><span>]</span></div></div><div><div><div>8</div></div><div><span>  </span><span>workflow_dispatch</span><span>:</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span># 需要写入权限来推送 build 分支</span></div></div><div><div><div>11</div></div><div><span>permissions</span><span>:</span></div></div><div><div><div>12</div></div><div><span>  </span><span>contents</span><span>:</span><span> </span><span>write</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span># 同一时间只运行一个构建任务，取消正在运行的旧任务</span></div></div><div><div><div>15</div></div><div><span>concurrency</span><span>:</span></div></div><div><div><div>16</div></div><div><span>  </span><span>group</span><span>:</span><span> </span><span>build</span></div></div><div><div><div>17</div></div><div><span>  </span><span>cancel-in-progress</span><span>:</span><span> </span><span>true</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>jobs</span><span>:</span></div></div><div><div><div>20</div></div><div><span>  </span><span>build</span><span>:</span></div></div><div><div><div>21</div></div><div><span>    </span><span>runs-on</span><span>:</span><span> </span><span>ubuntu-latest</span></div></div><div><div><div>22</div></div><div><span>    </span><span>steps</span><span>:</span></div></div><div><div><div>23</div></div><div><span><span>      </span></span><span># 检出代码</span></div></div><div><div><div>24</div></div><div><span><span>      </span></span><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Checkout</span></div></div><div><div><div>25</div></div><div><span>        </span><span>uses</span><span>:</span><span> </span><span>actions/checkout@v4</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span><span>      </span></span><span># 安装 pnpm 包管理器</span></div></div><div><div><div>28</div></div><div><span><span>      </span></span><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Setup pnpm</span></div></div><div><div><div>29</div></div><div><span>        </span><span>uses</span><span>:</span><span> </span><span>pnpm/action-setup@v4</span></div></div><div><div><div>30</div></div><div><span>        </span><span>with</span><span>:</span></div></div><div><div><div>31</div></div><div><span>          </span><span>version</span><span>:</span><span> </span><span>9.14.4</span></div></div><div><div><div>32</div></div><div>
</div></div><div><div><div>33</div></div><div><span><span>      </span></span><span># 安装 Node.js 并配置 pnpm 缓存</span></div></div><div><div><div>34</div></div><div><span><span>      </span></span><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Setup Node.js</span></div></div><div><div><div>35</div></div><div><span>        </span><span>uses</span><span>:</span><span> </span><span>actions/setup-node@v4</span></div></div><div><div><div>36</div></div><div><span>        </span><span>with</span><span>:</span></div></div><div><div><div>37</div></div><div><span>          </span><span>node-version</span><span>:</span><span> </span><span>'</span><span>24</span><span>'</span></div></div><div><div><div>38</div></div><div><span>          </span><span>cache</span><span>:</span><span> </span><span>'</span><span>pnpm</span><span>'</span></div></div><div><div><div>39</div></div><div>
</div></div><div><div><div>40</div></div><div><span><span>      </span></span><span># 安装项目依赖</span></div></div><div><div><div>41</div></div><div><span><span>      </span></span><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Install Dependencies</span></div></div><div><div><div>42</div></div><div><span>        </span><span>run</span><span>:</span><span> </span><span>pnpm install --frozen-lockfile</span></div></div><div><div><div>43</div></div><div>
</div></div><div><div><div>44</div></div><div><span><span>      </span></span><span># 构建项目：生成图标 → Astro 构建 → Pagefind 索引</span></div></div><div><div><div>45</div></div><div><span><span>      </span></span><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Build</span></div></div><div><div><div>46</div></div><div><span>        </span><span>run</span><span>:</span><span> </span><span>pnpm run build</span></div></div><div><div><div>47</div></div><div>
</div></div><div><div><div>48</div></div><div><span><span>      </span></span><span># 推送 dist 目录到 build 分支</span></div></div><div><div><div>49</div></div><div><span><span>      </span></span><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Push to build branch</span></div></div><div><div><div>50</div></div><div><span>        </span><span>uses</span><span>:</span><span> </span><span>peaceiris/actions-gh-pages@v4</span></div></div><div><div><div>51</div></div><div><span>        </span><span>with</span><span>:</span></div></div><div><div><div>52</div></div><div><span>          </span><span>github_token</span><span>:</span><span> </span><span>${{ secrets.GITHUB_TOKEN }}</span></div></div><div><div><div>53</div></div><div><span>          </span><span>publish_dir</span><span>:</span><span> </span><span>./dist</span></div></div><div><div><div>54</div></div><div><span>          </span><span>publish_branch</span><span>:</span><span> </span><span>build</span></div></div><div><div><div>55</div></div><div>
</div></div><div><div><div>56</div></div><div><span><span>      </span></span><span># 使用 wrangler CLI 部署到 Cloudflare Workers</span></div></div><div><div><div>57</div></div><div><span><span>      </span></span><span>-</span><span> </span><span>name</span><span>:</span><span> </span><span>Deploy to Cloudflare Workers</span></div></div><div><div><div>58</div></div><div><span>        </span><span>run</span><span>:</span><span> </span><span>npx wrangler deploy</span></div></div><div><div><div>59</div></div><div><span>        </span><span>env</span><span>:</span></div></div><div><div><div>60</div></div><div><span><span>          </span></span><span># Cloudflare API Token，需要 Edit Cloudflare Workers 权限</span></div></div><div><div><div>61</div></div><div><span>          </span><span>CLOUDFLARE_API_TOKEN</span><span>:</span><span> </span><span>${{ secrets.CLOUDFLARE_API_TOKEN }}</span></div></div><div><div><div>62</div></div><div><span><span>          </span></span><span># Cloudflare 账户 ID，在 Dashboard 右侧栏可见</span></div></div><div><div><div>63</div></div><div><span>          </span><span>CLOUDFLARE_ACCOUNT_ID</span><span>:</span><span> </span><span>${{ secrets.CLOUDFLARE_ACCOUNT_ID }}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section>
<section><h2>需要配置的 Secrets<a href="#需要配置的-secrets"><span>#</span></a></h2><p>在 GitHub 仓库 Settings → Secrets and variables → Actions 里加两个 secret，不加的话 workflow 会直接报错退出。</p>

<table><thead><tr><th>Secret</th><th>说明</th><th>获取方式</th></tr></thead><tbody><tr><td><code>CLOUDFLARE_API_TOKEN</code></td><td>API 令牌</td><td>Cloudflare Dashboard → My Profile → API Tokens → Create Token</td></tr><tr><td><code>CLOUDFLARE_ACCOUNT_ID</code></td><td>账户 ID</td><td>Cloudflare Dashboard 右侧栏</td></tr></tbody></table><p>API Token 要选 <strong>“Edit Cloudflare Workers”</strong> 模板，这个模板包含 Workers 脚本编辑权限，部署够用了。</p></section>
<section><h2>注意事项<a href="#注意事项"><span>#</span></a></h2><ul>
<li><code>--frozen-lockfile</code> 是个好东西，确保 CI 里不会偷偷给你更新依赖版本，避免”我本地能跑线上挂了”的经典剧情</li>
<li><code>concurrency</code> 配置能避免同时跑多个构建任务，省点 CI 分钟数</li>
<li><code>wrangler deploy</code> 会老老实实读取仓库根目录的 <code>wrangler.toml</code>，所以记得把这个文件提交上去</li>
<li><code>build</code> 分支只包含 <code>dist/</code> 的内容，源码不会泄露，放心用</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/firefly_welcometoast/</id>
      <title type="text">Firefly 主题 添加WelcomeToast 组件</title>
      <published>2026-05-31T00:00:00.000Z</published>
      <updated>2026-05-31T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/firefly_welcometoast/"/>
      <summary type="text">分析 Firefly Astro 博客主题中 WelcomeToast 欢迎弹窗的实现逻辑与在 MainGridLayout 中的集成方式</summary>
      <content type="html"><![CDATA[<section><h1>Firefly 主题 WelcomeToast 组件分析<a href="#firefly-主题-welcometoast-组件分析"><span>#</span></a></h1><p>Firefly是一个基于 Astro的博客主题。</p><a href="https://github.com/CuteLeaf/Firefly" target="_blank"><div><div><div><div></div><div>CuteLeaf</div></div><div>/</div><div>Firefly</div></div><div></div></div><div>🍀Firefly, fresh and aesthetic Astro blog theme template. </div><div><div>—</div><div>—</div><div>MIT</div><span>Astro</span></div></a><p>WelcomeToast 是我自己加的一个小组件，效果就是访客第一次打开页面时，右下角会弹出一个欢迎卡片，还带了 IP 地理位置的个性化问候，5 秒后自动消失。</p><p></p><figure><img alt="WelcomeToast 效果演示" loading="lazy" width="1078" height="366" src="/_astro/welcome.CqPgWiqc_SONnY.webp" /><figcaption>WelcomeToast 效果演示</figcaption></figure><p></p><hr /><section><h2>涉及的文件<a href="#涉及的文件"><span>#</span></a></h2><p>整个组件涉及两个文件：</p>

<table><thead><tr><th>文件</th><th>路径</th></tr></thead><tbody><tr><td>组件本身</td><td><code>src/components/widget/WelcomeToast.astro</code></td></tr><tr><td>在布局中引用</td><td><code>src/layouts/MainGridLayout.astro</code></td></tr></tbody></table><hr /></section><section><h2>组件是怎么实现的<a href="#组件是怎么实现的"><span>#</span></a></h2><p>这个组件的 frontmatter 是空的，没有任何服务端逻辑。所有事情都在浏览器端的 <code>&lt;script&gt;</code> 和 <code>&lt;style&gt;</code> 里完成。</p><section><h3>页面上长什么样<a href="#页面上长什么样"><span>#</span></a></h3><p>组件本身不输出任何 HTML，所有 DOM 都是 JS 动态生成的。结构大概是这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#welcome-toast（固定在右下角，z-50）</span></div></div><div><div><div>2</div></div><div><span>└── 一个卡片 div（白色背景、圆角、阴影）</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>├── 👋 表情</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>├── 文字区</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>│   ├── 问候语（一开始显示"正在加载..."，后面会变成具体地址）</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>│   └── 副标题"欢迎来到我的博客"</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>└── 关闭按钮（一个 ×）</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>几个关键函数<a href="#几个关键函数"><span>#</span></a></h3><p>整个逻辑不复杂，就是 5 个函数各管各的事：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 用 sessionStorage 做会话标识，防止重复弹出</span></div></div><div><div><div>2</div></div><div><span>const</span><span><span> </span><span>VISIT_SESSION_KEY</span><span> </span></span><span>=</span><span> </span><span>'</span><span>blog_visit_flag</span><span>'</span><span>;</span></div></div><div><div><div>3</div></div><div><span>let</span><span> hasShownToast </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>// 创建 Toast 的 DOM 结构，插入到页面上</span></div></div><div><div><div>6</div></div><div><span>function</span><span> </span><span>createWelcomeToast</span><span>()</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>// 关掉 Toast：先滑出去，500ms 后把节点从 DOM 里移掉</span></div></div><div><div><div>9</div></div><div><span>function</span><span> </span><span>closeWelcomeToast</span><span>()</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>// 请求一个免费的 IP API，拿到访客的位置信息</span></div></div><div><div><div>12</div></div><div><span>async</span><span> </span><span>function</span><span> </span><span>fetchLocation</span><span>()</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>// 整个弹出流程的"导演"：创建 → 滑入 → 拿位置 → 5秒后自动关闭</span></div></div><div><div><div>15</div></div><div><span>async</span><span> </span><span>function</span><span> </span><span>showWelcomeToast</span><span>()</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>// 入口函数：判断是不是第一次访问，是的话才弹</span></div></div><div><div><div>18</div></div><div><span>function</span><span> </span><span>initWelcome</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section><section><h3>执行顺序<a href="#执行顺序"><span>#</span></a></h3><p>说白了就是这么个流程：</p><div><div><img alt="@startuml
start

:initWelcome();

if (sessionStorage 有 blog_visit_flag?) then (有标记)
  :跳过，不弹了;
  stop
else (没标记)
  :打标记，调用 showWelcomeToast();
endif

:hasShownToast = true;
:createWelcomeToast() 创建 DO" src="https://www.plantuml.com/plantuml/svg/VP9FQnD16CRlyobUUafkzT3BBiKANWeL2nMy1CfqCyakRcPaPs8H4GesPjDicxGAjMc2iMI9Y8tn3rIJRVjbChERqtu5sLsXreEtUUUTUUPvVk-iS84TKQtOA5qWf5dK4e-9RR0AoULrXAmIv3ZXt69qGp07bmceRWEsR5RUV6PnIsoMR5o-cmTX4WevrMseVZqQZVC8G8iklaVNzJZmPZ_tPV1hDdcj8m0ks5D4R4uWfxvz-EkuQhp9gZZmel6hyEr7u2RR_cFd4SDS99u8BLebX3GJymsJRTEq0SiWd2hHaMOu10jo-n98zrHE9t3_uOC2LQqZMVDXYnMVnu4dtPuSDkUTTdJSqX7IxYmkLZZ8za4udCvF1ba331jpNg3gOYht-khTLJYOP7jnu6Nlg-cXhFawGYNcF4dIb8Wmp3LcO64nclW6I62khiEzzLN8yYctBVVUtz1BouG5W3O_7icTa-ZBEIp3mkooB_tBEF1Kpu_gdrx8xbZsTbw6dLtLRSfMSo6HpvZAmTVeX_z_aSzdqVM1wldI7IltCEpiolsE79rbAYdU90NWChPeaYKyRyYh6Yn1E3nA1m1fIgGPDkF_i2xG6o3rIHnuIxU99aFG4Kf_mGgXnMh5_Wq0" loading="lazy" /></div></div></section><section><h3>怎么适配 SPA 导航的<a href="#怎么适配-spa-导航的"><span>#</span></a></h3><p>这里有个坑：Firefly 用了 Swup 做客户端路由，页面切换的时候不会走完整的 <code>DOMContentLoaded</code>。所以组件同时绑了两个事件：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span> </span><span>(</span><span>document</span><span>.</span><span>readyState </span><span>===</span><span> </span><span>'</span><span>loading</span><span>'</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>document</span><span>.</span><span>addEventListener</span><span>(</span><span>'</span><span>DOMContentLoaded</span><span>'</span><span>,</span><span> initWelcome</span><span>);</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>document</span><span>.</span><span>addEventListener</span><span>(</span><span>'</span><span>astro:page-load</span><span>'</span><span>,</span><span> initWelcome</span><span>);</span><span>  </span><span>// Swup 专用</span></div></div><div><div><div>4</div></div><div><span>}</span><span> </span><span>else</span><span> </span><span>{</span></div></div><div><div><div>5</div></div><div><span>    </span><span>initWelcome</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>onclick</code>，所以函数得挂到 <code>window</code> 上才能调用：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>(</span><span>window </span><span>as</span><span> </span><span>any</span><span>).</span><span>closeWelcomeToast </span><span>=</span><span> closeWelcomeToast</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>样式部分<a href="#样式部分"><span>#</span></a></h3><ul>
<li><strong>桌面端</strong>：固定在右下角（<code>fixed bottom-4 right-4 z-50</code>），用 CSS transition 做滑入滑出动画，500ms ease-out。</li>
<li><strong>移动端</strong>（640px 以下）：改到屏幕底部居中，宽度 90%，不会遮挡太多内容。</li>
<li><strong>深色模式</strong>：用 Tailwind 的 <code>dark:</code> 变体自动切换背景色、边框色和文字色。</li>
</ul><hr /></section></section><section><h2>在 MainGridLayout 里怎么放的<a href="#在-maingridlayout-里怎么放的"><span>#</span></a></h2><p>MainGridLayout 是 Firefly 的主布局，导航栏、壁纸、侧边栏、Footer 都在这里面。WelcomeToast 在第 7 行被 import，然后放在了第 858 行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> WelcomeToast </span><span>from</span><span> </span><span>"</span><span>@components/widget/WelcomeToast.astro</span><span>"</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>具体位置是在 <code>#swup-container</code> 里面、<code>&lt;slot /&gt;</code> 后面：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>main</span><span> </span><span>id</span><span>=</span><span>"</span><span>swup-container</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>transition-main</span><span>"</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"</span><span>content-wrapper</span><span>"</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span><span>        </span></span><span>&lt;</span><span>slot</span><span>&gt;&lt;/</span><span>slot</span><span>&gt;</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>&lt;</span><span>WelcomeToast</span><span> /&gt;</span></div></div><div><div><div>6</div></div><div><span>&lt;/</span><span>main</span><span>&gt;</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>Layout.astro</span></div></div><div><div><div>2</div></div><div><span>└── MainGridLayout.astro</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>├── Navbar（导航栏）</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>├── Wallpaper（背景壁纸）</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>├── &lt;main id="swup-container"&gt;      ← Swup 的容器，导航时会替换这里面的内容</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>│   ├── &lt;slot&gt;                       ← 每个页面的具体内容</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>│   └── &lt;WelcomeToast /&gt;             ← 放在内容后面</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>├── Footer（页脚）</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>└── FloatingControls（浮动控制按钮）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><section><h3>几个值得注意的点<a href="#几个值得注意的点"><span>#</span></a></h3><ul>
<li><strong>没传任何 props</strong>：组件完全是自包含的，不需要从外面传参数。</li>
<li><strong>靠 sessionStorage 去重</strong>：每次 Swup 导航都会重建组件，但因为有 <code>blog_visit_flag</code> 标记，所以不会重复弹出。</li>
<li><strong>兼容 Swup 的 astro 事件</strong>：客户端路由切换后也能正常触发。</li>
<li><strong>构建时没有 HTML 输出</strong>：组件只有 <code>&lt;script&gt;</code> 和 <code>&lt;style&gt;</code>，不产生静态 HTML，全靠 JS 在浏览器里创建。</li>
</ul><hr /></section></section><section><h2>总结<a href="#总结"><span>#</span></a></h2><p>WelcomeToast 说白了就是一个浏览器端的欢迎弹窗，用 <code>sessionStorage</code> 保证每个会话只弹一次，用 IP API 拿位置做个性化问候，5 秒自动消失。放在 Firefly 主题的 MainGridLayout 里，和 Swup 的 SPA 导航机制配合得很好。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/firefly-umami-stats/</id>
      <title type="text">为 Firefly 添加 Umami 访问统计卡片</title>
      <published>2026-05-29T00:00:00.000Z</published>
      <updated>2026-05-31T00:00:00.000Z</updated>
      <author><name>THW&apos;s Blog</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/firefly-umami-stats/"/>
      <summary type="text">在 Firefly 博客侧边栏集成 Umami 访问统计，实时展示浏览量、访问数和游客数</summary>
      <content type="html"><![CDATA[<div><div><div></div><div>Note</div></div><div><p>这篇文章是基于 <a href="https://blog.tianhw.top/posts/fuwari-umami-stats/" target="_blank">THW’s Blog</a> 的教程改造的，感谢大佬的分享。</p></div></div>
<section><h2>为什么要加统计<a href="#为什么要加统计"><span>#</span></a></h2><p>搞个博客总想知道有没有人看吧。Umami 是个轻量级的统计工具，部署简单，也不会像 Google Analytics 那样隐私问题一堆。</p><p>但问题来了：Umami 自带的分享链接打开就是一个独立页面，跟博客风格完全不搭。所以我把它做成了一个侧边栏组件，嵌进博客里，打开就能看到访问数据，顺眼多了。</p></section>
<section><h2>准备工作<a href="#准备工作"><span>#</span></a></h2><p>前提是你已经部署好了 Umami，然后拿到一个分享链接。后面填进代码里就行。</p></section>
<section><h2>添加组件<a href="#添加组件"><span>#</span></a></h2><p>在 <code>src/components/widget/</code> 目录下新建一个 <code>UmamiStats.astro</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>import</span><span> WidgetLayout </span><span>from</span><span> </span><span>"</span><span>../common/WidgetLayout.astro</span><span>"</span><span>;</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>interface</span><span><span> </span><span>Props</span><span> </span></span><span>{</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>class</span><span>?:</span><span> </span><span>string</span><span>;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>style</span><span>?:</span><span> </span><span>string</span><span>;</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div><div><div><div>8</div></div><div><span>const</span><span> </span><span>{</span><span><span> </span><span>class</span></span><span>:</span><span><span> </span><span>className</span></span><span>,</span><span><span> </span><span>style</span><span> </span></span><span>}</span><span> </span><span>=</span><span> Astro</span><span>.</span><span>props</span><span>;</span></div></div><div><div><div>9</div></div><div><span>---</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>&lt;</span><span>WidgetLayout</span><span> </span><span>name</span><span>=</span><span>"</span><span>统计</span><span>"</span><span> </span><span>class:list</span><span><span>=</span><span>{</span><span>[</span></span><span>"</span><span>umami-stats-container</span><span>"</span><span>, </span><span>className</span><span>, </span><span>"</span><span>cursor-pointer transition-opacity active:scale-95</span><span>"</span><span><span>]</span><span>}</span><span> {</span></span><span>style</span><span>}&gt;</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>&lt;</span><span>a</span><span> </span><span>target</span><span>=</span><span>"</span><span>_blank</span><span>"</span><span> </span><span>rel</span><span>=</span><span>"</span><span>noopener noreferrer</span><span>"</span><span> </span><span>class</span><span>=</span><span>"</span><span>block umami-link</span><span>"</span><span>&gt;</span></div></div><div><div><div>13</div></div><div><span><span>        </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-center py-2</span><span>"</span><span>&gt;</span></div></div><div><div><div>14</div></div><div><span><span>            </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-3xl font-bold text-neutral-900 dark:text-neutral-100 umami-total-pageviews</span><span>"</span><span>&gt;</span><span>-</span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>15</div></div><div><span><span>            </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-sm text-neutral-500 dark:text-neutral-400</span><span>"</span><span>&gt;</span><span>总浏览量</span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>16</div></div><div><span><span>        </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>17</div></div><div><span><span>        </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>grid grid-cols-2 divide-x divide-neutral-200 dark:divide-neutral-700 text-center pt-2</span><span>"</span><span>&gt;</span></div></div><div><div><div>18</div></div><div><span><span>            </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>px-2</span><span>"</span><span>&gt;</span></div></div><div><div><div>19</div></div><div><span><span>                </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-xl font-bold text-neutral-900 dark:text-neutral-100 umami-total-visits</span><span>"</span><span>&gt;</span><span>-</span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>20</div></div><div><span><span>                </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-sm text-neutral-500 dark:text-neutral-400</span><span>"</span><span>&gt;</span><span>访问数</span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>21</div></div><div><span><span>            </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>22</div></div><div><span><span>            </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>px-2</span><span>"</span><span>&gt;</span></div></div><div><div><div>23</div></div><div><span><span>                </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-xl font-bold text-neutral-900 dark:text-neutral-100 umami-total-visitors</span><span>"</span><span>&gt;</span><span>-</span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>24</div></div><div><span><span>                </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>text-sm text-neutral-500 dark:text-neutral-400</span><span>"</span><span>&gt;</span><span>游客数</span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>25</div></div><div><span><span>            </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>26</div></div><div><span><span>        </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>27</div></div><div><span><span>    </span></span><span>&lt;/</span><span>a</span><span>&gt;</span></div></div><div><div><div>28</div></div><div><span>&lt;/</span><span>WidgetLayout</span><span>&gt;</span></div></div><div><div><div>29</div></div><div>
</div></div><div><div><div>30</div></div><div><span>&lt;</span><span>script</span><span>&gt;</span></div></div><div><div><div>31</div></div><div><span>const</span><span><span> </span><span>UMAMI_CONFIG</span><span> </span></span><span>=</span><span> </span><span>{</span></div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>shareUrl</span><span>:</span><span> </span><span>'</span><span>你的分享链接</span><span>'</span><span>,</span></div></div><div><div><div>33</div></div><div><span>};</span></div></div><div><div><div>34</div></div><div>
</div></div><div><div><div>35</div></div><div><span>let</span><span> __UMAMI_INTERNAL </span><span>=</span><span> </span><span>{</span></div></div><div><div><div>36</div></div><div><span><span>    </span></span><span>baseUrl</span><span>:</span><span> </span><span>''</span><span>,</span></div></div><div><div><div>37</div></div><div><span><span>    </span></span><span>websiteId</span><span>:</span><span> </span><span>''</span><span>,</span></div></div><div><div><div>38</div></div><div><span><span>    </span></span><span>shareToken</span><span>:</span><span> </span><span>''</span><span>,</span></div></div><div><div><div>39</div></div><div><span><span>    </span></span><span>shareId</span><span>:</span><span> </span><span>''</span><span>,</span></div></div><div><div><div>40</div></div><div><span><span>    </span></span><span>isReady</span><span>:</span><span> </span><span>false</span></div></div><div><div><div>41</div></div><div><span>};</span></div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div><span>const</span><span><span> </span><span>FALLBACK_STATS</span><span> </span></span><span>=</span><span> </span><span>{</span></div></div><div><div><div>44</div></div><div><span><span>    </span></span><span>pageviews</span><span>:</span><span> </span><span>1000</span><span>,</span></div></div><div><div><div>45</div></div><div><span><span>    </span></span><span>visits</span><span>:</span><span> </span><span>1000</span><span>,</span></div></div><div><div><div>46</div></div><div><span><span>    </span></span><span>visitors</span><span>:</span><span> </span><span>1000</span><span>,</span></div></div><div><div><div>47</div></div><div><span>};</span></div></div><div><div><div>48</div></div><div>
</div></div><div><div><div>49</div></div><div><span>async</span><span> </span><span>function</span><span> </span><span>initUmamiConfig</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>50</div></div><div><span>    </span><span>try</span><span> </span><span>{</span></div></div><div><div><div>51</div></div><div><span>        </span><span>const</span><span><span> </span><span>sharePath</span><span> </span></span><span>=</span><span><span> </span><span>UMAMI_CONFIG</span></span><span>.</span><span>shareUrl</span><span>.</span><span>split</span><span>(</span><span>'</span><span>/share/</span><span>'</span><span>)[</span><span>1</span><span>];</span></div></div><div><div><div>52</div></div><div><span>        </span><span>if</span><span> </span><span><span>(</span><span>!</span></span><span>sharePath</span><span>)</span><span> </span><span>throw</span><span> </span><span>new</span><span> </span><span>Error</span><span>(</span><span>'</span><span>Invalid Umami Share URL</span><span>'</span><span>);</span></div></div><div><div><div>53</div></div><div>
</div></div><div><div><div>54</div></div><div><span>        </span><span>let</span><span> apiBase </span><span>=</span><span> </span><span>''</span><span>;</span></div></div><div><div><div>55</div></div><div><span>        </span><span>if</span><span> </span><span>(</span><span>UMAMI_CONFIG</span><span>.</span><span>shareUrl</span><span>.</span><span>includes</span><span>(</span><span>'</span><span>cloud.umami.is</span><span>'</span><span>)</span><span> </span><span>||</span><span><span> </span><span>UMAMI_CONFIG</span></span><span>.</span><span>shareUrl</span><span>.</span><span>includes</span><span>(</span><span>'</span><span>analytics.umami.is</span><span>'</span><span>))</span><span> </span><span>{</span></div></div><div><div><div>56</div></div><div><span>            </span><span>const</span><span><span> </span><span>region</span><span> </span></span><span>=</span><span><span> </span><span>UMAMI_CONFIG</span></span><span>.</span><span>shareUrl</span><span>.</span><span>includes</span><span>(</span><span>'</span><span>/analytics/eu/</span><span>'</span><span>)</span><span> </span><span>?</span><span> </span><span>'</span><span>eu</span><span>'</span><span> </span><span>:</span><span> </span><span>'</span><span>us</span><span>'</span><span>;</span></div></div><div><div><div>57</div></div><div><span><span>            </span></span><span>apiBase </span><span>=</span><span> </span><span>`</span><span>https://cloud.umami.is/analytics/</span><span>${</span><span>region</span><span>}</span><span>/api</span><span>`</span><span>;</span></div></div><div><div><div>58</div></div><div><span><span>        </span></span><span>}</span><span> </span><span>else</span><span> </span><span>{</span></div></div><div><div><div>59</div></div><div><span>            </span><span>const</span><span><span> </span><span>urlObj</span><span> </span></span><span>=</span><span> </span><span>new</span><span> </span><span>URL</span><span>(</span><span>UMAMI_CONFIG</span><span>.</span><span>shareUrl</span><span>);</span></div></div><div><div><div>60</div></div><div><span><span>            </span></span><span>apiBase </span><span>=</span><span> </span><span>`${</span><span>urlObj</span><span>.</span><span>origin</span><span>}</span><span>/api</span><span>`</span><span>;</span></div></div><div><div><div>61</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>62</div></div><div>
</div></div><div><div><div>63</div></div><div><span>        </span><span>const</span><span><span> </span><span>res</span><span> </span></span><span>=</span><span> </span><span>await</span><span> </span><span>fetch</span><span>(</span><span>`${</span><span>apiBase</span><span>}</span><span>/share/</span><span>${</span><span>sharePath</span><span>}`</span><span>);</span></div></div><div><div><div>64</div></div><div><span>        </span><span>if</span><span> </span><span><span>(</span><span>!</span></span><span>res</span><span>.</span><span>ok</span><span>)</span><span> </span><span>throw</span><span> </span><span>new</span><span> </span><span>Error</span><span>(</span><span>`</span><span>Failed to fetch share config: </span><span>${</span><span>res</span><span>.</span><span>status</span><span>}`</span><span>);</span></div></div><div><div><div>65</div></div><div><span>        </span><span>const</span><span><span> </span><span>data</span><span> </span></span><span>=</span><span> </span><span>await</span><span> res</span><span>.</span><span>json</span><span>();</span></div></div><div><div><div>66</div></div><div>
</div></div><div><div><div>67</div></div><div><span><span>        </span></span><span>__UMAMI_INTERNAL </span><span>=</span><span> </span><span>{</span></div></div><div><div><div>68</div></div><div><span><span>            </span></span><span>baseUrl</span><span>:</span><span> apiBase</span><span>,</span></div></div><div><div><div>69</div></div><div><span><span>            </span></span><span>websiteId</span><span>:</span><span> data</span><span>.</span><span>websiteId</span><span>,</span></div></div><div><div><div>70</div></div><div><span><span>            </span></span><span>shareToken</span><span>:</span><span> data</span><span>.</span><span>token</span><span>,</span></div></div><div><div><div>71</div></div><div><span><span>            </span></span><span>shareId</span><span>:</span><span> data</span><span>.</span><span>shareId</span><span>,</span></div></div><div><div><div>72</div></div><div><span><span>            </span></span><span>isReady</span><span>:</span><span> </span><span>true</span></div></div><div><div><div>73</div></div><div><span><span>        </span></span><span>};</span></div></div><div><div><div>74</div></div><div>
</div></div><div><div><div>75</div></div><div><span>        </span><span>const</span><span><span> </span><span>links</span><span> </span></span><span>=</span><span> document</span><span>.</span><span>querySelectorAll</span><span>(</span><span>'</span><span>.umami-link</span><span>'</span><span>);</span></div></div><div><div><div>76</div></div><div><span><span>        </span></span><span>links</span><span>.</span><span>forEach</span><span>(</span><span><span>link</span><span> </span></span><span>=&gt;</span><span> link</span><span>.</span><span>setAttribute</span><span>(</span><span>'</span><span>href</span><span>'</span><span>,</span><span><span> </span><span>UMAMI_CONFIG</span></span><span>.</span><span>shareUrl</span><span>));</span></div></div><div><div><div>77</div></div><div>
</div></div><div><div><div>78</div></div><div><span><span>    </span></span><span>}</span><span> </span><span>catch</span><span> </span><span>(</span><span>e</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>79</div></div><div><span><span>        </span></span><span>console</span><span>.</span><span>error</span><span>(</span><span>'</span><span>Umami Config Init Failed:</span><span>'</span><span>,</span><span> e</span><span>);</span></div></div><div><div><div>80</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>81</div></div><div><span>}</span></div></div><div><div><div>82</div></div><div>
</div></div><div><div><div>83</div></div><div><span>function</span><span> </span><span>formatNumber</span><span>(</span><span>num</span><span>:</span><span> </span><span>number</span><span><span>)</span><span>:</span></span><span> </span><span>string</span><span> </span><span>{</span></div></div><div><div><div>84</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>num </span><span>&gt;=</span><span> </span><span>1000000</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>85</div></div><div><span>        </span><span>return</span><span> </span><span>(</span><span>num </span><span>/</span><span> </span><span>1000000</span><span>).</span><span>toFixed</span><span>(</span><span>1</span><span>)</span><span> </span><span>+</span><span> </span><span>'</span><span>M</span><span>'</span><span>;</span></div></div><div><div><div>86</div></div><div><span><span>    </span></span><span>}</span><span> </span><span>else</span><span> </span><span>if</span><span> </span><span>(</span><span>num </span><span>&gt;=</span><span> </span><span>1000</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>87</div></div><div><span>        </span><span>return</span><span> </span><span>(</span><span>num </span><span>/</span><span> </span><span>1000</span><span>).</span><span>toFixed</span><span>(</span><span>1</span><span>)</span><span> </span><span>+</span><span> </span><span>'</span><span>K</span><span>'</span><span>;</span></div></div><div><div><div>88</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>89</div></div><div><span>    </span><span>return</span><span> Math</span><span>.</span><span>round</span><span>(</span><span>num</span><span>).</span><span>toString</span><span>();</span></div></div><div><div><div>90</div></div><div><span>}</span></div></div><div><div><div>91</div></div><div>
</div></div><div><div><div>92</div></div><div><span>function</span><span> </span><span>setStats</span><span>(</span><span>values</span><span>:</span><span> </span><span>{</span><span><span> </span><span>pageviews</span></span><span>:</span><span> </span><span>number</span><span>;</span><span><span> </span><span>visits</span></span><span>:</span><span> </span><span>number</span><span>;</span><span><span> </span><span>visitors</span></span><span>:</span><span> </span><span>number</span><span> </span><span>})</span><span> </span><span>{</span></div></div><div><div><div>93</div></div><div><span>    </span><span>const</span><span><span> </span><span>pageviewsElements</span><span> </span></span><span>=</span><span> document</span><span>.</span><span>querySelectorAll</span><span>(</span><span>'</span><span>.umami-total-pageviews</span><span>'</span><span>);</span></div></div><div><div><div>94</div></div><div><span>    </span><span>const</span><span><span> </span><span>visitsElements</span><span> </span></span><span>=</span><span> document</span><span>.</span><span>querySelectorAll</span><span>(</span><span>'</span><span>.umami-total-visits</span><span>'</span><span>);</span></div></div><div><div><div>95</div></div><div><span>    </span><span>const</span><span><span> </span><span>visitorsElements</span><span> </span></span><span>=</span><span> document</span><span>.</span><span>querySelectorAll</span><span>(</span><span>'</span><span>.umami-total-visitors</span><span>'</span><span>);</span></div></div><div><div><div>96</div></div><div>
</div></div><div><div><div>97</div></div><div><span>    </span><span>const</span><span> </span><span>easeOutCubic</span><span> </span><span>=</span><span> </span><span>(</span><span>t</span><span>:</span><span> </span><span>number</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>1</span><span> </span><span>-</span><span> Math</span><span>.</span><span>pow</span><span>(</span><span>1</span><span> </span><span>-</span><span> t</span><span>,</span><span> </span><span>3</span><span>);</span></div></div><div><div><div>98</div></div><div><span>    </span><span>const</span><span><span> </span><span>animHandles</span><span> </span></span><span>=</span><span> </span><span>new</span><span> </span><span>Map</span><span>&lt;</span><span>HTMLElement</span><span>,</span><span> </span><span>number</span><span>&gt;();</span></div></div><div><div><div>99</div></div><div>
</div></div><div><div><div>100</div></div><div><span>    </span><span>const</span><span> </span><span>animateStat</span><span> </span><span>=</span><span> </span><span>(</span><span>el</span><span>:</span><span> </span><span>HTMLElement</span><span> </span><span>|</span><span> </span><span>null</span><span>,</span><span><span> </span><span>to</span></span><span>:</span><span> </span><span>number</span><span>,</span><span><span> </span><span>duration</span><span> </span></span><span>=</span><span> </span><span>2000</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>101</div></div><div><span>        </span><span>if</span><span> </span><span><span>(</span><span>!</span></span><span>el</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>102</div></div><div>
</div></div><div><div><div>103</div></div><div><span>        </span><span>const</span><span><span> </span><span>prev</span><span> </span></span><span>=</span><span> animHandles</span><span>.</span><span>get</span><span>(</span><span>el</span><span>);</span></div></div><div><div><div>104</div></div><div><span>        </span><span>if</span><span> </span><span>(</span><span>prev</span><span>)</span><span> </span><span>cancelAnimationFrame</span><span>(</span><span>prev</span><span>);</span></div></div><div><div><div>105</div></div><div>
</div></div><div><div><div>106</div></div><div><span>        </span><span>const</span><span><span> </span><span>from</span><span> </span></span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>107</div></div><div><span>        </span><span>const</span><span><span> </span><span>startTime</span><span> </span></span><span>=</span><span> performance</span><span>.</span><span>now</span><span>();</span></div></div><div><div><div>108</div></div><div>
</div></div><div><div><div>109</div></div><div><span>        </span><span>const</span><span> </span><span>tick</span><span> </span><span>=</span><span> </span><span>(</span><span>now</span><span>:</span><span> </span><span>number</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>110</div></div><div><span>            </span><span>const</span><span><span> </span><span>elapsed</span><span> </span></span><span>=</span><span> now </span><span>-</span><span> startTime</span><span>;</span></div></div><div><div><div>111</div></div><div><span>            </span><span>const</span><span><span> </span><span>progress</span><span> </span></span><span>=</span><span> Math</span><span>.</span><span>min</span><span>(</span><span>1</span><span>,</span><span> elapsed </span><span>/</span><span> duration</span><span>);</span></div></div><div><div><div>112</div></div><div><span>            </span><span>const</span><span><span> </span><span>easedProgress</span><span> </span></span><span>=</span><span> </span><span>easeOutCubic</span><span>(</span><span>progress</span><span>);</span></div></div><div><div><div>113</div></div><div>
</div></div><div><div><div>114</div></div><div><span>            </span><span>const</span><span><span> </span><span>current</span><span> </span></span><span>=</span><span> from </span><span>+</span><span> </span><span>(</span><span>to </span><span>-</span><span> from</span><span>)</span><span> </span><span>*</span><span> easedProgress</span><span>;</span></div></div><div><div><div>115</div></div><div><span><span>            </span></span><span>el</span><span>.</span><span>textContent </span><span>=</span><span> </span><span>formatNumber</span><span>(</span><span>current</span><span>);</span></div></div><div><div><div>116</div></div><div>
</div></div><div><div><div>117</div></div><div><span>            </span><span>if</span><span> </span><span>(</span><span>progress </span><span>&lt;</span><span> </span><span>1</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>118</div></div><div><span><span>                </span></span><span>animHandles</span><span>.</span><span>set</span><span>(</span><span>el</span><span>,</span><span> </span><span>requestAnimationFrame</span><span>(</span><span>tick</span><span>));</span></div></div><div><div><div>119</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>120</div></div><div><span><span>        </span></span><span>};</span></div></div><div><div><div>121</div></div><div><span><span>        </span></span><span>animHandles</span><span>.</span><span>set</span><span>(</span><span>el</span><span>,</span><span> </span><span>requestAnimationFrame</span><span>(</span><span>tick</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>
</div></div><div><div><div>124</div></div><div><span><span>    </span></span><span>pageviewsElements</span><span>.</span><span>forEach</span><span>(</span><span><span>el</span><span> </span></span><span>=&gt;</span><span> </span><span>animateStat</span><span>(</span><span>el </span><span>as</span><span><span> </span><span>HTMLElement</span></span><span>,</span><span> values</span><span>.</span><span>pageviews</span><span>));</span></div></div><div><div><div>125</div></div><div><span><span>    </span></span><span>visitsElements</span><span>.</span><span>forEach</span><span>(</span><span><span>el</span><span> </span></span><span>=&gt;</span><span> </span><span>animateStat</span><span>(</span><span>el </span><span>as</span><span><span> </span><span>HTMLElement</span></span><span>,</span><span> values</span><span>.</span><span>visits</span><span>));</span></div></div><div><div><div>126</div></div><div><span><span>    </span></span><span>visitorsElements</span><span>.</span><span>forEach</span><span>(</span><span><span>el</span><span> </span></span><span>=&gt;</span><span> </span><span>animateStat</span><span>(</span><span>el </span><span>as</span><span><span> </span><span>HTMLElement</span></span><span>,</span><span> values</span><span>.</span><span>visitors</span><span>));</span></div></div><div><div><div>127</div></div><div><span>}</span></div></div><div><div><div>128</div></div><div>
</div></div><div><div><div>129</div></div><div><span>async</span><span> </span><span>function</span><span> </span><span>fetchUmamiStats</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>130</div></div><div><span>    </span><span>if</span><span> </span><span><span>(</span><span>!</span></span><span>__UMAMI_INTERNAL</span><span>.</span><span>isReady</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>131</div></div><div><span>        </span><span>await</span><span> </span><span>initUmamiConfig</span><span>();</span></div></div><div><div><div>132</div></div><div><span><span>    </span></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>!</span></span><span>__UMAMI_INTERNAL</span><span>.</span><span>isReady</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>135</div></div><div><span>        </span><span>setStats</span><span>(</span><span>FALLBACK_STATS</span><span>);</span></div></div><div><div><div>136</div></div><div><span>        </span><span>return</span><span>;</span></div></div><div><div><div>137</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>138</div></div><div>
</div></div><div><div><div>139</div></div><div><span>    </span><span>try</span><span> </span><span>{</span></div></div><div><div><div>140</div></div><div><span>        </span><span>const</span><span><span> </span><span>endAt</span><span> </span></span><span>=</span><span> Date</span><span>.</span><span>now</span><span>();</span></div></div><div><div><div>141</div></div><div><span>        </span><span>const</span><span><span> </span><span>startAt</span><span> </span></span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>142</div></div><div><span>        </span><span>const</span><span><span> </span><span>url</span><span> </span></span><span>=</span><span> </span><span>`${</span><span>__UMAMI_INTERNAL</span><span>.</span><span>baseUrl</span><span>}</span><span>/websites/</span><span>${</span><span>__UMAMI_INTERNAL</span><span>.</span><span>websiteId</span><span>}</span><span>/stats?startAt=</span><span>${</span><span>startAt</span><span>}</span><span>&amp;endAt=</span><span>${</span><span>endAt</span><span>}</span><span>&amp;unit=hour&amp;timezone=Asia%2FShanghai</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>const</span><span><span> </span><span>response</span><span> </span></span><span>=</span><span> </span><span>await</span><span> </span><span>fetch</span><span>(</span><span>url</span><span>,</span><span> </span><span>{</span></div></div><div><div><div>145</div></div><div><span><span>            </span></span><span>headers</span><span>:</span><span> </span><span>{</span></div></div><div><div><div>146</div></div><div><span>                </span><span>'</span><span>x-umami-share-context</span><span>'</span><span>:</span><span> </span><span>'</span><span>1</span><span>'</span><span>,</span></div></div><div><div><div>147</div></div><div><span>                </span><span>'</span><span>x-umami-share-token</span><span>'</span><span>:</span><span> __UMAMI_INTERNAL</span><span>.</span><span>shareToken</span></div></div><div><div><div>148</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>149</div></div><div><span><span>        </span></span><span>});</span></div></div><div><div><div>150</div></div><div>
</div></div><div><div><div>151</div></div><div><span>        </span><span>if</span><span> </span><span><span>(</span><span>!</span></span><span>response</span><span>.</span><span>ok</span><span>)</span><span> </span><span>throw</span><span> </span><span>new</span><span> </span><span>Error</span><span>(</span><span>`</span><span>HTTP </span><span>${</span><span>response</span><span>.</span><span>status</span><span>}`</span><span>);</span></div></div><div><div><div>152</div></div><div><span>        </span><span>const</span><span><span> </span><span>data</span><span> </span></span><span>=</span><span> </span><span>await</span><span> response</span><span>.</span><span>json</span><span>();</span></div></div><div><div><div>153</div></div><div><span>        </span><span>const</span><span> </span><span>getValue</span><span> </span><span>=</span><span> </span><span>(</span><span>field</span><span>:</span><span> </span><span>any</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span><span>(</span><span>typeof</span></span><span> field </span><span>===</span><span> </span><span>'</span><span>object</span><span>'</span><span> </span><span>?</span><span> field</span><span>?.</span><span>value </span><span>:</span><span> field</span><span>)</span><span> </span><span>||</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>154</div></div><div>
</div></div><div><div><div>155</div></div><div><span>        </span><span>setStats</span><span>({</span></div></div><div><div><div>156</div></div><div><span><span>            </span></span><span>pageviews</span><span>:</span><span> </span><span>getValue</span><span>(</span><span>data</span><span>.</span><span>pageviews</span><span>),</span></div></div><div><div><div>157</div></div><div><span><span>            </span></span><span>visits</span><span>:</span><span> </span><span>getValue</span><span>(</span><span>data</span><span>.</span><span>visits</span><span>),</span></div></div><div><div><div>158</div></div><div><span><span>            </span></span><span>visitors</span><span>:</span><span> </span><span>getValue</span><span>(</span><span>data</span><span>.</span><span>visitors</span><span>),</span></div></div><div><div><div>159</div></div><div><span><span>        </span></span><span>});</span></div></div><div><div><div>160</div></div><div>
</div></div><div><div><div>161</div></div><div><span><span>    </span></span><span>}</span><span> </span><span>catch</span><span> </span><span>(</span><span>error</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>162</div></div><div><span><span>        </span></span><span>console</span><span>.</span><span>error</span><span>(</span><span>'</span><span>Umami Fetch Failed:</span><span>'</span><span>,</span><span> error</span><span>);</span></div></div><div><div><div>163</div></div><div><span>        </span><span>setStats</span><span>(</span><span>FALLBACK_STATS</span><span>);</span></div></div><div><div><div>164</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>165</div></div><div><span>}</span></div></div><div><div><div>166</div></div><div>
</div></div><div><div><div>167</div></div><div><span>let</span><span> __umamiStatsStarted </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>168</div></div><div><span>function</span><span> </span><span>startUmamiStats</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>169</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>__umamiStatsStarted</span><span>)</span><span> </span><span>return</span><span>;</span></div></div><div><div><div>170</div></div><div><span><span>    </span></span><span>__umamiStatsStarted </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>171</div></div><div><span>    </span><span>fetchUmamiStats</span><span>();</span></div></div><div><div><div>172</div></div><div><span>}</span></div></div><div><div><div>173</div></div><div>
</div></div><div><div><div>174</div></div><div><span>function</span><span> </span><span>initUmamiStatsVisibility</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>175</div></div><div><span>    </span><span>const</span><span><span> </span><span>containers</span><span> </span></span><span>=</span><span> document</span><span>.</span><span>querySelectorAll</span><span>(</span><span>'</span><span>.umami-stats-container</span><span>'</span><span>);</span></div></div><div><div><div>176</div></div><div><span>    </span><span>const</span><span><span> </span><span>io</span><span> </span></span><span>=</span><span> </span><span>new</span><span> </span><span>IntersectionObserver</span><span>((</span><span>entries</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>177</div></div><div><span>        </span><span>let</span><span> isAnyVisible </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>178</div></div><div><span><span>        </span></span><span>entries</span><span>.</span><span>forEach</span><span>(</span><span><span>entry</span><span> </span></span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>179</div></div><div><span>            </span><span>if</span><span> </span><span>(</span><span>entry</span><span>.</span><span>isIntersecting</span><span>)</span><span> isAnyVisible </span><span>=</span><span> </span><span>true</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>if</span><span> </span><span>(</span><span>isAnyVisible</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>183</div></div><div><span>            </span><span>startUmamiStats</span><span>();</span></div></div><div><div><div>184</div></div><div><span><span>            </span></span><span>io</span><span>.</span><span>disconnect</span><span>();</span></div></div><div><div><div>185</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>186</div></div><div><span><span>    </span></span><span>},</span><span> </span><span>{</span><span> </span><span>threshold</span><span>:</span><span> </span><span>0.1</span><span> </span><span>});</span></div></div><div><div><div>187</div></div><div>
</div></div><div><div><div>188</div></div><div><span><span>    </span></span><span>containers</span><span>.</span><span>forEach</span><span>(</span><span><span>container</span><span> </span></span><span>=&gt;</span><span> io</span><span>.</span><span>observe</span><span>(</span><span>container</span><span>));</span></div></div><div><div><div>189</div></div><div><span>}</span></div></div><div><div><div>190</div></div><div>
</div></div><div><div><div>191</div></div><div><span>initUmamiStatsVisibility</span><span>();</span></div></div><div><div><div>192</div></div><div>
</div></div><div><div><div>193</div></div><div><span>if</span><span> </span><span>(</span><span>window</span><span>.</span><span>swup</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>194</div></div><div><span><span>    </span></span><span>window</span><span>.</span><span>swup</span><span>.</span><span>hooks</span><span>.</span><span>on</span><span>(</span><span>'</span><span>page:view</span><span>'</span><span>,</span><span> </span><span>()</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>195</div></div><div><span><span>        </span></span><span>__umamiStatsStarted </span><span>=</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>196</div></div><div><span>        </span><span>initUmamiStatsVisibility</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><span>}</span></div></div><div><div><div>199</div></div><div><span>&lt;/</span><span>script</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section>
<section><h2>配置<a href="#配置"><span>#</span></a></h2><p>代码里有个 <code>UMAMI_CONFIG</code>，把你的分享链接填进去就行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>script</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span>const UMAMI_CONFIG = </span><span>{</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>shareUrl</span><span>: </span><span>'</span><span>你的分享链接</span><span>'</span><span>,</span><span>  </span><span>// ← 这里改成你自己的</span></div></div><div><div><div>4</div></div><div><span>}</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>挂到侧边栏上<a href="#挂到侧边栏上"><span>#</span></a></h2><p>组件写好了，接下来得让它出现在页面上。打开 <code>src/components/layout/SideBar.astro</code>，把 <code>UmamiStats</code> import 进来，然后在合适的位置放上 <code>&lt;UmamiStats /&gt;</code>。</p><p>下面是改完之后的完整代码，可以对照着改：</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>import</span><span> </span><span>type</span><span> </span><span>{</span><span> MarkdownHeading </span><span>}</span><span> </span><span>from</span><span> </span><span>"</span><span>astro</span><span>"</span><span>;</span></div></div><div><div><div>3</div></div><div><span>import</span><span> Advertisement </span><span>from</span><span> </span><span>"</span><span>@/components/widget/Advertisement.astro</span><span>"</span><span>;</span></div></div><div><div><div>4</div></div><div><span>import</span><span> Announcement </span><span>from</span><span> </span><span>"</span><span>@/components/widget/Announcement.astro</span><span>"</span><span>;</span></div></div><div><div><div>5</div></div><div><span>import</span><span> Calendar </span><span>from</span><span> </span><span>"</span><span>@/components/widget/Calendar.astro</span><span>"</span><span>;</span></div></div><div><div><div>6</div></div><div><span>import</span><span> Categories </span><span>from</span><span> </span><span>"</span><span>@/components/widget/Categories.astro</span><span>"</span><span>;</span></div></div><div><div><div>7</div></div><div><span>import</span><span> Music </span><span>from</span><span> </span><span>"</span><span>@/components/widget/Music.astro</span><span>"</span><span>;</span></div></div><div><div><div>8</div></div><div><span>import</span><span> Profile </span><span>from</span><span> </span><span>"</span><span>@/components/widget/Profile.astro</span><span>"</span><span>;</span></div></div><div><div><div>9</div></div><div><span>import</span><span> SidebarTOC </span><span>from</span><span> </span><span>"</span><span>@/components/widget/SidebarTOC.astro</span><span>"</span><span>;</span></div></div><div><div><div>10</div></div><div><span>import</span><span> SiteStats </span><span>from</span><span> </span><span>"</span><span>@/components/widget/SiteStats.astro</span><span>"</span><span>;</span></div></div><div><div><div>11</div></div><div><span>import</span><span> Tags </span><span>from</span><span> </span><span>"</span><span>@/components/widget/Tags.astro</span><span>"</span><span>;</span></div></div><div><div><div>12</div></div><div><span>import</span><span> </span><span>{</span><span> sidebarLayoutConfig </span><span>}</span><span> </span><span>from</span><span> </span><span>"</span><span>@/config</span><span>"</span><span>;</span></div></div><div><div><div>13</div></div><div><span>import</span><span> </span><span>type</span><span> </span><span>{</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>MobileBottomComponentConfig</span><span>,</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>WidgetComponentConfig</span><span>,</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>WidgetComponentType</span><span>,</span></div></div><div><div><div>17</div></div><div><span>}</span><span> </span><span>from</span><span> </span><span>"</span><span>@/types/config</span><span>"</span><span>;</span></div></div><div><div><div>18</div></div><div><span>import</span><span> UmamiStats </span><span>from</span><span> </span><span>"</span><span>../widget/UmamiStats.astro</span><span>"</span><span>;</span></div></div><div><div><div>19</div></div><div>
</div></div><div><div><div>20</div></div><div><span>interface</span><span><span> </span><span>Props</span><span> </span></span><span>{</span></div></div><div><div><div>21</div></div><div><span><span>  </span></span><span>class</span><span>?:</span><span> </span><span>string</span><span>;</span></div></div><div><div><div>22</div></div><div><span><span>  </span></span><span>headings</span><span>?:</span><span> </span><span>MarkdownHeading</span><span>[];</span></div></div><div><div><div>23</div></div><div><span><span>  </span></span><span>side</span><span>?:</span><span> </span><span>"</span><span>left</span><span>"</span><span> </span><span>|</span><span> </span><span>"</span><span>right</span><span>"</span><span> </span><span>|</span><span> </span><span>"</span><span>bottom</span><span>"</span><span>;</span></div></div><div><div><div>24</div></div><div><span>}</span></div></div><div><div><div>25</div></div><div>
</div></div><div><div><div>26</div></div><div><span>const</span><span><span> </span><span>SIDEBAR_SIDE</span><span> </span></span><span>=</span><span> </span><span>{</span></div></div><div><div><div>27</div></div><div><span><span>  </span></span><span>LEFT</span><span>:</span><span> </span><span>"</span><span>left</span><span>"</span><span>,</span></div></div><div><div><div>28</div></div><div><span><span>  </span></span><span>RIGHT</span><span>:</span><span> </span><span>"</span><span>right</span><span>"</span><span>,</span></div></div><div><div><div>29</div></div><div><span><span>  </span></span><span>BOTTOM</span><span>:</span><span> </span><span>"</span><span>bottom</span><span>"</span><span>,</span></div></div><div><div><div>30</div></div><div><span>}</span><span> </span><span>as</span><span> </span><span>const</span><span>;</span></div></div><div><div><div>31</div></div><div>
</div></div><div><div><div>32</div></div><div><span>const</span><span><span> </span><span>COMPONENT_POSITION</span><span> </span></span><span>=</span><span> </span><span>{</span></div></div><div><div><div>33</div></div><div><span><span>  </span></span><span>TOP</span><span>:</span><span> </span><span>"</span><span>top</span><span>"</span><span>,</span></div></div><div><div><div>34</div></div><div><span><span>  </span></span><span>STICKY</span><span>:</span><span> </span><span>"</span><span>sticky</span><span>"</span><span>,</span></div></div><div><div><div>35</div></div><div><span>}</span><span> </span><span>as</span><span> </span><span>const</span><span>;</span></div></div><div><div><div>36</div></div><div>
</div></div><div><div><div>37</div></div><div><span>const</span><span><span> </span><span>ANIMATION_DELAY_UNIT</span><span> </span></span><span>=</span><span> </span><span>50</span><span>;</span></div></div><div><div><div>38</div></div><div>
</div></div><div><div><div>39</div></div><div><span>const</span><span><span> </span><span>componentMap</span><span> </span></span><span>=</span><span> </span><span>{</span></div></div><div><div><div>40</div></div><div><span><span>  </span></span><span>profile</span><span>:</span><span> Profile</span><span>,</span></div></div><div><div><div>41</div></div><div><span><span>  </span></span><span>announcement</span><span>:</span><span> Announcement</span><span>,</span></div></div><div><div><div>42</div></div><div><span><span>  </span></span><span>categories</span><span>:</span><span> Categories</span><span>,</span></div></div><div><div><div>43</div></div><div><span><span>  </span></span><span>tags</span><span>:</span><span> Tags</span><span>,</span></div></div><div><div><div>44</div></div><div><span><span>  </span></span><span>sidebarToc</span><span>:</span><span> SidebarTOC</span><span>,</span></div></div><div><div><div>45</div></div><div><span><span>  </span></span><span>advertisement</span><span>:</span><span> Advertisement</span><span>,</span></div></div><div><div><div>46</div></div><div><span><span>  </span></span><span>stats</span><span>:</span><span> SiteStats</span><span>,</span></div></div><div><div><div>47</div></div><div><span><span>  </span></span><span>calendar</span><span>:</span><span> Calendar</span><span>,</span></div></div><div><div><div>48</div></div><div><span><span>  </span></span><span>music</span><span>:</span><span> Music</span><span>,</span></div></div><div><div><div>49</div></div><div><span>}</span><span> </span><span>satisfies</span><span><span> </span><span>Record</span></span><span>&lt;</span><span>WidgetComponentType</span><span>,</span><span> </span><span>typeof</span><span> Profile</span><span>&gt;;</span></div></div><div><div><div>50</div></div><div>
</div></div><div><div><div>51</div></div><div><span>const</span><span><span> </span><span>side</span><span> </span></span><span>=</span><span> </span><span>(</span><span>Astro</span><span>.</span><span>props</span><span>.</span><span>side </span><span>||</span></div></div><div><div><div>52</div></div><div><span><span>  </span></span><span>SIDEBAR_SIDE</span><span>.</span><span>LEFT</span><span>)</span><span> </span><span>as</span><span> </span><span><span>(</span><span>typeof</span></span><span><span> </span><span>SIDEBAR_SIDE</span></span><span><span>)[</span><span>keyof</span></span><span> </span><span>typeof</span><span><span> </span><span>SIDEBAR_SIDE</span></span><span>];</span></div></div><div><div><div>53</div></div><div><span>const</span><span><span> </span><span>className</span><span> </span></span><span>=</span><span> Astro</span><span>.</span><span>props</span><span>.</span><span>class</span><span>;</span></div></div><div><div><div>54</div></div><div>
</div></div><div><div><div>55</div></div><div><span>const</span><span> </span><span>getComponents</span><span> </span><span>=</span><span> </span><span><span>()</span><span>:</span></span><span> </span><span>(</span></div></div><div><div><div>56</div></div><div><span>  </span><span>|</span><span><span> </span><span>WidgetComponentConfig</span></span></div></div><div><div><div>57</div></div><div><span>  </span><span>|</span><span><span> </span><span>MobileBottomComponentConfig</span></span></div></div><div><div><div>58</div></div><div><span>)[]</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>59</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>side </span><span>===</span><span><span> </span><span>SIDEBAR_SIDE</span></span><span>.</span><span>LEFT</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>60</div></div><div><span>    </span><span>return</span><span> sidebarLayoutConfig</span><span>.</span><span>leftComponents</span><span>;</span></div></div><div><div><div>61</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>62</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>side </span><span>===</span><span><span> </span><span>SIDEBAR_SIDE</span></span><span>.</span><span>RIGHT</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>63</div></div><div><span>    </span><span>return</span><span> sidebarLayoutConfig</span><span>.</span><span>rightComponents</span><span>;</span></div></div><div><div><div>64</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>65</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>side </span><span>===</span><span><span> </span><span>SIDEBAR_SIDE</span></span><span>.</span><span>BOTTOM</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>66</div></div><div><span>    </span><span>return</span><span> sidebarLayoutConfig</span><span>.</span><span>mobileBottomComponents</span><span>;</span></div></div><div><div><div>67</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>68</div></div><div><span>  </span><span>return</span><span> </span><span>[];</span></div></div><div><div><div>69</div></div><div><span>};</span></div></div><div><div><div>70</div></div><div>
</div></div><div><div><div>71</div></div><div><span>const</span><span> </span><span>filterAndSortComponents</span><span> </span><span>=</span><span> </span><span>(</span></div></div><div><div><div>72</div></div><div><span><span>  </span></span><span>components</span><span>:</span><span> </span><span>(</span><span>WidgetComponentConfig</span><span> </span><span>|</span><span> </span><span>MobileBottomComponentConfig</span><span>)[],</span></div></div><div><div><div>73</div></div><div><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>74</div></div><div><span>  </span><span>return</span><span> components</span><span>.</span><span>filter</span><span>((</span><span>comp</span><span>)</span><span> </span><span>=&gt;</span><span> comp</span><span>.</span><span>enable</span><span>);</span></div></div><div><div><div>75</div></div><div><span>};</span></div></div><div><div><div>76</div></div><div>
</div></div><div><div><div>77</div></div><div><span>const</span><span> </span><span>getComponentsByPosition</span><span> </span><span>=</span><span> </span><span>(</span></div></div><div><div><div>78</div></div><div><span><span>  </span></span><span>components</span><span>:</span><span> </span><span>(</span><span>WidgetComponentConfig</span><span> </span><span>|</span><span> </span><span>MobileBottomComponentConfig</span><span>)[],</span></div></div><div><div><div>79</div></div><div><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>80</div></div><div><span>  </span><span>const</span><span><span> </span><span>topComponents</span><span> </span></span><span>=</span><span> components</span><span>.</span><span>filter</span><span>(</span></div></div><div><div><div>81</div></div><div><span><span>    </span></span><span>(</span><span>c</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>"</span><span>position</span><span>"</span><span> </span><span>in</span><span> c </span><span>&amp;&amp;</span><span> c</span><span>.</span><span>position </span><span>===</span><span><span> </span><span>COMPONENT_POSITION</span></span><span>.</span><span>TOP</span><span>,</span></div></div><div><div><div>82</div></div><div><span><span>  </span></span><span>)</span><span> </span><span>as</span><span><span> </span><span>WidgetComponentConfig</span></span><span>[];</span></div></div><div><div><div>83</div></div><div><span>  </span><span>const</span><span><span> </span><span>stickyComponents</span><span> </span></span><span>=</span><span> components</span><span>.</span><span>filter</span><span>(</span></div></div><div><div><div>84</div></div><div><span><span>    </span></span><span>(</span><span>c</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>"</span><span>position</span><span>"</span><span> </span><span>in</span><span> c </span><span>&amp;&amp;</span><span> c</span><span>.</span><span>position </span><span>===</span><span><span> </span><span>COMPONENT_POSITION</span></span><span>.</span><span>STICKY</span><span>,</span></div></div><div><div><div>85</div></div><div><span><span>  </span></span><span>)</span><span> </span><span>as</span><span><span> </span><span>WidgetComponentConfig</span></span><span>[];</span></div></div><div><div><div>86</div></div><div><span>  </span><span>return</span><span> </span><span>{</span><span> topComponents</span><span>,</span><span> stickyComponents </span><span>};</span></div></div><div><div><div>87</div></div><div><span>};</span></div></div><div><div><div>88</div></div><div>
</div></div><div><div><div>89</div></div><div><span>const</span><span> </span><span>getAnimationDelay</span><span> </span><span>=</span><span> </span><span>(</span><span>index</span><span>:</span><span> </span><span>number</span><span><span>)</span><span>:</span></span><span> </span><span>string</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>90</div></div><div><span>  </span><span>return</span><span> </span><span>`${</span><span>index</span><span> </span><span>*</span><span> </span><span>ANIMATION_DELAY_UNIT</span><span>}</span><span>ms</span><span>`</span><span>;</span></div></div><div><div><div>91</div></div><div><span>};</span></div></div><div><div><div>92</div></div><div>
</div></div><div><div><div>93</div></div><div><span>const</span><span><span> </span><span>isPostPage</span><span> </span></span><span>=</span><span> Astro</span><span>.</span><span>url</span><span>.</span><span>pathname</span><span>.</span><span>includes</span><span>(</span><span>"</span><span>/posts/</span><span>"</span><span>);</span></div></div><div><div><div>94</div></div><div>
</div></div><div><div><div>95</div></div><div><span>const</span><span> </span><span>isComponentInitiallyVisible</span><span> </span><span>=</span><span> </span><span>(</span></div></div><div><div><div>96</div></div><div><span><span>  </span></span><span>config</span><span>:</span><span> </span><span>WidgetComponentConfig</span><span> </span><span>|</span><span> </span><span>MobileBottomComponentConfig</span><span>,</span></div></div><div><div><div>97</div></div><div><span><span>)</span><span>:</span></span><span> </span><span>boolean</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>98</div></div><div><span>  </span><span>if</span><span> </span><span>(</span></div></div><div><div><div>99</div></div><div><span>    </span><span>"</span><span>showOnPostPage</span><span>"</span><span> </span><span>in</span><span> config </span><span>&amp;&amp;</span></div></div><div><div><div>100</div></div><div><span><span>    </span></span><span>config</span><span>.</span><span>showOnPostPage </span><span>===</span><span> </span><span>false</span><span> </span><span>&amp;&amp;</span></div></div><div><div><div>101</div></div><div><span><span>    </span></span><span>isPostPage</span></div></div><div><div><div>102</div></div><div><span><span>  </span></span><span>)</span><span> </span><span>{</span></div></div><div><div><div>103</div></div><div><span>    </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>104</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>105</div></div><div><span>  </span><span>if</span><span> </span><span>(</span></div></div><div><div><div>106</div></div><div><span>    </span><span>"</span><span>showOnNonPostPage</span><span>"</span><span> </span><span>in</span><span> config </span><span>&amp;&amp;</span></div></div><div><div><div>107</div></div><div><span><span>    </span></span><span>config</span><span>.</span><span>showOnNonPostPage </span><span>===</span><span> </span><span>false</span><span> </span><span>&amp;&amp;</span></div></div><div><div><div>108</div></div><div><span>    </span><span>!</span><span>isPostPage</span></div></div><div><div><div>109</div></div><div><span><span>  </span></span><span>)</span><span> </span><span>{</span></div></div><div><div><div>110</div></div><div><span>    </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>111</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>112</div></div><div><span>  </span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>113</div></div><div><span>};</span></div></div><div><div><div>114</div></div><div>
</div></div><div><div><div>115</div></div><div><span>const</span><span> </span><span>getComponentProps</span><span> </span><span>=</span><span> </span><span>(</span></div></div><div><div><div>116</div></div><div><span><span>  </span></span><span>config</span><span>:</span><span> </span><span>WidgetComponentConfig</span><span> </span><span>|</span><span> </span><span>MobileBottomComponentConfig</span><span>,</span></div></div><div><div><div>117</div></div><div><span><span>  </span></span><span>index</span><span>:</span><span> </span><span>number</span><span>,</span></div></div><div><div><div>118</div></div><div><span><span>)</span><span>:</span></span><span><span> </span><span>Record</span></span><span>&lt;</span><span>string</span><span>,</span><span> </span><span>unknown</span><span>&gt;</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>119</div></div><div><span>  </span><span>const</span><span><span> </span><span>baseProps</span></span><span>:</span><span> </span><span>Record</span><span>&lt;</span><span>string</span><span>,</span><span> </span><span>unknown</span><span>&gt;</span><span> </span><span>=</span><span> </span><span>{</span></div></div><div><div><div>120</div></div><div><span><span>    </span></span><span>class</span><span>:</span><span> </span><span>"</span><span>onload-animation</span><span>"</span><span>,</span></div></div><div><div><div>121</div></div><div><span><span>    </span></span><span>style</span><span>:</span><span> </span><span>`</span><span>animation-delay: </span><span>${</span><span>getAnimationDelay</span><span>(</span><span>index</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>
</div></div><div><div><div>124</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>"</span><span>showOnPostPage</span><span>"</span><span> </span><span>in</span><span> config </span><span>&amp;&amp;</span><span> config</span><span>.</span><span>showOnPostPage </span><span>===</span><span> </span><span>false</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>125</div></div><div><span><span>    </span></span><span>baseProps</span><span>.</span><span>class </span><span>=</span><span> </span><span>`${</span><span>baseProps</span><span>.</span><span>class</span><span>}</span><span> widget-hide-on-post</span><span>`</span><span>;</span></div></div><div><div><div>126</div></div><div><span>    </span><span>if</span><span> </span><span>(</span><span>isPostPage</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>127</div></div><div><span><span>      </span></span><span>baseProps</span><span>.</span><span>class </span><span>=</span><span> </span><span>`${</span><span>baseProps</span><span>.</span><span>class</span><span>}</span><span> hidden</span><span>`</span><span>;</span></div></div><div><div><div>128</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>129</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>130</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>"</span><span>showOnNonPostPage</span><span>"</span><span> </span><span>in</span><span> config </span><span>&amp;&amp;</span><span> config</span><span>.</span><span>showOnNonPostPage </span><span>===</span><span> </span><span>false</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>131</div></div><div><span><span>    </span></span><span>baseProps</span><span>.</span><span>class </span><span>=</span><span> </span><span>`${</span><span>baseProps</span><span>.</span><span>class</span><span>}</span><span> widget-hide-on-non-post</span><span>`</span><span>;</span></div></div><div><div><div>132</div></div><div><span>    </span><span>if</span><span> </span><span><span>(</span><span>!</span></span><span>isPostPage</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>133</div></div><div><span><span>      </span></span><span>baseProps</span><span>.</span><span>class </span><span>=</span><span> </span><span>`${</span><span>baseProps</span><span>.</span><span>class</span><span>}</span><span> hidden</span><span>`</span><span>;</span></div></div><div><div><div>134</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>135</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>136</div></div><div>
</div></div><div><div><div>137</div></div><div><span>  </span><span>if</span><span> </span><span>(</span><span>config</span><span>.</span><span>type </span><span>===</span><span> </span><span>"</span><span>sidebarToc</span><span>"</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>138</div></div><div><span>    </span><span>return</span><span> </span><span>{</span><span> </span><span>...</span><span>baseProps</span><span>,</span><span> </span><span>headings</span><span>:</span><span> Astro</span><span>.</span><span>props</span><span>.</span><span>headings </span><span>||</span><span> </span><span>[]</span><span> </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>
</div></div><div><div><div>141</div></div><div><span>  </span><span>if</span><span> </span><span>(</span></div></div><div><div><div>142</div></div><div><span><span>    </span></span><span>config</span><span>.</span><span>type </span><span>===</span><span> </span><span>"</span><span>advertisement</span><span>"</span><span> </span><span>&amp;&amp;</span></div></div><div><div><div>143</div></div><div><span>    </span><span>"</span><span>configId</span><span>"</span><span> </span><span>in</span><span> config </span><span>&amp;&amp;</span></div></div><div><div><div>144</div></div><div><span><span>    </span></span><span>config</span><span>.</span><span>configId</span></div></div><div><div><div>145</div></div><div><span><span>  </span></span><span>)</span><span> </span><span>{</span></div></div><div><div><div>146</div></div><div><span>    </span><span>return</span><span> </span><span>{</span><span> </span><span>...</span><span>baseProps</span><span>,</span><span> </span><span>configId</span><span>:</span><span> config</span><span>.</span><span>configId </span><span>};</span></div></div><div><div><div>147</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>148</div></div><div>
</div></div><div><div><div>149</div></div><div><span>  </span><span>return</span><span> baseProps</span><span>;</span></div></div><div><div><div>150</div></div><div><span>};</span></div></div><div><div><div>151</div></div><div>
</div></div><div><div><div>152</div></div><div><span>const</span><span><span> </span><span>allComponents</span><span> </span></span><span>=</span><span> </span><span>getComponents</span><span>();</span></div></div><div><div><div>153</div></div><div><span>const</span><span><span> </span><span>filteredComponents</span><span> </span></span><span>=</span><span> </span><span>filterAndSortComponents</span><span>(</span><span>allComponents</span><span>);</span></div></div><div><div><div>154</div></div><div>
</div></div><div><div><div>155</div></div><div><span>const</span><span><span> </span><span>isMobileBottom</span><span> </span></span><span>=</span><span> side </span><span>===</span><span><span> </span><span>SIDEBAR_SIDE</span></span><span>.</span><span>BOTTOM</span><span>;</span></div></div><div><div><div>156</div></div><div><span>const</span><span> </span><span>{</span><span><span> </span><span>topComponents</span></span><span>,</span><span><span> </span><span>stickyComponents</span><span> </span></span><span>}</span><span> </span><span>=</span><span> </span><span>!</span><span>isMobileBottom</span></div></div><div><div><div>157</div></div><div><span>  </span><span>?</span><span> </span><span>getComponentsByPosition</span><span>(</span><span>filteredComponents</span><span>)</span></div></div><div><div><div>158</div></div><div><span>  </span><span>:</span><span> </span><span>{</span><span> </span><span>topComponents</span><span>:</span><span> </span><span>[],</span><span> </span><span>stickyComponents</span><span>:</span><span> </span><span>[]</span><span> </span><span>};</span></div></div><div><div><div>159</div></div><div><span>const</span><span><span> </span><span>bottomComponents</span><span> </span></span><span>=</span><span> isMobileBottom </span><span>?</span><span> filteredComponents </span><span>:</span><span> </span><span>[];</span></div></div><div><div><div>160</div></div><div><span>const</span><span><span> </span><span>hasInitiallyVisibleTopComponents</span><span> </span></span><span>=</span><span> topComponents</span><span>.</span><span>some</span><span>(</span></div></div><div><div><div>161</div></div><div><span><span>  </span></span><span>isComponentInitiallyVisible</span><span>,</span></div></div><div><div><div>162</div></div><div><span>);</span></div></div><div><div><div>163</div></div><div>
</div></div><div><div><div>164</div></div><div><span>let</span><span> mobileProfileComp</span><span>:</span><span> </span><span>MobileBottomComponentConfig</span><span> </span><span>|</span><span> </span><span>undefined</span><span>;</span></div></div><div><div><div>165</div></div><div><span>let</span><span> mobileOtherComponents</span><span>:</span><span> </span><span>MobileBottomComponentConfig</span><span>[]</span><span> </span><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>if</span><span> </span><span>(</span><span>isMobileBottom</span><span>)</span><span> </span><span>{</span></div></div><div><div><div>168</div></div><div><span><span>  </span></span><span>mobileProfileComp </span><span>=</span><span> bottomComponents</span><span>.</span><span>find</span><span>((</span><span>comp</span><span>)</span><span> </span><span>=&gt;</span><span> comp</span><span>.</span><span>type </span><span>===</span><span> </span><span>"</span><span>profile</span><span>"</span><span>);</span></div></div><div><div><div>169</div></div><div><span><span>  </span></span><span>mobileOtherComponents </span><span>=</span><span> bottomComponents</span><span>.</span><span>filter</span><span>(</span></div></div><div><div><div>170</div></div><div><span><span>    </span></span><span>(</span><span>comp</span><span>)</span><span> </span><span>=&gt;</span><span> comp</span><span>.</span><span>type </span><span>!==</span><span> </span><span>"</span><span>profile</span><span>"</span><span>,</span></div></div><div><div><div>171</div></div><div><span><span>  </span></span><span>);</span></div></div><div><div><div>172</div></div><div><span>}</span></div></div><div><div><div>173</div></div><div>
</div></div><div><div><div>174</div></div><div><span>const</span><span><span> </span><span>hasStickyContent</span><span> </span></span><span>=</span></div></div><div><div><div>175</div></div><div><span><span>  </span></span><span>stickyComponents</span><span>.</span><span><span>length</span><span> </span></span><span>&gt;</span><span> </span><span>0</span><span> </span><span>||</span><span> side </span><span>===</span><span><span> </span><span>SIDEBAR_SIDE</span></span><span>.</span><span>LEFT</span><span>;</span></div></div><div><div><div>176</div></div><div><span>---</span></div></div><div><div><div>177</div></div><div>
</div></div><div><div><div>178</div></div><div><span>{</span></div></div><div><div><div>179</div></div><div><span><span>  </span></span><span>(</span><span>topComponents</span><span>.</span><span><span>length</span><span> </span></span><span>&gt;</span><span> </span><span>0</span><span> </span><span>||</span><span> stickyComponents</span><span>.</span><span><span>length</span><span> </span></span><span>&gt;</span><span> </span><span>0</span><span> </span><span>||</span><span> bottomComponents</span><span>.</span><span><span>length</span><span> </span></span><span>&gt;</span><span> </span><span>0</span><span> </span><span>||</span><span> hasStickyContent</span><span>)</span><span> </span><span>&amp;&amp;</span><span> </span><span>(</span></div></div><div><div><div>180</div></div><div><span><span>    </span></span><span>&lt;</span><span>div</span><span> </span><span>id</span><span>={</span><span>`${</span><span>side</span><span>}</span><span>-sidebar</span><span>`</span><span><span>}</span><span> </span></span><span>class</span><span>:</span><span>list</span><span><span>={</span><span>[</span></span><span>className</span><span>, </span><span>"</span><span>flex flex-col w-full pt-0</span><span>"</span><span><span>]</span><span>}</span><span>&gt;</span></span></div></div><div><div><div>181</div></div><div><span>      </span><span>{</span><span>isMobileBottom</span><span> </span><span>?</span><span> </span><span>(</span></div></div><div><div><div>182</div></div><div><span><span>        </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"</span><span>flex flex-col w-full gap-4</span><span>"</span><span>&gt;</span></div></div><div><div><div>183</div></div><div><span>          </span><span>{</span><span>mobileProfileComp</span><span> </span><span>&amp;&amp;</span><span> </span><span>(()</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>184</div></div><div><span>            </span><span>const</span><span> </span><span>Component</span><span> </span><span>=</span><span> </span><span>componentMap</span><span>[</span><span>mobileProfileComp</span><span>.</span><span>type</span><span>];</span></div></div><div><div><div>185</div></div><div><span>            </span><span>if</span><span> </span><span><span>(</span><span>!</span></span><span>Component</span><span>)</span><span> </span><span>return</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>186</div></div><div><span>            </span><span>const</span><span> </span><span>props</span><span> </span><span>=</span><span> </span><span>getComponentProps</span><span>(</span><span>mobileProfileComp</span><span>,</span><span> </span><span>0</span><span>)</span><span> </span><span>as</span><span> </span><span>any</span><span>;</span></div></div><div><div><div>187</div></div><div><span>            </span><span>return</span><span> </span><span>&lt;</span><span>Component</span><span><span> </span><span>{...</span></span><span>props</span><span><span>}</span><span> /&gt;;</span></span></div></div><div><div><div>188</div></div><div><span><span>          </span></span><span><span>})()</span><span>}</span></span></div></div><div><div><div>189</div></div><div>
</div></div><div><div><div>190</div></div><div><span><span>          </span></span><span>&lt;</span><span>UmamiStats</span><span> </span><span>class</span><span>=</span><span>"</span><span>onload-animation</span><span>"</span><span> </span><span>style</span><span>=</span><span>"</span><span>animation-delay: 50ms</span><span>"</span><span> /&gt;</span></div></div><div><div><div>191</div></div><div>
</div></div><div><div><div>192</div></div><div><span>          </span><span>{</span><span>mobileOtherComponents</span><span>.</span><span>map</span><span>((</span><span>comp</span><span>,</span><span> </span><span>index</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>193</div></div><div><span>            </span><span>const</span><span> </span><span>Component</span><span> </span><span>=</span><span> </span><span>componentMap</span><span>[</span><span>comp</span><span>.</span><span>type</span><span>];</span></div></div><div><div><div>194</div></div><div><span>            </span><span>if</span><span> </span><span><span>(</span><span>!</span></span><span>Component</span><span>)</span><span> </span><span>return</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>195</div></div><div><span>            </span><span>const</span><span> </span><span>props</span><span> </span><span>=</span><span> </span><span>getComponentProps</span><span>(</span><span>comp</span><span>,</span><span> </span><span>index</span><span> </span><span>+</span><span> </span><span>2</span><span>)</span><span> </span><span>as</span><span> </span><span>any</span><span>;</span></div></div><div><div><div>196</div></div><div><span>            </span><span>return</span><span> </span><span>&lt;</span><span>Component</span><span><span> </span><span>{...</span></span><span>props</span><span><span>}</span><span> /&gt;;</span></span></div></div><div><div><div>197</div></div><div><span><span>          </span></span><span><span>})</span><span>}</span></span></div></div><div><div><div>198</div></div><div><span><span>        </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>199</div></div><div><span><span>      </span></span><span>)</span><span> </span><span>:</span><span> </span><span>(</span></div></div><div><div><div>200</div></div><div><span><span>        </span></span><span>&lt;&gt;</span></div></div><div><div><div>201</div></div><div><span>          </span><span>{</span><span>topComponents</span><span>.</span><span>length</span><span> </span><span>&gt;</span><span> </span><span>0</span><span> </span><span>&amp;&amp;</span><span> </span><span>(</span></div></div><div><div><div>202</div></div><div><span><span>            </span></span><span>&lt;</span><span>div</span></div></div><div><div><div>203</div></div><div><span>              </span><span>class</span><span>:</span><span>list</span><span><span>={</span><span>[</span></span></div></div><div><div><div>204</div></div><div><span>                </span><span>"</span><span>flex flex-col w-full gap-4</span><span>"</span><span>,</span></div></div><div><div><div>205</div></div><div><span><span>                </span></span><span>hasInitiallyVisibleTopComponents</span><span><span> </span><span>&amp;&amp;</span><span> </span></span><span>"</span><span>mb-4</span><span>"</span><span>,</span></div></div><div><div><div>206</div></div><div><span><span>              </span></span><span>]</span><span>}</span></div></div><div><div><div>207</div></div><div><span><span>            </span></span><span>&gt;</span></div></div><div><div><div>208</div></div><div><span>              </span><span>{</span><span>topComponents</span><span>.</span><span>map</span><span>((</span><span>comp</span><span>,</span><span> </span><span>index</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>209</div></div><div><span>                </span><span>const</span><span> </span><span>Component</span><span> </span><span>=</span><span> </span><span>componentMap</span><span>[</span><span>comp</span><span>.</span><span>type</span><span>];</span></div></div><div><div><div>210</div></div><div><span>                </span><span>if</span><span> </span><span><span>(</span><span>!</span></span><span>Component</span><span>)</span><span> </span><span>return</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>211</div></div><div><span>                </span><span>const</span><span> </span><span>props</span><span> </span><span>=</span><span> </span><span>getComponentProps</span><span>(</span><span>comp</span><span>,</span><span> </span><span>index</span><span>)</span><span> </span><span>as</span><span> </span><span>any</span><span>;</span></div></div><div><div><div>212</div></div><div><span>                </span><span>return</span><span> </span><span>&lt;</span><span>Component</span><span><span> </span><span>{...</span></span><span>props</span><span><span>}</span><span> /&gt;;</span></span></div></div><div><div><div>213</div></div><div><span><span>              </span></span><span><span>})</span><span>}</span></span></div></div><div><div><div>214</div></div><div><span><span>            </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>215</div></div><div><span><span>          </span></span><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>{</span><span>hasStickyContent</span><span> </span><span>&amp;&amp;</span><span> </span><span>!</span><span>isMobileBottom</span><span> </span><span>&amp;&amp;</span><span> </span><span>(</span></div></div><div><div><div>218</div></div><div><span><span>            </span></span><span>&lt;</span><span>div</span></div></div><div><div><div>219</div></div><div><span>              </span><span>id</span><span>={</span><span>`${</span><span>side</span><span>}</span><span>-sidebar-sticky</span><span>`</span><span>}</span></div></div><div><div><div>220</div></div><div><span>              </span><span>class</span><span>:</span><span>list</span><span><span>={</span><span>[</span></span></div></div><div><div><div>221</div></div><div><span>                </span><span>"</span><span>flex flex-col w-full mt-0 gap-4</span><span>"</span><span>,</span></div></div><div><div><div>222</div></div><div><span>                </span><span>"</span><span>sticky</span><span>"</span><span>,</span></div></div><div><div><div>223</div></div><div><span><span>                </span></span><span>hasInitiallyVisibleTopComponents</span><span><span> </span><span>?</span><span> </span></span><span>"</span><span>top-4</span><span>"</span><span><span> </span><span>:</span><span> </span></span><span>"</span><span>top-0</span><span>"</span><span>,</span></div></div><div><div><div>224</div></div><div><span><span>              </span></span><span>]</span><span>}</span></div></div><div><div><div>225</div></div><div><span><span>            </span></span><span>&gt;</span></div></div><div><div><div>226</div></div><div><span>              </span><span>{</span><span>side</span><span> </span><span>===</span><span> </span><span>SIDEBAR_SIDE</span><span>.</span><span>LEFT</span><span> </span><span>&amp;&amp;</span><span> </span><span>(</span></div></div><div><div><div>227</div></div><div><span><span>                </span></span><span>&lt;</span><span>UmamiStats</span><span> </span><span>class</span><span>=</span><span>"</span><span>onload-animation</span><span>"</span><span> </span><span>style</span><span>=</span><span>"</span><span>animation-delay: 200ms</span><span>"</span><span> /&gt;</span></div></div><div><div><div>228</div></div><div><span><span>              </span></span><span><span>)</span><span>}</span></span></div></div><div><div><div>229</div></div><div>
</div></div><div><div><div>230</div></div><div><span>              </span><span>{</span><span>stickyComponents</span><span>.</span><span>map</span><span>((</span><span>comp</span><span>,</span><span> </span><span>index</span><span>)</span><span> </span><span>=&gt;</span><span> </span><span>{</span></div></div><div><div><div>231</div></div><div><span>                </span><span>const</span><span> </span><span>Component</span><span> </span><span>=</span><span> </span><span>componentMap</span><span>[</span><span>comp</span><span>.</span><span>type</span><span>];</span></div></div><div><div><div>232</div></div><div><span>                </span><span>if</span><span> </span><span><span>(</span><span>!</span></span><span>Component</span><span>)</span><span> </span><span>return</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>233</div></div><div><span>                </span><span>const</span><span> </span><span>props</span><span> </span><span>=</span><span> </span><span>getComponentProps</span><span>(</span></div></div><div><div><div>234</div></div><div><span><span>                  </span></span><span>comp</span><span>,</span></div></div><div><div><div>235</div></div><div><span><span>                  </span></span><span>topComponents</span><span>.</span><span>length</span><span> </span><span>+</span><span> </span><span>index</span></div></div><div><div><div>236</div></div><div><span><span>                </span></span><span>)</span><span> </span><span>as</span><span> </span><span>any</span><span>;</span></div></div><div><div><div>237</div></div><div><span>                </span><span>return</span><span> </span><span>&lt;</span><span>Component</span><span><span> </span><span>{...</span></span><span>props</span><span><span>}</span><span> /&gt;;</span></span></div></div><div><div><div>238</div></div><div><span><span>              </span></span><span><span>})</span><span>}</span></span></div></div><div><div><div>239</div></div><div><span><span>            </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>240</div></div><div><span><span>          </span></span><span><span>)</span><span>}</span></span></div></div><div><div><div>241</div></div><div><span><span>        </span></span><span>&lt;/&gt;</span></div></div><div><div><div>242</div></div><div><span><span>      </span></span><span><span>)</span><span>}</span></span></div></div><div><div><div>243</div></div><div><span><span>    </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>244</div></div><div><span><span>  </span></span><span>)</span></div></div><div><div><div>245</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><h2>结尾<a href="#结尾"><span>#</span></a></h2><p>搞定了。现在每次打开博客，侧边栏就会自动加载 Umami 的访问数据，还有个数字滚动的动画效果，看着还挺舒服的。如果数据拉不到（比如 API 挂了），会自动显示 fallback 的数字，不至于空空的。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/nvidia-free-api/</id>
      <title type="text">NVIDIA Build：免费 AI API</title>
      <published>2026-05-29T00:00:00.000Z</published>
      <updated>2026-05-31T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/nvidia-free-api/"/>
      <summary type="text">白嫖 NVIDIA 的免费 AI API，额度够用，模型够多</summary>
      <content type="html"><![CDATA[<section><h2>免费 API<a href="#免费-api"><span>#</span></a></h2><p><a href="https://build.nvidia.com/" target="_blank">NVIDIA Build</a> 的免费额度挺大方的，每分钟 40 次请求，日常开发完全够用。</p><p>模型也挺丰富，DeepSeek V4 Pro、DeepSeek V4 Flash 这些都有，还有云端 GPU 实例可以免费试用。</p><p>如果你想体验一下最新的 AI 模型又不想花钱，这个平台目前是最值得薅的。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/nbip/</id>
      <title type="text">一个非常NB的IP优选</title>
      <published>2026-05-24T00:00:00.000Z</published>
      <updated>2026-05-31T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/nbip/"/>
      <summary type="text">推荐一个开源免费的 Cloudflare IP 优选工具</summary>
      <content type="html"><![CDATA[<p>在 <a href="https://imbee.top/" target="_blank">MIFENG の 主页</a> 上发现了一个 IP 优选项目，用起来挺顺手的。</p>
<p>开源免费，不需要折腾太多配置，直接上手就能用。</p>
<p>网址：<a href="https://www.byoip.top" target="_blank">NB优选</a></p>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/life/2026-5-21-exam/</id>
      <title type="text">论二模试卷上的错误</title>
      <published>2026-05-23T00:00:00.000Z</published>
      <updated>2026-05-23T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/life/2026-5-21-exam/"/>
      <summary type="text">世界就是一个巨大的草台班子</summary>
      <content type="html"><![CDATA[<p>众所周知我是一名初二学生，在5月21日这么浪漫的一天，我考玩了地理和生物的二模。发现试卷上有些低级错误。</p>
<section><h2>地理<a href="#地理"><span>#</span></a></h2><p>赤道周长写错了成了赤道昼长。</p><p></p><figure><img alt="地理" loading="lazy" width="4022" height="1596" src="/_astro/dl.CiqtL0iT_ZBvvB9.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="4094" height="1716" src="/_astro/sw.HYfeZuQ6_Z6a97F.webp" /><figcaption>生物</figcaption></figure><p></p><p>所以，试卷是不需要审核的吗。错别字都能出现。
果然世界就是一个巨大的草台班子。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/article_2/</id>
      <title type="text">转载止战之殇</title>
      <published>2026-05-13T00:00:00.000Z</published>
      <updated>2026-05-13T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/article_2/"/>
      <summary type="text">神了</summary>
      <content type="html"><![CDATA[<section><h2>止战之殇<a href="#止战之殇"><span>#</span></a></h2><p>雷德王还有3个小时降临地球，世界各大媒体平台将这条消息传唤的沸沸扬扬，他的到来，是救赎还是毁灭，我无从得知，众说纷纭，我只想先吃个汉堡吧。</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><figure><img alt="原文图,请以倒观方式观看" loading="lazy" width="1114" height="720" src="/_astro/2%E5%88%86.DiBktH_7_Z11YEAT.webp" /><figcaption>原文图,请以倒观方式观看</figcaption></figure><p></p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/linux_cpp/</id>
      <title type="text">Linux（Debian 系）上搭建 C++ 开发环境</title>
      <published>2026-05-02T00:00:00.000Z</published>
      <updated>2026-05-31T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/linux_cpp/"/>
      <summary type="text">用 VS Code、Clang、CMake、Ninja 和 vcpkg 搭建一个现代化的 C++ 开发环境</summary>
      <content type="html"><![CDATA[<section><h2>介绍<a href="#介绍"><span>#</span></a></h2><p>Linux 做 C++ 开发确实舒服，工具链齐全，编译速度也快。这篇文章手把手教你在 Debian 系的 Linux 上，用 VS Code + Clang + CMake + Ninja + vcpkg 搭一套现代化的开发环境，最后还会用 C++20 模块跑一个示例项目。</p><section><h3>需要装的东西<a href="#需要装的东西"><span>#</span></a></h3><ol>
<li><strong>编译器</strong>：Clang</li>
<li><strong>项目构建器</strong>：CMake</li>
<li><strong>本地构建器</strong>：Ninja</li>
<li><strong>依赖包管理</strong>：vcpkg</li>
</ol><div><div><img alt="@startuml
start
:源代码 (.cpp/.h);
:vcpkg：安装依赖库（如 fmt, OpenCV）;
:CMake：读取 CMakeLists.txt，结合 vcpkg 工具链，生成 build.ninja;
:Ninja：根据 build.ninja 启动并行构建任务;
:Clang：执行编译（.cpp → .o）和链接（+ 依赖库）;
:得到最终可执行文件;
stop
@e" src="https://www.plantuml.com/plantuml/svg/JO-_Jy907CNt-ojkr6ZgtYuahFxOt2kW8crfx64SrOXUIPLAJKqQHGX1dQYB4BXB_nZJxxLCzo-OuiBqtijBNjwdv13zdBHC0wqCKiN2Jzae6rpZ3QLYsplAwQQ6r8kAtJYHF8A9cu_QQVAM_uIm22Idy763ZqsoZG_icbK-bDpLa5hUqniroQCyPj0DyIhkrXtYAEIIIEvbB02VujKgXjaOshDba1J5yxkWFZvgrOsgOjMjCrr3wdwXaaTYC1SFa_KIWnz3vmlcqtpeYVuji4NA67I6nGT3juhBmltCXrx6mpn-afmMKFZtheULfkGkzBnba8Z7iUHq2wzn5HIGl03z5gzN6QFGZV-7H7YVigc67DAqKQbcLLkcyGS0" loading="lazy" /></div></div></section></section>
<section><h2>安装和配置<a href="#安装和配置"><span>#</span></a></h2><section><h3>1. 安装 Clang 编译器<a href="#1-安装-clang-编译器"><span>#</span></a></h3><p>Clang 的错误提示比 GCC 友好很多，而且对新标准的支持一直很积极。截至 2026 年 4 月，最新稳定版是 Clang 22.1.3。Clang 23 已经有预发布版了，但建议还是用稳定的。</p><p>用 LLVM 官方脚本装最省事：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>wget</span><span> </span><span>https://apt.llvm.org/llvm.sh</span></div></div><div><div><div>2</div></div><div><span>chmod</span><span> </span><span>+x</span><span> </span><span>llvm.sh</span></div></div><div><div><div>3</div></div><div><span>sudo</span><span> </span><span>./llvm.sh</span><span> </span><span>22</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>脚本跑完会自动添加 LLVM 的 APT 仓库，然后装好 clang-22 和相关工具。</p><p>装完之后设一下默认版本，这样终端里直接输 <code>clang</code> 就能用：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sudo</span><span> </span><span>update-alternatives</span><span> </span><span>--install</span><span> </span><span>/usr/bin/clang</span><span> </span><span>clang</span><span> </span><span>/usr/bin/clang-22</span><span> </span><span>100</span></div></div><div><div><div>2</div></div><div><span>sudo</span><span> </span><span>update-alternatives</span><span> </span><span>--install</span><span> </span><span>/usr/bin/clang++</span><span> </span><span>clang++</span><span> </span><span>/usr/bin/clang++-22</span><span> </span><span>100</span></div></div><div><div><div>3</div></div><div><span>sudo</span><span> </span><span>update-alternatives</span><span> </span><span>--install</span><span> </span><span>/usr/bin/clangd</span><span> </span><span>clangd</span><span> </span><span>/usr/bin/clangd-22</span><span> </span><span>100</span></div></div><div><div><div>4</div></div><div><span>sudo</span><span> </span><span>update-alternatives</span><span> </span><span>--install</span><span> </span><span>/usr/bin/lldb</span><span> </span><span>lldb</span><span> </span><span>/usr/bin/lldb-22</span><span> </span><span>100</span></div></div><div><div><div>5</div></div><div><span>sudo</span><span> </span><span>update-alternatives</span><span> </span><span>--install</span><span> </span><span>/usr/bin/lld</span><span> </span><span>lld</span><span> </span><span>/usr/bin/lld-22</span><span> </span><span>100</span></div></div><div><div><div>6</div></div><div><span>sudo</span><span> </span><span>update-alternatives</span><span> </span><span>--install</span><span> </span><span>/usr/bin/clang-tidy</span><span> </span><span>clang-tidy</span><span> </span><span>/usr/bin/clang-tidy-22</span><span> </span><span>100</span></div></div><div><div><div>7</div></div><div><span>sudo</span><span> </span><span>update-alternatives</span><span> </span><span>--install</span><span> </span><span>/usr/bin/clang-format</span><span> </span><span>clang-format</span><span> </span><span>/usr/bin/clang-format-22</span><span> </span><span>100</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>跑完 <code>clang --version</code> 看一下，输出里有 “clang version 22.1.3” 就说明装好了。</p></section><section><h3>2. 安装 CMake 构建系统<a href="#2-安装-cmake-构建系统"><span>#</span></a></h3><p>CMake 是 C++ 项目构建的标配，能生成各种构建系统需要的文件。当前最新稳定版是 CMake 4.3.1。</p><p>从官方下载安装脚本：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>wget</span><span> </span><span>https://github.com/Kitware/CMake/releases/download/v4.3.1/cmake-4.3.1-linux-x86_64.sh</span></div></div></code></pre><div><div></div><div></div></div></figure></div><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>+x</span><span> </span><span>cmake-4.3.1-linux-x86_64.sh</span></div></div><div><div><div>2</div></div><div><span>sudo</span><span> </span><span>./cmake-4.3.1-linux-x86_64.sh</span><span> </span><span>--prefix=/opt/cmake-4.3.1</span><span> </span><span>--skip-license</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>然后配一下默认版本：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sudo</span><span> </span><span>update-alternatives</span><span> </span><span>--install</span><span> </span><span>/usr/bin/cmake</span><span> </span><span>cmake</span><span> </span><span>/opt/cmake-4.3.1/bin/cmake</span><span> </span><span>100</span></div></div><div><div><div>2</div></div><div><span>sudo</span><span> </span><span>update-alternatives</span><span> </span><span>--install</span><span> </span><span>/usr/bin/ctest</span><span> </span><span>ctest</span><span> </span><span>/opt/cmake-4.3.1/bin/ctest</span><span> </span><span>100</span></div></div><div><div><div>3</div></div><div><span>sudo</span><span> </span><span>update-alternatives</span><span> </span><span>--install</span><span> </span><span>/usr/bin/cpack</span><span> </span><span>cpack</span><span> </span><span>/opt/cmake-4.3.1/bin/cpack</span><span> </span><span>100</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>cmake --version</code> 能看到 4.3.1 就 OK 了。</p></section><section><h3>3. 安装 Ninja 构建工具<a href="#3-安装-ninja-构建工具"><span>#</span></a></h3><p>Ninja 专注速度，配合 CMake 用编译特别快。目前最新稳定版是 1.13.2。</p><p>从 GitHub 下载预编译的二进制文件，解压放到系统路径里就行：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>wget</span><span> </span><span>https://github.com/ninja-build/ninja/releases/download/v1.13.2/ninja-linux.zip</span></div></div><div><div><div>2</div></div><div><span>unzip</span><span> </span><span>ninja-linux.zip</span></div></div><div><div><div>3</div></div><div><span>sudo</span><span> </span><span>mv</span><span> </span><span>ninja</span><span> </span><span>/usr/bin/ninja</span></div></div><div><div><div>4</div></div><div><span>sudo</span><span> </span><span>chmod</span><span> </span><span>+x</span><span> </span><span>/usr/bin/ninja</span></div></div><div><div><div>5</div></div><div><span>ninja</span><span> </span><span>--version</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>4. 安装 vcpkg 包管理器<a href="#4-安装-vcpkg-包管理器"><span>#</span></a></h3><p>vcpkg 是微软出的 C++ 库管理器，装第三方库特别方便，不用手动折腾依赖。最新稳定版是 2026.04.08。</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>~</span></div></div><div><div><div>2</div></div><div><span>git</span><span> </span><span>clone</span><span> </span><span>https://github.com/microsoft/vcpkg.git</span></div></div><div><div><div>3</div></div><div><span>cd</span><span> </span><span>vcpkg</span></div></div><div><div><div>4</div></div><div><span># Linux 下用 .sh 结尾的引导脚本</span></div></div><div><div><div>5</div></div><div><span>./bootstrap-vcpkg.sh</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>装好后把路径加到环境变量里，编辑 <code>~/.bashrc</code> 或 <code>~/.zshrc</code>：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> PATH</span><span>=</span><span>/opt/cmake-4.3.1/bin:$PATH</span></div></div><div><div><div>2</div></div><div><span>export</span><span> VCPKG_ROOT</span><span>=~</span><span>/vcpkg</span></div></div><div><div><div>3</div></div><div><span>export</span><span> PATH</span><span>=</span><span>$VCPKG_ROOT:$PATH</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>改完重启终端或者 <code>source ~/.bashrc</code>，然后 <code>vcpkg --version</code> 看看版本就对了。</p></section><section><h3>5. Visual Studio Code<a href="#5-visual-studio-code"><span>#</span></a></h3><p>VS Code 做 C++ 开发挺好用的（当然你也可以用 CLion 之类的）。装好之后去扩展市场搜这几个插件：</p><ol>
<li><strong>Clangd</strong>（作者: LLVM）：代码补全、跳转、实时诊断，比微软的 C/C++ 插件好用。</li>
<li><strong>CodeLLDB</strong>（作者: Vadim Chugunov）：本地调试器，跟 VS Code 集成得很好。</li>
<li><strong>CMake Tools</strong>（作者: Microsoft）：直接在 VS Code 里配置、构建、运行 CMake 项目。</li>
<li><strong>CMake</strong>（作者: Microsoft）：给 CMakeLists.txt 提供语法高亮。</li>
</ol></section></section>
<section><h2>构建和运行你的第一个程序<a href="#构建和运行你的第一个程序"><span>#</span></a></h2><p>用 C++20 的模块特性来写第一个项目。</p><section><h3>项目结构<a href="#项目结构"><span>#</span></a></h3><p>建一个项目文件夹，结构长这样：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>CppProject/</span></div></div><div><div><div>2</div></div><div><span>├── .vscode/</span></div></div><div><div><div>3</div></div><div><span>│   └── launch.json      # 调试配置（后面会创建）</span></div></div><div><div><div>4</div></div><div><span>├── CMakeLists.txt       # CMake 项目配置</span></div></div><div><div><div>5</div></div><div><span>├── CMakePresets.json    # CMake 预设</span></div></div><div><div><div>6</div></div><div><span>├── main.cpp             # 主程序</span></div></div><div><div><div>7</div></div><div><span>├── utilities.ixx        # 一个简单的 C++20 模块</span></div></div><div><div><div>8</div></div><div><span>└── vcpkg.json           # vcpkg 依赖描述</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>不知道这些文件里写什么的话，把这篇文章丢给 AI 让它帮你生成。</p></section><section><h3>配置代码和调试器<a href="#配置代码和调试器"><span>#</span></a></h3><p><code>utilities.ixx</code> 模块文件：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>module</span><span>;</span></div></div><div><div><div>2</div></div><div><span>#</span><span>include</span><span> </span><span>&lt;</span><span>string</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span>export</span><span> </span><span>module</span><span> utilities</span><span>;</span></div></div><div><div><div>4</div></div><div><span>export</span><span> </span><span>auto</span><span> </span><span>get_greeting</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>5</div></div><div><span>    </span><span>return</span><span> </span><span>"</span><span>Hello from a C++20 Module!</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>main.cpp</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import utilities</span><span>;</span></div></div><div><div><div>2</div></div><div><span>#</span><span>include</span><span> </span><span>&lt;</span><span>fmt/core.h</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span>int</span><span> </span><span>main</span><span>()</span><span> </span><span>{</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>fmt</span><span>::</span><span>print</span><span>(</span><span>"</span><span>{}</span><span>\n</span><span>"</span><span>,</span><span> </span><span>get_greeting</span><span>());</span></div></div><div><div><div>5</div></div><div><span>    </span><span>return</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>6</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>调试配置文件 <code>.vscode/launch.json</code>，注意 <code>program</code> 的路径要跟你的构建输出目录对上：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>    </span><span>"</span><span>version</span><span>"</span><span>:</span><span> </span><span>"</span><span>0.2.0</span><span>"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>    </span><span>"</span><span>configurations</span><span>"</span><span>:</span><span> </span><span>[</span></div></div><div><div><div>4</div></div><div><span><span>        </span></span><span>{</span></div></div><div><div><div>5</div></div><div><span>            </span><span>"</span><span>type</span><span>"</span><span>:</span><span> </span><span>"</span><span>lldb</span><span>"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>            </span><span>"</span><span>request</span><span>"</span><span>:</span><span> </span><span>"</span><span>launch</span><span>"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>            </span><span>"</span><span>name</span><span>"</span><span>:</span><span> </span><span>"</span><span>Debug (LLDB)</span><span>"</span><span>,</span></div></div><div><div><div>8</div></div><div><span>            </span><span>"</span><span>program</span><span>"</span><span>:</span><span> </span><span>"</span><span>${workspaceFolder}/build/linux_clang/Debug/Rocket</span><span>"</span><span>,</span></div></div><div><div><div>9</div></div><div><span>            </span><span>"</span><span>args</span><span>"</span><span>:</span><span> </span><span>[],</span></div></div><div><div><div>10</div></div><div><span>            </span><span>"</span><span>cwd</span><span>"</span><span>:</span><span> </span><span>"</span><span>${workspaceFolder}</span><span>"</span></div></div><div><div><div>11</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>]</span></div></div><div><div><div>13</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>配置、构建与运行<a href="#配置构建与运行"><span>#</span></a></h3><section><h4>1. 选择预设<a href="#1-选择预设"><span>#</span></a></h4><p>在 VS Code 里打开项目文件夹，<code>Ctrl+Shift+P</code> 打开命令面板，输入 <code>CMake: Select Configure Preset</code>，根据你的系统和编译器选一个合适的预设。</p></section><section><h4>2. 开始构建<a href="#2-开始构建"><span>#</span></a></h4><p>再打开命令面板，输入 <code>CMake: Build</code>，或者直接按 <code>F7</code>。CMake 会调用 Clang 和 Ninja 来编译和链接。</p></section><section><h4>3. 运行与调试<a href="#3-运行与调试"><span>#</span></a></h4><p>构建成功后，点 VS Code 底部的 ▶️ 按钮就能运行。设好断点按 <code>F5</code> 启动调试，<code>F10</code> 单步执行，变量值也能实时看到。</p></section></section></section>
<section><h2>总结<a href="#总结"><span>#</span></a></h2><p>搞定了，你现在有了一套完整的现代化 C++ 开发环境。不管是自己学习还是团队协作都能用。</p><p>有两个要注意的地方：</p><ol>
<li>装完所有工具后，记得用 <code>--version</code> 一个个验证版本，确保环境变量没配错。</li>
<li><code>launch.json</code> 是项目的调试启动配置，新建项目的时候记得把这个文件复制过去，然后把 <code>program</code> 字段改成新项目的可执行文件路径。</li>
<li><code>CMakeLists.txt</code> 和 <code>CMakePresets.json</code> 不知道怎么写的话，直接问 AI 就行。</li>
</ol></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/astbot/</id>
      <title type="text">Linux 上安装和使用 AstrBot</title>
      <published>2026-04-25T00:00:00.000Z</published>
      <updated>2026-05-31T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/astbot/"/>
      <summary type="text">在 Linux 系统上安装 AstrBot 聊天机器人并做基础设置</summary>
      <content type="html"><![CDATA[<section><h2>AstrBot 是什么<a href="#astrbot-是什么"><span>#</span></a></h2><p>AstrBot 是一个开源的聊天机器人框架，轻量好用，能对接 QQ、微信、飞书这些平台。自带插件系统和可视化面板，社区里已经有上千款插件了，做自动回复、群管理、定时任务什么的都挺方便的。</p></section>
<section><h2>安装<a href="#安装"><span>#</span></a></h2><p>在 Linux 终端里跑这个脚本就行：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>bash</span><span> </span><span>&lt;(</span><span>curl</span><span> </span><span>-sSL</span><span> https://raw.githubusercontent.com/zhende1113/Antlia/refs/heads/main/Script/AstrBot/Antlia.sh</span><span>)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>如果你的系统没装 curl，用 wget 也行：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>wget</span><span> </span><span>-qO-</span><span> </span><span>https://raw.githubusercontent.com/zhende1113/Antlia/refs/heads/main/Script/AstrBot/Antlia.sh</span><span> </span><span>|</span><span> </span><span>bash</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div></div><div>Tip</div></div><div><p>如果 <code>uv sync</code> 报错，可以试试设置环境变量：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>echo</span><span> </span><span>'</span><span>export UV_LINK_MODE=copy</span><span>'</span><span> </span><span>&gt;&gt;</span><span> </span><span>~/.bashrc</span></div></div><div><div><div>2</div></div><div><span>source</span><span> </span><span>~/.bashrc</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>然后正常启动：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>cd</span><span> </span><span>AstrBot</span></div></div><div><div><div>2</div></div><div><span>uv</span><span> </span><span>run</span><span> </span><span>main.py</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>如果下载依赖时遇到网络问题，可以换成国内镜像源：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>echo</span><span> </span><span>'</span><span>export UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple"</span><span>'</span><span> </span><span>&gt;&gt;</span><span> </span><span>~/.bashrc</span></div></div><div><div><div>2</div></div><div><span>source</span><span> </span><span>~/.bashrc</span></div></div></code></pre><div><div></div><div></div></div></figure></div></div></div><p><a href="https://github.com/AstrBotDevs/AstrBot/" target="_blank">AstrBot 仓库</a></p><p><a href="https://docs.astrbot.app/" target="_blank">AstrBot 官方文档</a></p><p><a href="https://github.com/zhende1113/Antlia/" target="_blank">脚本仓库</a></p></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.amamo.top/posts/cf_preferred/</id>
      <title type="text">Cloudflare Worker 优选</title>
      <published>2026-04-25T00:00:00.000Z</published>
      <updated>2026-05-31T00:00:00.000Z</updated>
      <author><name>年华</name></author>
      <link rel="alternate" href="https://blog.amamo.top/posts/cf_preferred/"/>
      <summary type="text">通过 Cloudflare Worker 优选让你的网站解析到国内访问更快的 IP，提升访问速度</summary>
      <content type="html"><![CDATA[<section><h2>什么是优选<a href="#什么是优选"><span>#</span></a></h2><p>说白了就是<strong>挑一个国内访问更快的 Cloudflare 节点。</strong></p><p>Cloudflare 默认分配给你的 IP，国内访问延迟可能很高，甚至直接打不开。通过优选，我们可以手动把域名解析到那些国内访问快的 Cloudflare IP 上，速度会明显提升。</p><p>要实现优选，核心就两件事：自己控制路由规则 + 自己控制 DNS 解析。</p></section>
<section><h2>寻找优选域名<a href="#寻找优选域名"><span>#</span></a></h2><p>可以用社区大佬做的<a href="https://cf.090227.xyz/" target="_blank">这个网站</a>来获取优选域名。</p></section>
<section><h2>Worker 优选方法<a href="#worker-优选方法"><span>#</span></a></h2><p>打开你的 Worker 项目，添加一条路由，路由格式填 <code>你的域名/*</code>：</p><p></p><figure><img alt="路由示例" loading="lazy" width="628" height="1080" src="/_astro/cf_2.W1hF9hGa_Z10r3g9.webp" /><figcaption>路由示例</figcaption></figure><p></p><p>然后去域名 DNS 设置里加一条记录，类型选 CNAME。名称根据你路由里填的内容来定：比如你路由填的是 <code>blog.example.com</code>，这里名称就填 <code>blog</code>（前提是域名 <code>example.com</code> 托管在 Cloudflare 上）。如果路由填的域名跟你托管的域名一样，直接填 <code>@</code>。最后目标填从社区网站复制下来的优选域名就行。</p><p></p><figure><img alt="DNS记录示例" loading="lazy" width="1874" height="640" src="/_astro/cf_1.DXsnsJIP_b40c6.webp" /><figcaption>DNS记录示例</figcaption></figure><p></p><div><div><div></div><div>Tip</div></div><div><p>优选的前提是你有一个域名托管在 Cloudflare 上。
路由里记得在域名后面加 <code>/*</code>，不然优选只对根域名生效。</p></div></div></section>]]></content>
    </entry>
</feed>
