<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://x1anyu.cn/</id>
  <title type="text">临渊羡鱼</title>
  <subtitle type="text">Wander like a fish</subtitle>
  <updated>2026-09-10T23:30:00.000Z</updated>
  <author><name>羡鱼</name></author>
  <link rel="alternate" href="https://x1anyu.cn/"/>
  <link rel="self" href="https://x1anyu.cn/atom.xml"/>
  <generator uri="https://github.com/CuteLeaf/Firefly">Firefly v6.16.8</generator>
    <entry>
      <id>https://x1anyu.cn/posts/15/</id>
      <title type="text">音乐页软导航改造：播放不中断的排错记录</title>
      <published>2026-09-10T23:30:00.000Z</published>
      <updated>2026-09-10T23:30:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/15/"/>
      <summary type="text">音乐页一直是整页加载，导致播放切页就断。这次把它交还 Swup 软导航，顺手填掉导航栏发白、位移、歌词丢失、高亮过亮、浮层面板反白、移动端 Web Audio 接管暂停一串坑。</summary>
      <content type="html"><![CDATA[<p>音乐页有个一直没解决的问题：在首页放着歌，点进音乐页，播放会断。</p>
<p>文章之间切页是连着的，因为 Firefly 用的是 Swup 软导航，只换内容区、不重载文档，挂在 Layout 上的 <code>&lt;audio&gt;</code> 一直活着。唯独音乐页不行 —— 当初为了让全屏可视化不跟 Swup 打架，给它设了整页加载。而整页加载会把整个 JS 上下文连同那个常驻播放器一起销毁。</p>
<p>为了续播我做过一版跨整页的方案：<code>pagehide</code> 时把曲目索引、进度、是否在播写进 localStorage，重载后读出来 seek 回去再 <code>play()</code>。进度是能继承的，播放状态不行 —— 整页导航会重置浏览器的用户手势，这时候再调 <code>play()</code> 会被 autoplay 政策直接拒。我又加了静音兜底（先 muted 播再恢复），还是拒；再加”首次点击或按键时续播”的一次性监听，那就不叫无缝了，跟”点一下才能听”没区别。</p>
<p>绕到这儿结论很清楚：只要音乐页走整页加载，就不可能无缝。得回到软导航。</p>
<section><h2>音乐页缺了 Swup 的容器<a href="#音乐页缺了-swup-的容器"><span>#</span></a></h2><p>回到软导航，先得解释上次为什么放弃了它。</p><p>上次的现象是”进音乐页界面乱、内链还失效”，当时的判断是可视化器那个 <code>position: fixed</code> 全屏层被 Swup 过渡期加在容器上的 transform 弄坏了。这次我去翻了 git，那层从来没改过，这个假设站不住。</p><p>真正的根子，校验一下 Swup 的容器配置就浮出来了。<code>astro.config.mjs</code> 里 <code>containers</code> 配了六个：</p><ul>
<li><code>#swup-container</code></li>
<li><code>#banner-overlay-container</code></li>
<li><code>#banner-dim-container</code></li>
<li><code>#left-sidebar-dynamic</code></li>
<li><code>#right-sidebar-dynamic</code></li>
<li><code>#floating-toc-wrapper</code></li>
</ul><p>把这六个 id 拿去搜谁渲染，答案只有一个：<code>MainGridLayout</code>。而 <code>music.astro</code> 用的是 <code>&lt;Layout&gt;</code> 直出 —— 也就是说音乐页的 HTML 里，这六个容器一个都不存在。</p><p>Swup 换内容的方式是”拿目标页的同名容器来替换”。目标页没有容器，这次导航就只能半途失败：地址变了、内容没换，过渡用的 <code>is-page-transitioning</code> 类也摘不掉，表现出来就是界面乱、内链点不动。</p><p>之所以一直没暴露，是因为音乐链接上挂着 <code>fullPage: true</code>，从来没真正软导航过。上次我把这个标志去掉，等于第一次真的走软导航，问题立刻现形。</p><p>还有条佐证。<code>swup-transitions.ts</code> 的 <code>page:view</code> 里会跑 <code>updateMainGridCols()</code>、<code>updateSidebarComponentsVisibility()</code>、<code>syncFullscreenOverlays()</code> 这些，全都依赖 <code>MainGridLayout</code> 的 DOM。用 <code>Layout</code> 直出，它们不是空转就是报错。</p><p>所以修法很直接：<code>music.astro</code> 改用 <code>MainGridLayout</code>，内容塞一个空占位。引申一下，凡是要参与软导航的路由，都不能用 <code>Layout</code> 直出。</p></section>
<section><h2>把可视化器挪成常驻覆盖层<a href="#把可视化器挪成常驻覆盖层"><span>#</span></a></h2><p>容器补齐，音乐页能正常软导航了。但可视化器本身还渲染在内容区里，软导航会把它跟内容一起换掉，three 场景和 AudioContext 都得重建。虽然播放不断了，但可视化的状态每次都要重来，不干净。</p><p>做法是把 <code>MusicVisualizer</code> 从页面里提出来，挂进 Layout，做成常驻覆盖层。它和 <code>MusicManager</code> 一样待在 <code>#swup-container</code> 外面，软导航碰不到。进出音乐页只切 <code>body</code> 上的 <code>music-active</code> 类来显隐，three 场景和播放器都不销毁。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;body&gt;</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>#top-row                     导航栏（常驻）</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>&lt;audio&gt;                      MusicManager 的播放器（常驻）</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>#music-overlay               可视化器覆盖层（常驻，软导航不销毁）</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>.content-panel</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>└─ #swup-container         Swup 只换这里面的东西</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>页面那边只剩一个路由占位，内容是个空的 <code>&lt;main&gt;</code>，回退按钮走 <code>window.swup.navigate("/")</code>。</p><p>常驻是有代价的：组件的脚本和样式从此在每个页面都会加载。所以 three 改成懒加载，第一次进音乐页才 <code>await import("three")</code>，不然那六百多 KB 会摊到每一页的首屏。同理，覆盖层的 DOM 现在每页都在，里面就不能留会自动发请求的东西 —— 之前有个 <code>src=""</code> 的空封面 <code>&lt;img&gt;</code>，顺手去掉了。</p></section>
<section><h2>过渡的几十毫秒<a href="#过渡的几十毫秒"><span>#</span></a></h2><p>覆盖层如果只靠 <code>astro:page-load</code> 切类，Swup 的过渡动画期间会先露出音乐页的外壳，闪一下。所以在 <code>visit:start</code> 里按目标地址提前把类切好，<code>visit:end</code> 和 <code>astro:page-load</code> 再按真实地址校准一次。</p><p>覆盖层本身给了不透明的底色，three 首帧就绪之前不会透出下面的页面。</p></section>
<section><h2>几个小问题<a href="#几个小问题"><span>#</span></a></h2><section><h3>导航栏白字发白<a href="#导航栏白字发白"><span>#</span></a></h3><p>音乐页场景是深色的，导航栏得透明加白字。我写的是 <code>body.music-active #navbar &gt; div { background: transparent !important }</code>，结果导航栏还是浅色毛玻璃，白字叠上去就是发白。</p><p>原因是 <code>navbar.css</code> 里导航栏底色和阴影用的都是 <code>!important</code>，选择器还是 <code>html[data-has-wallpaper] #navbar[data-transparent-mode="…"] &gt; div</code> 这种。两条都带 <code>!important</code> 时，比的不是谁写在后面，是特异性。</p><p>改法是选择器也带上 <code>[data-transparent-mode]</code>，把特异性抬上去。顺手把 <code>--navbar-surface-blur</code> 清零，不然 <code>::before</code> 那层毛玻璃还在糊背景。</p><p>还有个连带的地方。下拉面板跟搜索、主题、音乐、显示设置面板共用 <code>.float-panel</code>，而且都挂在 <code>#navbar</code> 里。一开始只敢改 <code>.dropdown-content</code>，怕整片刷会把那几块面板的文字也刷白。结果留着的那几块成了”亮色面板配浅色文字”，比不改还难看，只能回头再收拾一遍。</p></section><section><h3>音乐页没了滚动条，导航栏会挪<a href="#音乐页没了滚动条导航栏会挪"><span>#</span></a></h3><p>这个问题只在返回的时候露出来：点回首页，导航栏肉眼可见地往左移了一下。</p><p>用浏览器一量就清楚了。音乐页的 <code>body.music-active</code> 上有 <code>overflow: hidden</code>，把页面滚动条去掉了。滚动条一没，视口可用宽度就多出整整一条 —— 本机是 15px，1249 变成 1264。导航栏外壳 <code>#top-row</code> 是 <code>fixed</code> 加 <code>mx-auto</code>，还挂着 <code>transition-all duration-700</code>，宽度一变，靠右和居中的导航项整体位移，再被那 700ms 过渡一放，就是一个明显的”往左挪”。</p><p>这里有个很容易量错的点：<code>#navbar</code> 自己的 <code>left</code> 两次都是 16，根本没变，变的是宽度。只盯着左边缘看，永远看不出问题，得量 <code>#top-row</code> 的宽度或者右边元素的 <code>right</code>。</p><p>改法是给根元素锁住滚动条槽位：<code>html { scrollbar-gutter: stable }</code>。这样布局宽度恒等于”视口宽减滚动条宽”，滚动条出现或消失都不再引起位移。首页本来就有滚动条、槽位被它占着，位置一点没变。</p><p>两个细节。<code>scrollbar-gutter</code> 必须挂 <code>html</code>，我试过挂 <code>body</code>，计算值确实生效了，但视口槽位不保留 —— gutter 不跟着 overflow 一起传播。另外不能只在音乐页加，离开音乐页时 <code>music-active</code> 是在内容替换之前就被摘掉的，那一瞬页面还没滚动条，宽度会先弹回去，反而抖一下。</p><p>锁完槽位还有个副作用：空出来的那一条露的是根元素背景，亮色主题下是浅色，在深色场景右边留一条亮边。音乐页把根背景刷成近黑就看不见了。</p></section><section><h3>切进音乐页没有歌词<a href="#切进音乐页没有歌词"><span>#</span></a></h3><p>在首页放着歌切进音乐页，歌词区一直停在”加载歌词”；但在音乐页刷新一下就一直正常。</p><p>歌词是事件驱动的（<code>fm:lyrics</code>），可视化器在初始化的时候才绑监听。问题就出在这条路径上：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>首页     取歌词 → 发 fm:lyrics        （监听器这时还没绑）</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></code></pre><div><div></div><div></div></div></figure></div><p>改法是初始化时主动从管理器状态里补一次，<code>getState()</code> 里本来就带着歌词数组和当前行号。</p><p>顺带修了个同类的：覆盖层隐藏时是 <code>display: none</code>，量出来的歌词高度全是 0；而换行事件只在行真正切换时才发，不会立刻把位置纠正回来，所以恢复显示的时候要重算一次滚动位置。</p></section><section><h3>hover 的高亮太亮<a href="#hover-的高亮太亮"><span>#</span></a></h3><p>导航栏一 hover 或者按下就亮一块，在那个深色场景上很刺眼。</p><p>那块底色不是元素自己的背景，是主题的 <code>expand-animation</code> 画在 <code>::before</code> 上的，亮色主题里用的颜色是 <code>oklch(0.95 0.025 …)</code>，基本就是白的。</p><p>排查的时候也绕了一下。读 <code>getComputedStyle(el).backgroundColor</code> 拿到的是透明，因为背景压根不在元素上。得读伪元素，<code>getComputedStyle(el, "::before").backgroundColor</code>，才看得到那块底色。</p><p>改法就是把音乐页的 <code>::before</code> 在 hover、聚焦、按下这几种状态下的底色换成半透明白。</p></section><section><h3>浮层面板还是亮的<a href="#浮层面板还是亮的"><span>#</span></a></h3><p>导航栏里不只有下拉菜单，搜索面板、主题面板、音乐面板、显示设置面板都挂在 <code>#navbar</code> 里，共用 <code>.float-panel</code>。只改 <code>.dropdown-content</code> 的话，那几块还是亮色毛玻璃，在深色场景上非常突兀。</p><p>更麻烦的是它们内部的文字走 Tailwind 的 <code>dark:</code> 变体。主题自定义了 <code>@custom-variant dark (&amp;:where(.dark, .dark *))</code>，意思是只要在面板自身或祖先挂上 <code>dark</code> 类，内部 <code>dark:text-*</code> 就会生效。所以修法分两步：给这几个面板统一加 <code>dark</code> 类，同时用 CSS 把 <code>.float-panel</code> 的背景、边框、阴影强制改成深色毛玻璃。</p><p>搜索框是另一个特例。它用的是亮色主题配色 <code>bg-black/4 text-black/30</code>，在音乐页的深色背景上等于完全隐形，但点击区域还在，于是出现”点得到却看不见”的怪现象。要单独覆盖成半透明白底白字。</p><p>显示设置面板又更隐蔽一层。它的分区标题 <code>.section-title</code> 在 <code>display-settings.css</code> 里默认是深色，dark 类按道理能让它变浅，但实际因为规则优先级或加载顺序，“主色色相""页面布局""卡片样式”这些标题还是几乎读不出来。最后是在 <code>music-visualizer.css</code> 里直接对 <code>body.music-active #display-setting .section-title</code> 强制浅色，并把面板里的 <code>--btn-content</code> 也翻成浅色，才把所有文字统一压住。</p></section><section><h3>移动端进音乐页会停一下<a href="#移动端进音乐页会停一下"><span>#</span></a></h3><p>桌面软导航后播放一直连着，手机却从首页进音乐页时会闪一下、然后播放暂停。反向（音乐页回首页）没事。</p><p>根子在 Web Audio 的接管时机。可视化器要拿音频数据，必须用 <code>createMediaElementSource(audio)</code> 把 <code>&lt;audio&gt;</code> 的输出改道进 AudioContext。如果 AudioContext 处于 <code>suspended</code> 状态（移动端没有用户手势就新建上下文，默认就是 suspended），这一接管会把正在播的声音直接闷掉。</p><p>桌面通常能直接 running，所以无感；移动端从首页点进音乐页时还没手势，于是播放被掐。</p><p>修法是把 <code>AudioAnalyzer</code> 拆成三步：<code>ensureContext()</code> 只建上下文和各节点、<code>ensureRunning()</code> 等用户交互把状态顶到 running、<code>connect()</code> 只在 running 时执行接管。如果当前跑不起来，先把 <code>&lt;audio&gt;</code> 记下来，挂一个一次性 <code>pointerdown</code>/<code>keydown</code> 监听，首个手势时再做接管。离开音乐页时不 disconnect，反向导航自然不受影响。</p></section></section>
<section><h2>结语<a href="#结语"><span>#</span></a></h2><p>到这里整个修复流程基本就结束了，现在整个网站的音乐都能一首歌不中断播放，而不是像之前的播放器和音乐页各放各的。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/14/</id>
      <title type="text">Fork Friend-Circle-Lite 后我做了什么</title>
      <published>2026-09-02T12:26:00.000Z</published>
      <updated>2026-09-02T12:26:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/14/"/>
      <summary type="text">我博客一直想要一个友链聚合页——把朋友们最近的 RSS 文章、友链可达性、主页截图汇到一页里。之前用的 checkflink 已经不再能满足我的使用要求，所以我又在原作者新项目的基础上进行了二开...</summary>
      <content type="html"><![CDATA[<section><h2>为什么又 Fork 了一个友链项目<a href="#为什么又-fork-了一个友链项目"><span>#</span></a></h2><p>我博客一直想要一个友链聚合页——把朋友们最近的 RSS 文章、友链可达性、主页截图汇到一页里。</p><p>之前用的 check-flink 撑不起这些了，后面看到原作者 willow-god 又搞了个新项目 Friend-Circle-Lite，于是 fork 下来做了二开。</p><a href="https://github.com/willow-god/Friend-Circle-Lite" target="_blank"><div><div><div><div></div><div>willow-god</div></div><div>/</div><div>Friend-Circle-Lite</div></div><div></div></div><div>🐱一个精简版，无后端，且仅利用github action运行的精简版友链朋友圈程序，兼容fc的json格式信息，同时支持推送友圈更新，支持他人订阅个人站点并在更新时发送邮箱推送。</div><div><div>—</div><div>—</div><div>NOASSERTION</div><span>Python</span></div></a><p>上游已经把核心跑通了，但有几个点跟我自己的用法对不上：友链真源只想放在 FCL 仓库、对外发布的 <code>friends.json</code> 字段名要对齐我的主题、Astro 这类客户端渲染的站反链抓不到、截图和告警没人管。</p><p>于是按自己的用法加了一些小功能。</p><a href="https://github.com/ImYufish/Friend-Circle-Lite" target="_blank"><div><div><div><div></div><div>ImYufish</div></div><div>/</div><div>Friend-Circle-Lite</div></div><div></div></div><div>🐱一个精简版，无后端，且仅利用github action运行的精简版友链朋友圈程序，兼容fc的json格式信息，同时支持推送友圈更新，支持他人订阅个人站点并在更新时发送邮箱推送。</div><div><div>—</div><div>—</div><div>NOASSERTION</div><span>Python</span></div></a><p>线上效果：<a href="https://fc.yufish.cn/" target="_blank">fc.yufish.cn</a>，本站的朋友圈、友链数据和截图延迟都来自这个项目。</p><hr /></section>
<section><h2>一、相对上游，我加了些什么<a href="#一相对上游我加了些什么"><span>#</span></a></h2><ul>
<li>新增 <code>spider_settings.source</code>：<code>local</code> / <code>remote</code> 切换开关</li>
<li><code>friends_input.rename</code> 读写共用一份桥接</li>
<li>加 Playwright 无头兜底，访问 JS 渲染站</li>
<li><code>eo_ping_url</code> 国内延迟探针 + <code>verified</code> 人工核验</li>
<li>主页截图 <code>postprocess.siteshot</code> + <code>refresh_days</code></li>
<li>地域屏蔽诊断 <code>postprocess.geo_diagnose</code></li>
<li>增加 Issue 自助申请友链 + 巡检回评</li>
<li>QQ 机器人 + 企业微信双通道告警</li>
<li>新增CNB平台（<code>.cnb.yml</code>）</li>
</ul><section><h3>真源 local/remote 开关 + 字段桥接<a href="#真源-localremote-开关--字段桥接"><span>#</span></a></h3><p>原版把友链真源远程读取 <code>json_url</code>。我加了个 <code>spider_settings.source</code> 开关：</p><ul>
<li><code>local</code>：真源在 FCL 仓库的 <code>friends.json</code>，Issue 申请通过后自动写回这里；</li>
<li><code>remote</code>：真源在外部博客的 <code>/friends.json</code> 端点，FCL 只巡检展示、不维护，对应 Issue 自助申请整套关闭。</li>
</ul><p><strong>同一份字段映射既管读取又管发布</strong>：我博客用 <code>title/siteurl/imgurl</code>，FCL 内部用 <code>name/link/avatar</code>，两边都不用迁就对方。配起来就是 <code>conf.yaml</code> 里这几行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>spider_settings</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>source</span><span>: </span><span>local</span></div></div><div><div><div>3</div></div><div><span>  </span><span>list_key</span><span>: </span><span>"friends"</span></div></div><div><div><div>4</div></div><div><span>  </span><span>friends_input</span><span>:</span></div></div><div><div><div>5</div></div><div><span>    </span><span>rename</span><span>:</span></div></div><div><div><div>6</div></div><div><span>      </span><span>name</span><span>: </span><span>"title"</span></div></div><div><div><div>7</div></div><div><span>      </span><span>link</span><span>: </span><span>"siteurl"</span></div></div><div><div><div>8</div></div><div><span>      </span><span>avatar</span><span>: </span><span>"imgurl"</span></div></div><div><div><div>9</div></div><div><span>      </span><span>linkpage</span><span>: </span><span>""</span></div></div><div><div><div>10</div></div><div><span>      </span><span>verified</span><span>: </span><span>""</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>值留空 <code>""</code> 表示直接用 FCL 原生字段名，不做映射；填了就按外部端点字段名取/重命名。这样不同的端点字段名不一也能零改动适配。</p></section><section><h3>反链检测：能认 JS 渲染的站了<a href="#反链检测能认-js-渲染的站了"><span>#</span></a></h3><p>原版只在静态 HTML 里找你的域名。可部分网站的友链是客户端渲染的，静态 HTML 里根本没有友链列表，反链永远判「无」。我加了 Playwright 无头渲染兜底（<code>backlink_headless</code>），没装 playwright 时自动回退静态匹配、不阻断主流程，装了就用无头浏览器渲染后再判定。</p></section><section><h3>可达性：国内探针 + verified 人工核验<a href="#可达性国内探针--verified-人工核验"><span>#</span></a></h3><p>由于GitHub Actions服务器在美国，访问国内网站延迟会失真，于是我加了 <code>eo_ping_url</code> 国内延迟探针，额外测一个国内视角并自动取优显示；另加了 <code>verified</code> 人工核验标记（默认关），用于给个别网站添加白名单，避免反链误报反复打扰。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>link_check</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>enable_backlink_check</span><span>: </span><span>true</span></div></div><div><div><div>3</div></div><div><span>  </span><span>author_url</span><span>: </span><span>"x1anyu.cn"</span></div></div><div><div><div>4</div></div><div><span>  </span><span>eo_ping_url</span><span>: </span><span>""</span><span>          </span><span># 留空则走仓库环境变量 EO_PING_URL</span></div></div><div><div><div>5</div></div><div><span>  </span><span>backlink_headless</span><span>: </span><span>true</span><span>  </span><span># 未装 playwright 自动跳过</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>主页截图会过期重拍<a href="#主页截图会过期重拍"><span>#</span></a></h3><p><code>postprocess.siteshot.refresh_days</code> 控制截图有效期，图龄超过天数自动重新截：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>postprocess</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>siteshot</span><span>:</span></div></div><div><div><div>3</div></div><div><span>    </span><span>enable</span><span>: </span><span>true</span></div></div><div><div><div>4</div></div><div><span>    </span><span>upload_folder</span><span>: </span><span>"friends"</span></div></div><div><div><div>5</div></div><div><span>    </span><span>max_workers</span><span>: </span><span>2</span></div></div><div><div><div>6</div></div><div><span>    </span><span>refresh_days</span><span>: </span><span>7</span><span>        </span><span># 7 天前的截图自动重拍；0 = 永久有效</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>自助申请友链 + 巡检回评<a href="#自助申请友链--巡检回评"><span>#</span></a></h3><p><code>apply-friend.yml</code> 支持 Issue 形式的友链自助申请：访客提交 <code>[友链申请]</code> Issue → 工作流解析 → Playwright 验证可访问性 + 反链 → 通过即写回 <code>friends.json</code>，失败打 <code>待更新</code> 标签。</p><p>主流程里再用 <code>github-script</code> 做巡检回评：不可达或反链丢失就自动给对应申请 Issue 回评、打标签，恢复了再撤。<code>verified</code> 站点不会因反链误报被反复打扰。</p></section><section><h3>后处理<a href="#后处理"><span>#</span></a></h3><p><code>postprocess</code> 拆成三段，开关集中在 <code>conf.yaml</code>，CI 只注入敏感信息与路径：</p><ul>
<li><code>geo_diagnose</code>：不可达站点地域屏蔽二次诊断（含国内视角二次探测）；</li>
<li><code>siteshot</code>：截图回填 + 图床上传；</li>
<li><code>alert</code>：状态翻转告警，QQ 机器人经 blog-bot 转发消息</li>
</ul><a href="https://github.com/ImYufish/blog-bot" target="_blank"><div><div><div><div></div><div>ImYufish</div></div><div>/</div><div>blog-bot</div></div><div></div></div><div>Description not set</div><div><div>—</div><div>—</div><div>no-license</div><span>JavaScript</span></div></a></section><section><h3>顺手补了 CNB 流水线<a href="#顺手补了-cnb-流水线"><span>#</span></a></h3><p>加了一份 <code>.cnb.yml</code>（cnb.cool 的流水线），和 GitHub Actions 二选一，跑的是同一组命令。</p><hr /></section></section>
<section><h2>二、整套数据流长什么样<a href="#二整套数据流长什么样"><span>#</span></a></h2><div><div><div><div><span><p>仅人工手推</p></span></div><div><span></span></div><div><span><p>pull/push</p></span></div><div><span><p>needs</p></span></div><div><span></span></div><div><span></span></div><div><span><p>tree比对变了才force</p></span></div><div><span><p>普通push</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>该验</p></span></div><div><span><p>pass</p></span></div><div><span><p>不pass</p></span></div><div><span><p>访客回复评论</p></span></div><div><span><p>写回真源</p></span></div><div><span></span></div><div><span><p>dispatch</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>友链方回复</p></span></div><div><span><p>friends.json · main 分支<br /><br />唯一真源</p></span></div><div><span><p>cache 分支<br /><br />cache.sqlite3 orphan 单文件</p></span></div><div><span><p>Friend Circle Lite 工作流<br /><br />schedule 每4h · dispatch · push</p></span></div><div><span><p>Job1 检测<br /><br />pull cache→抓RSS/可达性/反链<br /><br />→postprocess: 告警diff<br /><br />→Issue巡检回评→push cache<br /><br />→强推 page</p></span></div><div><span><p>Job2 截图 needs Job1<br /><br />Selenium 增量截友链主页<br /><br />普通 push page</p></span></div><div><span><p>Job3 保活 always</p></span></div><div><span><p>page 分支产物<br /><br />index/link/all/friends.json</p></span></div><div><span><p>Vercel · fc.yufish.cn</p></span></div><div><span><p>博客 friends.astro<br /><br />useRemote 渲染</p></span></div><div><span><p>Issue opened/reopened<br /><br />或 comment ·「友链申请」</p></span></div><div><span><p>解析·去重·闸门「待更新」</p></span></div><div><span><p>Playwright 核验<br /><br />可达性+反链含 AUTHOR_URL</p></span></div><div><span><p>pass→写回 friends.json<br /><br />关 Issue</p></span></div><div><span><p>不pass→打「待更新」开放</p></span></div><div><span><p>gh workflow run 主流程<br /><br />仅 shouldVerify</p></span></div><div><span><p>alert 检出状态翻转<br /><br />不可达/反链丢失/恢复<br /><br />持续离线档位/反链缺失</p></span></div><div><span><p>POST /api/alert</p></span></div><div><span><p>blog-bot Worker</p></span></div><div><span><p>QQ 主动消息→我<br /><br />失败兜底企微</p></span></div><div><span><p>巡检异常→评论Issue<br /><br />打待更新；恢复移除</p></span></div>
</div><div><div><span><p>仅人工手推</p></span></div><div><span></span></div><div><span><p>pull/push</p></span></div><div><span><p>needs</p></span></div><div><span></span></div><div><span></span></div><div><span><p>tree比对变了才force</p></span></div><div><span><p>普通push</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>该验</p></span></div><div><span><p>pass</p></span></div><div><span><p>不pass</p></span></div><div><span><p>访客回复评论</p></span></div><div><span><p>写回真源</p></span></div><div><span></span></div><div><span><p>dispatch</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>友链方回复</p></span></div><div><span><p>friends.json · main 分支<br /><br />唯一真源</p></span></div><div><span><p>cache 分支<br /><br />cache.sqlite3 orphan 单文件</p></span></div><div><span><p>Friend Circle Lite 工作流<br /><br />schedule 每4h · dispatch · push</p></span></div><div><span><p>Job1 检测<br /><br />pull cache→抓RSS/可达性/反链<br /><br />→postprocess: 告警diff<br /><br />→Issue巡检回评→push cache<br /><br />→强推 page</p></span></div><div><span><p>Job2 截图 needs Job1<br /><br />Selenium 增量截友链主页<br /><br />普通 push page</p></span></div><div><span><p>Job3 保活 always</p></span></div><div><span><p>page 分支产物<br /><br />index/link/all/friends.json</p></span></div><div><span><p>Vercel · fc.yufish.cn</p></span></div><div><span><p>博客 friends.astro<br /><br />useRemote 渲染</p></span></div><div><span><p>Issue opened/reopened<br /><br />或 comment ·「友链申请」</p></span></div><div><span><p>解析·去重·闸门「待更新」</p></span></div><div><span><p>Playwright 核验<br /><br />可达性+反链含 AUTHOR_URL</p></span></div><div><span><p>pass→写回 friends.json<br /><br />关 Issue</p></span></div><div><span><p>不pass→打「待更新」开放</p></span></div><div><span><p>gh workflow run 主流程<br /><br />仅 shouldVerify</p></span></div><div><span><p>alert 检出状态翻转<br /><br />不可达/反链丢失/恢复<br /><br />持续离线档位/反链缺失</p></span></div><div><span><p>POST /api/alert</p></span></div><div><span><p>blog-bot Worker</p></span></div><div><span><p>QQ 主动消息→我<br /><br />失败兜底企微</p></span></div><div><span><p>巡检异常→评论Issue<br /><br />打待更新；恢复移除</p></span></div>
</div></div></div><hr /></section>
<section><h2>三、博客侧怎么接（my-blog / Firefly 主题）<a href="#三博客侧怎么接my-blog--firefly-主题"><span>#</span></a></h2><p>我博客是 Firefly 主题。友链页面修改数据在线获取自 FCL，本地 <code>friendsConfig.ts</code> 作为兜底数据，同时写一份同步脚本每日同步，每天12点自动检测远程，有改动就自动添加，无改动则静默。</p><p><strong><code>src/config/friendsConfig.ts</code></strong> 里只开一个开关：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>friendsPageConfig</span><span>:</span><span> </span><span>FriendsPageConfig</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>useRemote</span><span>:</span><span> </span><span>true</span><span>,                       </span><span>// true = 拉 fc.yufish.cn 实时数据；false = 仅用本地兜底</span></div></div><div><div><div>3</div></div><div><span>  </span><span>remoteBaseUrl</span><span>:</span><span> </span><span>"https://fc.yufish.cn"</span><span>, </span><span>// 页面从此取 friends.json / link.json</span></div></div><div><div><div>4</div></div><div><span>  </span><span>imgProxy</span><span>:</span><span> {                            </span><span>// 封面图同域反代，走博客 CDN 避免访客直连图床</span></div></div><div><div><div>5</div></div><div><span>    </span><span>enabled</span><span>:</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>6</div></div><div><span>    </span><span>fromHost</span><span>:</span><span> </span><span>"imgbed.yufish.cn"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>    </span><span>toHost</span><span>:</span><span> </span><span>"x1anyu.cn"</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><code>src/pages/friends.astro</code></strong> 的拉取逻辑分两步、各自有回退：</p><ul>
<li>拉 <code>link.json</code> 拿封面截图和延迟状态；</li>
<li>拉 <code>friends.json</code> 拿友链清单，<strong>远程优先、失败回退到本地 <code>friendsConfig.ts</code></strong>；</li>
<li>客户端还做了 <code>fetch(..., { cache: "no-store" })</code> 的实时刷新，页面打开即重渲染。</li>
</ul><hr /></section>
<section><h2>四、机器人怎么接（blog-bot / Cloudflare Worker）<a href="#四机器人怎么接blog-bot--cloudflare-worker"><span>#</span></a></h2><p>博客机器人是一套跑在 Cloudflare Workers 上的服务。它给 FCL 提供了告警端点：</p>

<table><thead><tr><th>端点</th><th>方法</th><th>作用</th></tr></thead><tbody><tr><td><code>/api/alert</code></td><td>POST</td><td>FCL 后处理告警推送，QQ 单聊主动消息</td></tr></tbody></table><hr /></section>
<section><h2>五、部署：变量到底怎么配<a href="#五部署变量到底怎么配"><span>#</span></a></h2><p>这部分是踩坑后最该写清楚的地方——上游文档对 Secrets 语焉不详，我按真实工作流把变量列全。</p><section><h3>5.1 FCL 仓库：GitHub Actions + Vercel<a href="#51-fcl-仓库github-actions--vercel"><span>#</span></a></h3><p>流水线 <code>.github/workflows/friend_circle_lite.yml</code> 三个触发入口：每 4 小时定时（<code>00 */4 * * *</code>）、推 <code>friends.json</code> 到 <code>main</code> 自动巡检、<code>workflow_dispatch</code> 手动。步骤是：恢复 <code>cache</code> 分支缓存 → <code>run.py</code> 抓取检测 → <code>postprocess all</code> 后处理 → Issue 回评 → 组装静态产物强推 <code>page</code> 分支。并发做了串行化，避免定时 / 手动 / 截图 job 互相覆盖。</p><p>Vercel 这边 <code>vercel.json</code> 只一句，只从 <code>page</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>"git"</span><span>: { </span><span>"branch"</span><span>: </span><span>"page"</span><span>, </span><span>"deploymentEnabled"</span><span>: { </span><span>"main"</span><span>: </span><span>false</span><span> } }</span></div></div><div><div><div>3</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>缓存由根目录 <code>_headers</code> 控制（注意：Vercel 只读根 <code>_headers</code>，不读 <code>static/edgeone.json</code>，后者是死配置），给 <code>index.html</code> / <code>all.json</code> / <code>link.json</code> / <code>errors.json</code> / <code>friends.json</code> 设 <code>no-cache</code>，图片字体等长缓存。</p><p><strong>需要在 FCL 仓库 <code>Settings → Secrets and variables → Actions</code> 里配的 Secrets：</strong></p>

<table><thead><tr><th>Secret</th><th>用途</th><th>必填</th></tr></thead><tbody><tr><td><code>GITHUB_TOKEN</code></td><td>推 <code>page</code> 分支、触发 workflow、清理运行记录</td><td>内置自动提供，不用手动建</td></tr><tr><td><code>QQ_BOT_ALERT_URL</code></td><td>blog-bot 的 <code>/api/alert</code> 地址</td><td>想收告警则填</td></tr><tr><td><code>QQ_BOT_ALERT_TOKEN</code></td><td>与 blog-bot 的 <code>ALERT_TOKEN</code> 一致</td><td>同上</td></tr><tr><td><code>WECOM_WEBHOOK_URL</code></td><td>企业微信 webhook 兜底渠道</td><td>可选</td></tr><tr><td><code>IMG_UPLOAD_URL</code></td><td>图床上传端点（siteshot 用）</td><td>想用截图则填</td></tr><tr><td><code>IMG_AUTH_CODE</code></td><td>图床鉴权码</td><td>同上</td></tr><tr><td><code>PROXY_URL</code></td><td>代理地址（国内抓 RSS / 检测）</td><td>可选</td></tr><tr><td><code>EO_PING_URL</code></td><td>国内延迟探针端点（对应 <code>eo_ping_url</code>）</td><td>可选</td></tr><tr><td><code>AUTHOR_URL</code></td><td>反链检测的本站域名（<code>apply-friend.yml</code> 用，默认 <code>x1anyu.cn</code>）</td><td>可选</td></tr><tr><td><code>SMTP_PWD</code></td><td>邮件功能密码</td><td>基本不用（<code>email_push</code> / <code>rss_subscribe</code> 均关）</td></tr></tbody></table><p><code>conf.yaml</code> 里凡标注「推荐走环境变量、配置留空」的项，就对应上面这些 Secrets；填了 Secrets 后 <code>conf.yaml</code> 里保持空串即可。</p></section><section><h3>5.2 blog-bot：Cloudflare Worker<a href="#52-blog-botcloudflare-worker"><span>#</span></a></h3><p>非敏感变量写在 <code>wrangler.toml</code> 的 <code>[vars]</code> 里（随代码提交），敏感信息用 <code>wrangler secret put</code> 注入（不进仓库）：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>wrangler</span><span> </span><span>secret</span><span> </span><span>put</span><span> </span><span>WX_TOKEN</span><span>       </span><span># 企微回调 Token（只用企微才需要）</span></div></div><div><div><div>2</div></div><div><span>wrangler</span><span> </span><span>secret</span><span> </span><span>put</span><span> </span><span>WX_AESKEY</span><span>      </span><span># 企微 EncodingAESKey</span></div></div><div><div><div>3</div></div><div><span>wrangler</span><span> </span><span>secret</span><span> </span><span>put</span><span> </span><span>WX_CORPID</span><span>      </span><span># 企业 ID</span></div></div><div><div><div>4</div></div><div><span>wrangler</span><span> </span><span>secret</span><span> </span><span>put</span><span> </span><span>WX_SECRET</span><span>      </span><span># 应用 Secret</span></div></div><div><div><div>5</div></div><div><span>wrangler</span><span> </span><span>secret</span><span> </span><span>put</span><span> </span><span>ALERT_TOKEN</span><span>    </span><span># /api/alert 鉴权 token，须与 FCL 的 QQ_BOT_ALERT_TOKEN 一致</span></div></div><div><div><div>6</div></div><div><span>wrangler</span><span> </span><span>secret</span><span> </span><span>put</span><span> </span><span>GH_TOKEN</span><span>       </span><span># GitHub PAT（repo 权限），写文章/动态/开友链 Issue 用</span></div></div><div><div><div>7</div></div><div><span>wrangler</span><span> </span><span>secret</span><span> </span><span>put</span><span> </span><span>QQ_PRIVATE_KEY</span><span>  </span><span># QQ 机器人 Ed25519 私钥（只用 QQ 才需要）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>wrangler.toml</code> 里已固化的关键 <code>[vars]</code>：</p>

<table><thead><tr><th>变量</th><th>含义</th></tr></thead><tbody><tr><td><code>GH_OWNER</code> / <code>GH_REPO</code> / <code>GH_BRANCH</code> / <code>GH_PATH</code></td><td>博客仓库定位（写文章 / 动态）</td></tr><tr><td><code>FL_OWNER</code> / <code>FL_REPO</code> / <code>FL_BRANCH</code> / <code>FL_PATH</code></td><td>友链真源仓库定位（FCL 的 <code>friends.json</code>）</td></tr><tr><td><code>QQ_APPID</code> / <code>QQ_CALLBACK_PATH</code></td><td>QQ 机器人 AppID 与回调路径</td></tr><tr><td><code>SITE_URL</code> / <code>R2_PUBLIC_URL</code></td><td>站点域名与图床公开域名</td></tr><tr><td><code>DEPLOY_MODE</code></td><td><code>cf</code> = 纯 Cloudflare 自解密；<code>hybrid</code> = 作 EdgeOne 的 <code>/internal</code> 后端</td></tr></tbody></table><p>KV（草稿 / access_token 缓存）和 R2（图床）在 <code>wrangler.toml</code> 以 binding 形式声明，不是 Secret。</p><hr /></section></section>
<section><h2>六、怎么触发<a href="#六怎么触发"><span>#</span></a></h2><section><h3>自动触发<a href="#自动触发"><span>#</span></a></h3>

<table><thead><tr><th>时间</th><th>内容</th></tr></thead><tbody><tr><td>每 4 小时（<code>00 */4 * * *</code>）</td><td>抓取 + 检测 + 后处理全套</td></tr><tr><td>推 <code>friends.json</code> 到 <code>main</code></td><td>真源变更后立即重跑一轮</td></tr><tr><td>开 <code>[友链申请]</code> Issue</td><td>申请通过后自动写回并触发主检测</td></tr></tbody></table></section><section><h3>手动触发（按需）<a href="#手动触发按需"><span>#</span></a></h3><p>进仓库 <code>Actions → Friend Circle Lite → Run workflow</code>，只有一个参数：</p><ul>
<li><strong><code>target_link</code></strong>：填友链名称或 URL 关键词（多个用 <code>|</code> 或逗号分隔），只处理匹配的那几个；留空 = 按增量规则处理全部。改这个就能「只截某几个友链的图 / 只重测某几个」，不会把其他友链的数据冲掉。</li>
</ul></section><section><h3>自助申请<a href="#自助申请"><span>#</span></a></h3><p>在我博客友链页填表单 → 开 <code>[友链申请]</code> Issue → <code>apply-friend.yml</code> 解析并 Playwright 验证 → 通过写回 <code>friends.json</code>。失败会打 <code>待更新</code> 标签，修复后在 Issue 下回任意内容即可重验。</p><hr /></section></section>
<section><h2>七、常见场景<a href="#七常见场景"><span>#</span></a></h2>

<table><thead><tr><th>我想…</th><th>怎么做</th></tr></thead><tbody><tr><td>日常看看谁挂了</td><td>等每 4h 自动跑，或 Actions → Run workflow（target_link 留空）</td></tr><tr><td>新加了个友链，只测它</td><td>Run workflow，target_link 填名字或 URL 关键词</td></tr><tr><td>给别人加友链</td><td>在 friends.json 添加友链 → 自动触发验证</td></tr><tr><td>某友链失效被标「待更新」</td><td>修复后在对应 Issue 下回任意内容 → 自动重验 → 通过撤标签</td></tr><tr><td>截图太旧了</td><td>Run workflow 填 target_link 触发增量截图，或等 <code>refresh_days</code> 自动重拍</td></tr></tbody></table><hr /></section>
<section><h2>八、几个设计取舍<a href="#八几个设计取舍"><span>#</span></a></h2><p><strong>截图会过期，且有兜底链。</strong> <code>refresh_days</code> 控制图龄，超期自动重拍；截图上传走图床，图床挂了也有在线兜底服务，不会白屏。Selenium 那套在 CI 装 Chrome 要两三分钟，所以截图拆成独立 job，不和每 4h 的轻量检测抢时间。</p><p><strong>反链用 <code>verified</code> 挡误报。</strong> 客户端渲染的站靠 Playwright 兜底能认，但仍有个别反爬站抓不到。与其天天被误报吵，不如手动标 <code>verified</code> 让巡检跳过它的反链检查——这是给站长自己留的「我确认过」开关。</p><p><strong>状态翻转才告警，不是每轮都推。</strong> <code>notify.run</code> 先比对本轮和上轮 <code>link.json</code>，没变化就静默；有变化且 <code>alert.enable</code> 开着才推。QQ 通道失败会自动降级到企业微信，两条都没配就跳过，绝不阻断主流程。</p><p><strong>真源只维护一份。</strong> 友链清单只在 FCL 仓库的 <code>friends.json</code> 维护（local 模式），博客自动拉取、连本地兜底都是脚本同步出来的。加友链只需要在这边添加，不用两边改。</p><hr /></section>
<section><h2>收尾<a href="#收尾"><span>#</span></a></h2><p>fork 一个别人维护得挺好的项目，最舒服的地方就是能在它结实的地基上，按自己的用法挑几处改。截图、地域诊断、Playwright 反链兜底、Issue 自助申请、QQ 告警、CNB——这些都不是多高深的东西，但拼起来正好适合我的使用场景。</p><hr /></section>
<section><h2>相关链接<a href="#相关链接"><span>#</span></a></h2><ul>
<li>我的项目：<a href="https://github.com/ImYufish/Friend-Circle-Lite" target="_blank">github.com/ImYufish/Friend-Circle-Lite</a></li>
<li>上游原版：<a href="https://github.com/willow-god/Friend-Circle-Lite" target="_blank">github.com/willow-god/Friend-Circle-Lite</a></li>
<li>机器人：<a href="https://github.com/ImYufish/blog-bot" target="_blank">https://github.com/ImYufish/blog-bot</a></li>
<li>线上演示：<a href="https://fc.yufish.cn/" target="_blank">fc.yufish.cn</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/13/</id>
      <title type="text">Sonic Topography 改造全记录：从 Electron 桌面应用到纯静态网页</title>
      <published>2026-08-29T14:13:00.000Z</published>
      <updated>2026-08-29T14:13:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/13/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>上次弄完博客里的音乐页面后才发现，那个页面也是从 Sonic Topography 获取灵感改造的，做完感觉比原版还要炫酷一些，那还说啥了，继续折腾。<br />
Sonic Topography 原本是个 Electron 桌面应用，这次把它整个搬进浏览器。</p>
<p>演示地址: <a href="https://music.x1anyu.cn/" target="_blank">music.x1anyu.cn</a></p>
<section><h2>一、从 Electron 砍到纯静态<a href="#一从-electron-砍到纯静态"><span>#</span></a></h2><section><h3>砍掉的三层<a href="#砍掉的三层"><span>#</span></a></h3><p>动手前项目里有三块桌面专属的东西：<code>desktop/</code>（Electron 主进程、窗口、菜单）、<code>server/</code>（本地后端，干网易云/QQ 反代和 Cookie 转发的活）、<code>local-server.mjs</code>（开发态本地服务）。连 <code>package.json</code> 里的 electron 依赖和打包脚本一块删掉，项目立马轻了一截。</p><p><code>vite.config.ts</code> 里原来挂了个 <code>neteaseApiPlugin</code>，开发态把网易云接口代理到本地后端。删掉它，vite 的 <code>plugins</code> 就剩 <code>react()</code> 和 <code>tailwindcss()</code>，成了一次纯粹的前端构建，开发态不再有后端。</p></section><section><h3>三个决策<a href="#三个决策"><span>#</span></a></h3><p>第一个是 <code>base: './'</code>。桌面端资源都在本地，无所谓；但纯网页常常被丢到子目录或对象存储的某个路径下，构建产物要是写死 <code>/assets/xxx</code> 这种绝对路径，一放非根目录就整页 404。把 vite 的 <code>base</code> 设成 <code>'./'</code>，产物全走相对路径，<code>dist/</code> 扔哪都能跑——子目录、对象存储、EO Pages 都行。</p><p>第二个是状态。后端没了，歌单和设置没地方放，最顺手的去处就是浏览器 localStorage（<code>musicApi.ts</code> + <code>uiStorage.ts</code>，歌单 key 是 <code>sonic-topography-playlists-v1</code>）。好处是零后端、打开即用，代价是状态全绑在这台浏览器上——换设备、清缓存，歌单就没了。</p><p>第三个是音源，换成 Meting。网页端其实只走两路：Meting 接口（默认 <code>tencent</code>，就是 QQ 音乐）+ 本地文件上传。启动时默认加载一个 Meting 歌单（<code>DEFAULT_METING_PLAYLIST_ID = '7991874132'</code>）并放第一首，免得进来是张空白页。</p></section></section>
<section><h2>二、Meting 对接与封面跨域污染<a href="#二meting-对接与封面跨域污染"><span>#</span></a></h2><section><h3>为什么是 Meting<a href="#为什么是-meting"><span>#</span></a></h3><p>实际对接的是初叶 Meting（metowolf 的衍生版），不是 metowolf 原版，接口细节有点差别，下面写的都是实测过的。它响应统一带 <code>Access-Control-Allow-Origin: *</code>，配合 <code>AudioEngine</code> 的 <code>crossOrigin="anonymous"</code>，音频能播也能拿去做频谱可视化，正好补上「没登录态也能播」这个缺口。</p></section><section><h3>接口对接<a href="#接口对接"><span>#</span></a></h3><p>基址是 <code>/api</code>，不是根路径。初叶的 base 长 <code>https://meting.yufish.cn/api</code> 这样，解析路径是 <code>/?server=&amp;type=&amp;id=</code>。漏掉 <code>/api</code> 直接 404。前端 <code>metingApi.ts</code> 里的 <code>METING_BASE</code> 一定得带这个后缀。</p><p>返回字段是 <code>title</code> / <code>author</code> / <code>url</code> / <code>pic</code> / <code>lrc</code>。注意 <code>url</code> / <code>pic</code> / <code>lrc</code> 这三个不是真实资源的直链，而是接口端点，各自再 302 跳到真实资源。</p><ul>
<li><code>type=lrc</code> 返回的是纯文本歌词（不是 JSON）。</li>
<li><code>type=url</code> 返回的是音频端点，302 跳到真实音频 CDN，同样带 <code>Access-Control-Allow-Origin: *</code>，所以音频可视化没问题。</li>
</ul><p>地址解析有优先级。运行时可覆盖的 Meting 地址按这个顺序取：localStorage <code>sonic-topography:meting-api</code> → <code>window.__SONIC_METING_API__</code> → <code>public/meting-config.json</code> 的 <code>base</code> → 环境变量 <code>VITE_METING_API</code>。部署时填个自建地址就能切走公共实例。默认搜索源设成 <code>'meting'</code>。</p></section><section><h3>封面跨域污染<a href="#封面跨域污染"><span>#</span></a></h3><p>状态、构建、音源都顺了，下一个坑出在 3D 背景封面上。它时好时坏，特别容易误判。</p><p><code>pic</code> 返回的是接口端点，真实图片要 302 跳到腾讯图片 CDN（<code>y.gtimg.cn</code> / <code>y.qq.com</code>，偶尔 <code>qpic.cn</code>）。这些 CDN 不返回 <code>Access-Control-Allow-Origin</code>。后果分两种：</p><ul>
<li>播放条封面用的是 <code>&lt;img&gt;</code>。浏览器加载 <code>&lt;img&gt;</code> 不读像素，跨域限制不拦它，所以播放条封面一直正常显示，这也是我一开始没察觉的原因。</li>
<li>3D 背景封面用的是 <code>THREE.TextureLoader</code>，WebGL 贴图要读像素。CDN 没 CORS 头，纹理被标成跨域污染，加载失败，背景封面就不显示。</li>
</ul><p>同一张图，一个地方好使一个地方坏。根子就在 CORS 污染：<code>&lt;img&gt;</code> 能看不能读，WebGL 既要看又要读。</p><p>修法是在交给 <code>TextureLoader</code> 之前，把已知缺 CORS 的图床主机先包一层图片代理：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>resolveCoverUrl</span><span><span>(</span><span>pic</span></span><span><span>?</span><span>:</span></span><span> </span><span>string</span><span>)</span><span>:</span><span> </span><span>string</span><span> </span><span>|</span><span> </span><span>undefined</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>pic</span><span>) </span></span><span>return</span><span> </span><span>undefined</span><span>;</span></div></div><div><div><div>3</div></div><div><span>  </span><span>// 腾讯图床不返 CORS，WebGL 读像素会被污染 → 走图片代理补 CORS</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>noCorsHosts</span><span> </span><span>=</span><span> [</span><span>'y.gtimg.cn'</span><span>, </span><span>'y.qq.com'</span><span>, </span><span>'qpic.cn'</span><span>];</span></div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span><span> (</span><span>noCorsHosts</span><span>.</span></span><span>some</span><span><span>(</span><span>h</span><span> </span></span><span>=&gt;</span><span><span> </span><span>pic</span><span>.</span></span><span>includes</span><span><span>(</span><span>h</span><span>))) {</span></span></div></div><div><div><div>6</div></div><div><span>    </span><span>const</span><span> </span><span>proxy</span><span> </span><span>=</span><span><span> </span><span>localStorage</span><span>.</span></span><span>getItem</span><span>(</span><span>'sonic-topography:meting-pic-proxy'</span><span>)</span></div></div><div><div><div>7</div></div><div><span>      </span><span>||</span><span> </span><span>'https://proxy-api.x1anyu.cn/pic/?url='</span><span>;</span></div></div><div><div><div>8</div></div><div><span>    </span><span>return</span><span><span> </span><span>proxy</span><span> </span></span><span>+</span><span> </span><span>encodeURIComponent</span><span><span>(</span><span>pic</span><span>);</span></span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>10</div></div><div><span>  </span><span>return</span><span><span> </span><span>pic</span><span>;</span></span></div></div><div><div><div>11</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>自建的 edgeone <code>/pic</code> 图片代理（相当于 weserv 的角色，但跑在广州节点、同源可控）会返回 <code>Access-Control-Allow-Origin: *</code>，污染就解了。代理默认就是它，也能经 localStorage <code>sonic-topography:meting-pic-proxy</code> 改回 weserv 或任何自建端点。</p></section></section>
<section><h2>三、移动端适配<a href="#三移动端适配"><span>#</span></a></h2><section><h3>断点怎么定<a href="#断点怎么定"><span>#</span></a></h3><p>上游只有一条 CSS 断点 <code>@media (max-width: 600px)</code>，压根没有 JS 侧的 <code>isMobile</code>。第一件事就是加一个 JS 媒体查询，在组件里分流布局：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>MOBILE_MQ</span><span> </span><span>=</span><span> </span><span>'(max-width: 768px), (pointer: coarse)'</span><span>;</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>isMobile</span><span> </span><span>=</span><span> </span><span>typeof</span><span><span> </span><span>window</span><span> </span></span><span>!==</span><span> </span><span>'undefined'</span></div></div><div><div><div>3</div></div><div><span>  </span><span>?</span><span><span> </span><span>window</span><span>.</span></span><span>matchMedia</span><span>(</span><span>MOBILE_MQ</span><span>).</span><span>matches</span></div></div><div><div><div>4</div></div><div><span>  </span><span>:</span><span> </span><span>false</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>pointer: coarse</code> 是关键——它盯的是「主输入设备是不是粗指针（手指）」，不是屏幕宽度。触屏笔记本、平板会命中，普通桌面显示器不会。这样切出来的 <code>isMobile</code> 在真机上才准，不会误伤那些逻辑分辨率卡在 768 附近、实际是鼠标 + 触屏混用的设备。</p><p>顺带把 CSS 断点从 <code>600px</code> 提到 <code>768px</code>，跟这条 JS 断点对齐。不然 JS 判 <code>isMobile</code> 和 CSS 改样式会在同一台设备上各判各的，出现「JS 当手机、CSS 当桌面」的错位。</p></section><section><h3>思路分叉：isMobile 分支 vs 纯 CSS 覆盖<a href="#思路分叉ismobile-分支-vs-纯-css-覆盖"><span>#</span></a></h3><p>确定哪些地方是手机布局、哪些是桌面布局之后，有两种做法：</p><ul>
<li>A. CSS 覆盖：同一套 JSX，用 <code>@media</code> 把桌面样式改写成手机样式。</li>
<li>B. JSX 里 <code>isMobile ? &lt;手机布局&gt; : &lt;桌面布局&gt;</code> 直接分流。</li>
</ul><p>我一开始想走 A，少写重复结构。撞到的第一个坎是 Tailwind v4 下，某些 transform 类用 CSS 覆盖不掉。具体说，桌面端播放条某个元素用了 <code>translate-x-*</code> / <code>-translate-x-full</code> 这类工具类，我在 <code>@media (max-width: 768px)</code> 里写 <code>transform: none !important</code> 想压平，结果没生效。不是 <code>!important</code> 没写，是 Tailwind v4 的生成规则在那个具体类上赢了层叠和内联优先级。</p><p>于是下决心走 B：凡是手机和桌面布局差到「要改 transform / 要换元素结构」的地方，直接 <code>isMobile ? ... : ...</code> 在 JSX 层分成两套。桌面那套的 transform 类根本不参与手机渲染，从根上避开覆盖之争。CSS 只留「同一个 DOM、只是间距字号不同」的微调，比如按钮 <code>min-height: 46px</code>、<code>font-size: 14px</code> 这种纯数值覆盖。</p><p>说白了就是：结构或 transform 差异大就 JSX 分支，纯数值微调就 CSS 覆盖。两种混着用最稳，非要只用一种反而麻烦。</p></section><section><h3>最坑的一处：hover 触发条吞掉所有 touch<a href="#最坑的一处hover-触发条吞掉所有-touch"><span>#</span></a></h3><p>桌面端那个左侧菜单的触发，最早是挂在一个「hover 就展开」的整高触发条上（<code>side-nav-trigger</code> / <code>side-nav-trigger-right</code>，<code>absolute left-0 top-0 h-full</code> + <code>pointer-events-auto</code>），靠 <code>onMouseEnter</code> 展开、<code>onMouseLeave</code> 收起。桌面鼠标 hover 没问题。</p><p>到了手机上，这条触发条是一整条 <code>pointer-events-auto</code> 覆盖层，把左缘的 touch 全吞了——点歌单按钮、滑菜单，全没反应，事件根本传不到下面真实的按钮上。</p><blockquote><p>⚠️ 大坑：任何「悬停才出现/才触发」的交互，在手机上必须落地成一次明确的 tap，否则整片覆盖层会吞掉下面所有 touch 事件。</p></blockquote><p>修法：移动端把触发条的 <code>pointer-events</code> 设成 <code>none</code>、给 <code>onMouseEnter</code> 加个 <code>if (isMobile) return</code>，hover 展开在手机上直接失效；真正打开菜单交给左上角齿轮按钮的 <code>onClick</code> 切换 <code>isMobileSideNavOpen</code>。</p></section><section><h3>手机端交互<a href="#手机端交互"><span>#</span></a></h3><p>左侧菜单超长可滚动。菜单项一多，手机竖屏塞不下。原来 <code>justify-center</code> 居中，超长底部就被裁。改成 <code>justify-start gap-6 overflow-y-auto</code>，菜单比屏长就自己滚，不再丢项。</p><p>播放条按钮均分。手机播放条独立一行，8 个按钮（菜单 / 循环 / 上一首 / 播放 / 下一首 / 歌词 / 主题 / 音量）用 <code>flex</code> 均分，触摸目标够大。音量不是常驻滑块，是点一下弹个竖向滑块：<code>showMobileVolume</code> 状态 + 一个 <code>writing-mode: vertical-lr; direction: rtl</code> 的竖向 <code>range</code>（<code>.vol-vertical</code>），松开再收起来。手机横屏本来就窄，常驻横向音量条太占地方，弹窗更顺手。</p></section><section><h3>手机端歌词默认 3D 环绕<a href="#手机端歌词默认-3d-环绕"><span>#</span></a></h3><p>Sonic Topography 有三种歌词样式：<code>songyancai</code>（默认，2D 时间线）、<code>dynamic-bounce</code>（弹跳）、<code>spatial-wall</code>（3D 环绕，渲染在 Three.js 画布里）。</p><p>手机上其实更想要 3D 环绕那种沉浸感。我在 <code>App.tsx</code> 初始化 <code>lyricsSettings</code> 时做了个判断：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span><span> { </span><span>STORAGE_KEY</span><span> </span><span>as</span><span> </span></span><span>LYRICS_SETTINGS_STORAGE_KEY</span><span> } </span><span>from</span><span> </span><span>'./lib/lyricsSettings'</span><span>;</span></div></div><div><div><div>2</div></div><div><span>// ...</span></div></div><div><div><div>3</div></div><div><span>const</span><span> </span><span>stored</span><span> </span><span>=</span><span> </span><span>readLyricsSettingsStorage</span><span>();</span></div></div><div><div><div>4</div></div><div><span>const</span><span> </span><span>isMobileInit</span><span> </span><span>=</span></div></div><div><div><div>5</div></div><div><span>  </span><span>typeof</span><span><span> </span><span>window</span><span> </span></span><span>!==</span><span> </span><span>'undefined'</span><span> </span><span>&amp;&amp;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>window</span><span>.</span><span>matchMedia</span><span>(</span><span>'(max-width: 768px), (pointer: coarse)'</span><span>).</span><span>matches</span><span>;</span></div></div><div><div><div>7</div></div><div><span>const</span><span> </span><span>initial</span><span> </span><span>=</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>isMobileInit</span><span> </span><span>&amp;&amp;</span><span> </span><span>!</span><span><span>localStorage</span><span>.</span></span><span>getItem</span><span>(</span><span>LYRICS_SETTINGS_STORAGE_KEY</span><span>)</span></div></div><div><div><div>9</div></div><div><span>    </span><span>?</span><span> { </span><span>...</span><span><span>stored</span><span>, </span></span><span>style</span><span>:</span><span> </span><span>'spatial-wall'</span><span> }</span></div></div><div><div><div>10</div></div><div><span>    </span><span>:</span><span><span> </span><span>stored</span><span>;</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>要点：</p><ul>
<li>只在手机端、且用户从没自定义过歌词样式时，才默认改成 <code>spatial-wall</code>。桌面端沿用改后的全局默认 <code>songyancai</code>。有个容易忽略的点：上游全局默认其实是 <code>spatial-wall</code>，这轮我们把它翻成了 <code>songyancai</code>，再用这条 override 给手机端恢复 3D 环绕。所以「手机端 3D 环绕」严格说是「恢复」不是「新加」。</li>
<li>只改运行时 state，不写 localStorage。用户一旦在设置里手动换过样式就会持久化，之后尊重他的选择；老用户也不会被强制改回。这是「默认」和「强制」的区别。</li>
<li><code>spatial-wall</code> 是在 3D 画布里渲染的，2D 的 <code>LyricsDisplay</code> 在 <code>style === 'spatial-wall'</code> 时直接 <code>return null</code>。所以手机端默认开启后，2D 歌词浮层让位给 3D 环绕歌词。</li>
</ul><p>顺带提一个之前留的坑：<code>.lyrics-main-container</code> 那条移动端 CSS 写着「关闭 3D 倾斜避免溢出与变形」。它只作用于 2D 歌词容器的内联 transform，对 <code>spatial-wall</code>（走 3D 画布）完全没影响，所以这次默认开 3D 环绕不用动它。当初写它是为了防止 2D 歌词在窄屏被 transform 撑变形，跟 3D 环绕是两码事。</p></section><section><h3>顺手修的四个小毛病<a href="#顺手修的四个小毛病"><span>#</span></a></h3><p>重排过程中，几个一直没改好的细节也一起收掉了：</p><p>歌名别拼歌手。原来桌面歌名显示成 <code>周杰伦 - 红尘客栈</code> 这种「歌手 - 歌名」合并串。根子是 <code>trackName</code> 状态在加载歌曲时拼成了 <code>${artist} - ${name}</code>，桌面 meta 直接用了它。改法是桌面歌名改用 <code>currentSong?.name</code>（手机端早这么用了），歌手单独走下面那行 <code>currentSong?.artist</code>。<code>trackName</code> 这个组合串我没删，它还给 3D 标题和「无歌时的禁用判断」用，属于无害残留，没必要为了干净把依赖它的地方也改崩。</p><p>关掉歌名滚动。原来歌名是跑马灯（<code>MarqueeTitle</code> 组件，两份重复 span + CSS 动画）。桌面空间够，根本不用滚，反而晃眼。直接换成静态 <code>&lt;div className="truncate ..."&gt;</code>，超长就省略号，不再动。</p><p>左上角齿轮贴边 + 移动端常显。上游品牌齿轮（<code>brand-mark</code>）定位在 <code>top-[88px] left-[56px]</code>，而且没有那圈半透明圆——初稿里「原来套了一圈光晕」是我写错了，那圈 <code>rounded-full border border-white/15 bg-black/30</code> 其实是我们加在右菜单按钮的移动端分支上的，不是上游基线。真正改的是齿轮位置：移动端从 <code>top-[88px] left-[56px]</code> 移到 <code>top-[10px] left-[10px]</code> 贴左上角，可见条件从「仅 <code>showLeftIcon</code>」放宽成「移动端永远显示」（<code>(displaySettings.showLeftIcon || isMobile) &amp;&amp; !(isMobile &amp;&amp; isRightSidebarOpen)</code>）。要是你觉得右菜单按钮那圈圆角边框碍眼，它现在确实还在，可以一并去掉。</p><p>菜单点开关不了。播放条上的菜单按钮，原来 <code>onClick</code> 只干一件事：<code>setIsRightSidebarOpen(true)</code>，只能开不能关，再点一次当然没反应。改成 toggle：开着就关，关着就开并切到歌单视图：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>onClick</span><span>=</span><span>{() =&gt; {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>if</span><span><span> (</span><span>isRightSidebarOpen</span><span>) </span></span><span>setIsRightSidebarOpen</span><span>(</span><span>false</span><span>);</span></div></div><div><div><div>3</div></div><div><span>  </span><span>else</span><span> { </span><span>setIsRightSidebarOpen</span><span>(</span><span>true</span><span>); </span><span>setMobileRightView</span><span>(</span><span>'list'</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>改完再点菜单按钮，开合就正常了。到这里，从桌面到手机，网页版总算能当个正经播放器用了。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/11/</id>
      <title type="text">博客音乐播放器：3D 频谱地形与 Meting 接入</title>
      <published>2026-08-28T18:24:21.000Z</published>
      <updated>2026-08-28T18:24:21.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/11/"/>
      <summary type="text">给博客加了个能看的音乐页——Three.js 频谱方块地形 + Meting API 直连QQ音乐。</summary>
      <content type="html"><![CDATA[<p>一直想要个能看的音乐页，不是塞个 APlayer 列表就完事那种。后来看到 <a href="https://blog.tsh520.cn/" target="_blank">团子和蛋糕</a> 的音乐页面，正是我想要的效果，就照着这个搬过来、接上自己的歌单。</p>
<section><h2>三层分工<a href="#三层分工"><span>#</span></a></h2><p>MusicManager 是挂在 Layout 上的全局单例，负责拉歌单、管 <code>&lt;audio&gt;</code> 元素、做 Web Audio 频谱分析，是真正的”大脑”。MusicVisualizer 只管可视化：Three.js 那块 InstancedMesh 方块、着色器、OrbitControls，音频数据从 Manager 拿，驱动地形起伏。music.astro 就是个页面壳，把可视化挂上去。</p><p>为什么要做成全局单例、而不是只放在 /music/ 页里？因为 Firefly 的页面切换走的是 Swup 软导航——切页面不整页重载、不重跑脚本。播放器想跨页面连续播，就必须活在被保留的那一层（Layout）里。</p><p>数据源用 Meting API，配置只有一行 <code>meting.api</code>，歌单 id 写死在配置里。接口自己掌控的好处是灵活，代价是 cookie 和可用性得自己维护。</p></section>
<section><h2>频谱怎么变成地形起伏<a href="#频谱怎么变成地形起伏"><span>#</span></a></h2><p>链路本身不长：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>source</span><span> </span><span>=</span><span><span> </span><span>audioCtx</span><span>.</span></span><span>createMediaElementSource</span><span><span>(</span><span>audioEl</span><span>); </span></span><span>// 把 &lt;audio&gt; 接进 Web Audio</span></div></div><div><div><div>2</div></div><div><span><span>source</span><span>.</span></span><span>connect</span><span><span>(</span><span>analyser</span><span>);</span></span></div></div><div><div><div>3</div></div><div><span><span>analyser</span><span>.</span></span><span>connect</span><span><span>(</span><span>audioCtx</span><span>.</span></span><span>destination</span><span>);                    </span><span>// 这根不能省</span></div></div><div><div><div>4</div></div><div><span>// 每帧</span></div></div><div><div><div>5</div></div><div><span><span>analyser</span><span>.</span></span><span>getByteFrequencyData</span><span><span>(</span><span>freqData</span><span>);                   </span></span><span>// 0–255 的实时频点能量</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>freqData</code> 是一串 0–255 的频点能量，地形就是拿它去驱动 InstancedMesh 里每个方块的高度——方块数量固定，每帧只改矩阵、不重建几何体，所以铺几万个也不卡。</p><p>两个通用但容易忽略的点：</p><ul>
<li><code>analyser.connect(destination)</code> 这根线必须接。分析器不是”旁路监听”，它是<strong>串联</strong>在链路上的——接了分析器却没接输出，声音直接没了。</li>
<li><code>createMediaElementSource</code> 对同一个 <code>&lt;audio&gt;</code> 元素<strong>只能调一次</strong>，重复调用会抛 <code>InvalidStateError</code>。所以初始化那里要有锁，否则 Swup 反复进出页面就会炸。</li>
</ul></section>
<section><h2>配置里两个要注意的细节<a href="#配置里两个要注意的细节"><span>#</span></a></h2><p><strong><code>meting.api</code> 一定要写全 <code>https://</code>。</strong> 漏了协议头，浏览器把请求当成相对路径拼到当前页域名上，变成 <code>x1anyu.cn/music/meting.yufish.cn/...</code>，直接 404，歌单就拉不到。</p><p><strong><code>&lt;audio&gt;</code> 必须设 <code>crossOrigin="anonymous"</code>。</strong> 否则跨域音频会被浏览器判定为”污染”，Web Audio 的 analyser 读到的永远是 0，地形一动不动。这个属性要在元素创建时就设上，等 <code>src</code> 赋值之后再补是无效的。</p></section>
<section><h2>一点小问题<a href="#一点小问题"><span>#</span></a></h2><p><strong>地形不动。</strong> MusicVisualizer 要靠 <code>mgr.getAudio()</code> 拿 audio 元素去做 <code>createMediaElementSource</code>，但 MusicManager 的公开 API 里压根没有 <code>getAudio</code>——拿到的是 <code>null</code>，代码就走进了”等一会儿再试”的 <code>setTimeout</code> 死循环，分析器从头到尾没连上。补上 <code>getAudio: function () { return audio; }</code> 之后地形立刻活了。</p><p>这个 bug 的表现很有迷惑性：<strong>歌照放、进度条照走、UI 一切正常，只有地形是死的</strong>。因为播放本身根本不经过 Web Audio，只有可视化依赖它。所以排查时很容易往”着色器写错了""数据没传到”那边想，实际是源头就没接上。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/10/</id>
      <title type="text">自建 Meting API 并让国内访问不再慢：Vercel 香港节点 + 歌词代理 + 边缘缓存</title>
      <published>2026-08-27T00:00:00.000Z</published>
      <updated>2026-08-27T00:00:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/10/"/>
      <summary type="text">把 Meting-UI-API 部署到 Vercel 后遇到的两个真问题——国内访问慢、QQ 音乐歌词拿不到，以及对应的三处优化。</summary>
      <content type="html"><![CDATA[<section><h2>为什么不直接用现成的 API<a href="#为什么不直接用现成的-api"><span>#</span></a></h2><p>主题 Meting 默认指向的公共 API 用不了了，找了一圈也没几个能长期用的公共实例。于是干脆把 <a href="https://github.com/chuyegzs/Meting-UI-API" target="_blank">Meting-UI-API</a> 这个 fork 自己部署了一份，跑在 Vercel 上。</p><p>本以为克隆个仓库、设两个环境变量就完事，上线后才发现两个绕不开的问题：国内访问慢，以及 QQ 音乐的歌词拿不到。这篇记录怎么填这两个坑，以及哪几步才是真正起作用的。</p></section>
<section><h2>慢的原因：Vercel 函数区域<a href="#慢的原因vercel-函数区域"><span>#</span></a></h2><p>Vercel 函数默认部署在美国东部（IAD1），这才是国内访问慢的原因。</p><p>点一首歌，播放器实际会向 API 发 4 次请求：<code>song → url → pic → lrc</code>。我在中国，Vercel 在美国。每一次请求都是一次跨境往返，叠加起来就是 4 倍 RTT。</p><p>把函数区域从美东改到**香港（hkg1）**后，体感是立竿见影的——链路短了一大截，那 4 次往返的叠加延迟直接砍掉。这一步比任何代码层面的优化都直接。</p><p><strong>最好的做法是直接写死在 <code>vercel.json</code></strong>，免得哪天换机器、重新部署后忘了在控制台设：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"env"</span><span>: { </span><span>"RUNTIME"</span><span>: </span><span>"vercel"</span><span> },</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"functions"</span><span>: {</span></div></div><div><div><div>4</div></div><div><span>    </span><span>"api/index.js"</span><span>: { </span><span>"regions"</span><span>: [</span><span>"hkg1"</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>"rewrites"</span><span>: [</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>{ </span><span>"source"</span><span>: </span><span>"/(.*)"</span><span>, </span><span>"destination"</span><span>: </span><span>"/api"</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><blockquote><p>控制台也能改（Settings → Functions → Region → Hong Kong），但写进配置文件才不会因为忘记设置又回到美东。改完记得 Redeploy。</p></blockquote></section>
<section><h2>QQ 歌词拿不到：必须用「固定区域」的代理<a href="#qq-歌词拿不到必须用固定区域的代理"><span>#</span></a></h2><p><strong>现象</strong>：在 Vercel 上，<code>type=lrc&amp;server=tencent</code> 返回 <code>{"error":"no data"}</code>，但 <code>search</code>/<code>url</code>/<code>pic</code> 都正常，<code>netease</code> 的歌词也正常。</p><p><strong>原因</strong>：QQ 音乐的歌词接口 <code>c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg</code> 对境外 IP 有限制，其余接口不校验。所以只需要在请求 tencent 歌词时，把域名换成国内的中转服务即可。</p><p>我在 <code>api.js</code> 里留了一个开关：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>LYRIC_PROXY</span><span> </span><span>=</span><span><span> </span><span>process</span><span>.</span></span><span>env</span><span>.</span><span>LYRIC_PROXY</span><span> </span><span>||</span><span> </span><span>''</span><span>;</span></div></div><div><div><div>2</div></div><div><span>// ...</span></div></div><div><div><div>3</div></div><div><span>if</span><span><span> (</span><span>type</span><span> </span></span><span>===</span><span> </span><span>'lrc'</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>server</span><span> </span></span><span>===</span><span> </span><span>'tencent'</span><span> </span><span>&amp;&amp;</span><span> </span><span>LYRIC_PROXY</span><span>) {</span></div></div><div><div><div>4</div></div><div><span>    </span><span>const</span><span> </span><span>proxyBase</span><span> </span><span>=</span><span> </span><span>LYRIC_PROXY</span><span>.</span><span>replace</span><span>(</span><span><span>/</span><span>\/</span></span><span>$</span><span>/</span><span>, </span><span>''</span><span>);</span></div></div><div><div><div>5</div></div><div><span>    </span><span>const</span><span> </span><span>origCurl</span><span> </span><span>=</span><span><span> </span><span>meting</span><span>.</span></span><span>_curl</span><span>.</span><span>bind</span><span><span>(</span><span>meting</span><span>);</span></span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>meting</span><span>.</span><span>_curl</span><span> </span><span>=</span><span><span> (</span><span>url</span><span>, </span><span>body</span><span>) </span></span><span>=&gt;</span></div></div><div><div><div>7</div></div><div><span>        </span><span>origCurl</span><span>(</span><span>String</span><span><span>(</span><span>url</span><span>).</span></span><span>replace</span><span>(</span><span>/</span><span>^</span><span><span>https:</span><span>\/\/</span><span>c</span><span>\.</span><span>y</span><span>\.</span><span>qq</span><span>\.</span><span>com/</span></span><span><span>, </span><span>proxyBase</span><span>), </span><span>body</span><span>);</span></span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>只有 tencent 歌词会走代理，其他请求完全不受影响。留空则走直连（国内/无限制环境正常工作）。</p><section><h3>不要用「边缘函数」做这个代理<a href="#不要用边缘函数做这个代理"><span>#</span></a></h3><p>我一开始用的是 EdgeOne 的<strong>边缘函数</strong>，结果在 Vercel 上依然拿不到歌词。排查了半天才突然想到：</p><blockquote><p>⚠️ 边缘函数是<strong>就近执行</strong>——谁调用它，它就跑在谁附近的边缘节点上。</p></blockquote><p>Vercel 在美国 → 代理函数跑在美国边缘 → 从美国 IP 去抓 <code>c.y.qq.com</code> → 还是被 QQ 墙。等于白忙活。</p><p><strong>正解是用 EdgeOne Makers（Cloud Functions）</strong>，它是「固定区域」执行，能在 <code>edgeone.json</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>"mainlandRegions"</span><span>: [</span><span>"ap-guangzhou"</span><span>],</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"overseasRegions"</span><span>: [</span><span>"ap-guangzhou"</span><span>],</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"cloudFunctions"</span><span>: { </span><span>"maxDuration"</span><span>: </span><span>30</span><span> }</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>广州节点（<code>ap-guangzhou</code>）永远是国内 IP，从国内抓 QQ 歌词 → 放行。代理函数本身极简，就是个带白名单的 fetch 转发：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>UPSTREAM</span><span> </span><span>=</span><span> </span><span>'https://c.y.qq.com'</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>export</span><span> </span><span>async</span><span> </span><span>function</span><span> </span><span>onRequest</span><span><span>(</span><span>context</span><span>) {</span></span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>u</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>URL</span><span><span>(</span><span>context</span><span>.</span></span><span>request</span><span>.</span><span>url</span><span>);</span></div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>u</span><span>.</span></span><span>pathname</span><span>.</span><span>startsWith</span><span>(</span><span>'/lyric/'</span><span>)) {</span></div></div><div><div><div>6</div></div><div><span>    </span><span>return</span><span> </span><span>new</span><span> </span><span>Response</span><span>(</span><span>'forbidden'</span><span>, { </span><span>status</span><span>:</span><span> </span><span>403</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>const</span><span> </span><span>target</span><span> </span><span>=</span><span> </span><span>UPSTREAM</span><span> </span><span>+</span><span><span> </span><span>u</span><span>.</span></span><span>pathname</span><span> </span><span>+</span><span><span> </span><span>u</span><span>.</span></span><span>search</span><span>;</span></div></div><div><div><div>9</div></div><div><span>  </span><span>const</span><span> </span><span>headers</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>Headers</span><span>();</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>headers</span><span>.</span><span>set</span><span>(</span><span>'host'</span><span>, </span><span>'c.y.qq.com'</span><span>);</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>headers</span><span>.</span><span>set</span><span>(</span><span>'referer'</span><span>, </span><span>'https://y.qq.com/'</span><span>);</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>headers</span><span>.</span><span>set</span><span>(</span><span>'user-agent'</span><span>, </span><span>'Mozilla/5.0 ... Chrome/120 Safari/537.36'</span><span>);</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>  </span><span>const</span><span> </span><span>upstream</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>fetch</span><span><span>(</span><span>target</span><span>, { </span></span><span>method</span><span>:</span><span><span> </span><span>context</span><span>.</span></span><span>request</span><span>.</span><span>method</span><span><span>, </span><span>headers</span><span> });</span></span></div></div><div><div><div>15</div></div><div><span>  </span><span>const</span><span> </span><span>text</span><span> </span><span>=</span><span> </span><span>await</span><span><span> </span><span>upstream</span><span>.</span></span><span>text</span><span>();</span></div></div><div><div><div>16</div></div><div><span>  </span><span>return</span><span> </span><span>new</span><span> </span><span>Response</span><span><span>(</span><span>text</span><span>, { </span></span><span>status</span><span>:</span><span><span> </span><span>upstream</span><span>.</span></span><span>status</span><span> });</span></div></div><div><div><div>17</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>部署命令（注意 <code>-n</code> 指定名称，避免和已存在的同名项目撞车）：</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>makers-proxy</span></div></div><div><div><div>2</div></div><div><span>edgeone</span><span> </span><span>makers</span><span> </span><span>deploy</span><span> </span><span>-n</span><span> </span><span>meting-qq-proxy</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>拿到 Makers 默认域名后，把它填进 Vercel 的 <code>LYRIC_PROXY</code> 环境变量，重新 Deploy 即可。</p><blockquote><p>同理，腾讯云 SCF、阿里云 FC、你自己的国内 VPS，只要「执行位置固定在国内」都能干这活，思路完全一致。</p></blockquote></section></section>
<section><h2>锦上添花：边缘缓存 + 内联直链<a href="#锦上添花边缘缓存--内联直链"><span>#</span></a></h2><p>区域改香港之后，单次请求已经很快了。这两项优化是进一步把体验做稳，属于「有了更好」。</p><p><strong>边缘缓存</strong>：之前所有响应都没设缓存头，Vercel 不会边缘缓存，重复请求每次都回源。加上 <code>s-maxage</code> 后，重复请求由 Vercel 边缘就近返回：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>setEdgeCache</span><span> </span><span>=</span><span><span> (</span><span>c</span><span>, </span><span>type</span><span>) </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>2</div></div><div><span>    </span><span>const</span><span> </span><span>ttl</span><span> </span><span>=</span><span><span> (</span><span>type</span><span> </span></span><span>===</span><span> </span><span>'url'</span><span> </span><span>||</span><span><span> </span><span>type</span><span> </span></span><span>===</span><span> </span><span>'pic'</span><span>) </span><span>?</span><span> </span><span>600</span><span> </span><span>:</span><span> </span><span>3600</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>c</span><span>.</span><span>header</span><span>(</span><span>'Cache-Control'</span><span>, </span><span>`public, s-maxage=</span><span>${</span><span>ttl</span><span>}</span><span>, max-age=60`</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>错误响应不缓存，避免把「无数据」也顶到边缘上。</p><p><strong>内联播放直链（<code>&amp;fill=1</code>）</strong>：默认情况下，单曲接口返回的 <code>url</code> 是一个「再去拉一次」的 API 地址，播放器拿到后还要再发一次 <code>type=url</code> 请求才拿到真正的 mp3。等于白送一次往返。加上 <code>fill=1</code> 后，服务端并行把直链解析好直接塞进 <code>url</code> 字段：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>audioUrl</span><span> </span><span>=</span><span><span> </span><span>urlId</span></span></div></div><div><div><div>2</div></div><div><span>    </span><span>?</span><span><span> (</span><span>fillSong</span><span> </span></span><span>?</span><span> </span><span>await</span><span> </span><span>resolveAudioUrl</span><span><span>(</span><span>server</span><span>, </span><span>urlId</span><span>, </span><span>cookie</span><span>)</span></span></div></div><div><div><div>3</div></div><div><span>                </span><span>:</span><span> </span><span>`</span><span>${</span><span>get_url</span><span><span>(</span><span>c</span><span>)</span></span><span>}</span><span>?server=</span><span>${</span><span>server</span><span>}</span><span>&amp;type=url&amp;id=</span><span>${</span><span>urlId</span><span>}</span><span>`</span><span>)</span></div></div><div><div><div>4</div></div><div><span>    </span><span>:</span><span> </span><span>''</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这里有个取舍：只对 <code>type=song</code>（单曲）做内联，<strong><code>playlist</code>/<code>search</code>/<code>artist</code> 这些返回数组的不内联</strong>——否则一张歌单几十首，服务端要并发去打几十次接口，反而把自己打挂。所以内联保持「按需开启」（默认关），播放器调 <code>type=song</code> 时拼上 <code>&amp;fill=1</code> 就能少一次往返。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/9/</id>
      <title type="text">主题合并排错记：git 误删工作树、架构漂移与 CSS 三连击</title>
      <published>2026-08-22T00:00:00.000Z</published>
      <updated>2026-08-22T00:00:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/9/"/>
      <summary type="text">主题更新了一直嫌麻烦不想动。这次终于把上游合并进来，本以为最麻烦的是解冲突——平时就加了点小功能嘛。结果呢，冲突只占开头一小段，后面全是排错</summary>
      <content type="html"><![CDATA[<p>主题更新一直嫌麻烦不想动，这次终于把上游合并进来了。本以为最麻烦的是解冲突——平时也就加了点小功能。结果冲突只占开头一小段，后面全是排错：git 把工作树删了、上游大改导致私有功能挂不上、类型被悄悄吞、副标题的 CSS 连着炸三回，一整天就这么搭进去了。趁热记一笔，免得下次又踩。</p>
<section><h2>git merge —abort 把工作区删了<a href="#git-merge-abort-把工作区删了"><span>#</span></a></h2><p>合并到一半想回退，手比脑子快，直接 <code>git merge --abort</code>——本以为会回到合并前。结果工作树两百多个文件<strong>全没了</strong>，<code>git status</code> 一片 <code> D</code>，但索引还在。后来才摸清，<code>git checkout &lt;分支&gt;</code>、<code>git rm &lt;file&gt;</code> 这几个在这环境里都会触发同样的”物理删除”，我三个全中。</p><p>恢复倒不难，一条底层管道把索引里的 blob 重新写回磁盘就行：</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>checkout-index</span><span> </span><span>-a</span><span> </span><span>-f</span><span>   </span><span># 把索引里所有 blob 重新写回磁盘</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>跑完 <code>git status -s</code> 归零、<code>git diff HEAD --stat</code> 为空，文件一个没少。</p></section>
<section><h2>上游重构，我的私有功能挂不上了<a href="#上游重构我的私有功能挂不上了"><span>#</span></a></h2><p>这关最费脑子。上游把横幅/壁纸/侧栏整套重写了，引入 <code>WallpaperSection</code>、<code>SidebarColumn</code> 和 <code>getBannerVisibilityState()</code>，还把我依赖的那几个 <code>isBannerMode</code>/<code>isFullscreenMode</code> 布尔变量删了。</p><p>我那一言横幅正好挂在这几个旧变量上，于是冲突变得很别扭：两边各自引用了对方才有的变量——上游代码引用我这边已经删的，我的代码引用它那边刚删的。哪一段单独取都不对，<strong>干脆谁都别取，整段重写</strong>。</p><p>最后手动把一言重挂到新的 <code>banner</code> 模型上：渲染走 <code>BannerHomeTextOverlay</code>，逻辑塞进 <code>MainGridLayout</code> 的新薄壳。也就是换个挂点。</p><p>这事给我提了个醒：<strong>私有功能尽量挂在配置入口，别依赖主题内部的实现细节</strong>（布尔变量、DOM 结构这些）。不然上游每重构一次，我就得手动重挂一次。</p></section>
<section><h2>类型被”自动合并”偷偷吞了<a href="#类型被自动合并偷偷吞了"><span>#</span></a></h2><p>这个最隐蔽。<code>src/types/siteConfig.ts</code> 根本不在冲突清单里，git 自动合并时<strong>直接拿上游版本覆盖了我私改的部分</strong>——我加的 <code>anime?</code> 类型块没了，构建报 <code>ts(2353)</code>。</p><p>所以现在合完我第一件事就是跑 <code>astro check</code>，把被吞的私有类型补回来。这坑藏得深，不跑检查根本发现不了。</p></section>
<section><h2>CSS 三连击，全卡在副标题<a href="#css-三连击全卡在副标题"><span>#</span></a></h2><p>一言副标题在新布局里连着出了三个问题，一个比一个隐蔽。</p><p><strong>第一个，一言被顶到标题上方。</strong> 旧布局给 <code>.banner-subtitle</code> 加了 <code>position: absolute; top: 0</code>，新上游是 flex 列布局，这一下直接把它粘到 overlay 顶部，跑到标题上面去了。修法是把绝对定位<strong>收窄到 <code>.banner-subtitle-wrap</code> 容器内</strong>，普通 <code>.banner-subtitle</code> 回归文档流。</p><p><strong>第二个，副标题消失后主标题上下跳。</strong> 打字机删空、加载中、回退无数据时，副标题高度塌成 0，上方主标题跟着跳。给一言槽<strong>恒定预留一行高度</strong>就稳了：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#banner-subtitle</span><span>[</span><span>data-hitokoto</span><span>=</span><span>"1"</span><span><span>]</span><span>,</span></span></div></div><div><div><div>2</div></div><div><span>#banner-subtitle-fallback</span><span> {</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>min-height: </span><span><span>1.4</span><span>em</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>data-hitokoto</code>），不碰上游默认副标题——我一直守着一条线：<strong>私有功能不能影响上游默认行为</strong>。</p><p><strong>第三个，链接图标被挤来挤去。</strong> 回退槽初始带 Tailwind 的 <code>hidden</code>（<code>display:none</code>），回退逻辑里用 <code>display=""</code> 去还原，结果被 <code>.hidden</code> 吃掉，回退槽永远不出现。改成内联 <code>display="block"</code> 才生效。</p><p>这三个坑的共同教训：<strong>改布局前先看清楚当前 DOM 结构</strong>——是不是 flex、背后有没有 Tailwind class 在干扰，比闷头写 CSS 重要得多。</p></section>
<section><h2>还有个插曲：reset —hard 清了一整天<a href="#还有个插曲reset-hard-清了一整天"><span>#</span></a></h2><p>中途手滑了一次。想放弃一部分改动，<code>git reset --hard HEAD</code> 一敲，一整天没提交的私有改动全没了。还好 <code>git add</code> 过的东西其实还在对象库里没真消失：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>git</span><span> </span><span>fsck</span><span> </span><span>--unreachable</span><span> </span><span>--no-reflogs</span><span>   </span><span># 列出悬空 blob</span></div></div><div><div><div>2</div></div><div><span>git</span><span> </span><span>cat-file</span><span> </span><span>-p</span><span> </span><span>&lt;hash&gt;</span><span> | </span><span>grep</span><span> </span><span>"特征串"</span><span> </span><span># 按文件特征定位是哪个文件</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>靠这两条把 <code>git add</code> 过的文件一个个认领了回来。没 add 过的部分是真没了，只能认栽重写——也算买个教训，改动再不满意，先 add 一把再说。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/8/</id>
      <title type="text">从企业微信到QQ重构博客机器人</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://x1anyu.cn/posts/8/"/>
      <summary type="text">博客机器人最初只接企业微信（AES+SHA1、全链路加密、手写 PKCS#7 分块，调完弃用）。后来才发现 QQ 的机器人平台更好用，Ed25519 签名模型清爽太多，想直接迁过去。</summary>
      <content type="html"><![CDATA[<p>博客机器人最早只接了企业微信。发条消息就能把随笔/动态推到 GitHub 走构建发布，功能上没什么问题。</p>
<p>但当时写回调那会儿真磨人——AES 加 SHA1、全链路加密，还得自己手写 PKCS#7 分块。好在调通之后就不用再碰了。</p>
<p>后来我才发现 QQ 的机器人平台更好用，Webhook 也开放了好久。签名模型用 Ed25519，比企微那套清爽得多，所以想直接迁过去。</p>
<p>迁移下来最大的收获不是多接了个 QQ，而是把接入层和业务逻辑拆开了：想用 QQ 就用 QQ，想留企微就留企微，以后想接 Telegram 也只是多写个适配器，核心的「发博文/动态/友链」一行都不用动。</p>
<section><h2>QQ 的签名模型清爽在哪<a href="#qq-的签名模型清爽在哪"><span>#</span></a></h2><p>QQ 机器人走 Webhook，核心是 <strong>Ed25519</strong>（而不是企微的 SHA1 + 自定义 AES）。两件事：</p><ul>
<li><strong>握手（op13）</strong>：QQ 给你发一个 <code>plain_token</code> 和 <code>event_ts</code>，你用私钥对 <code>"plain_token" + plain_token + event_ts</code> 签名返回，平台确认你持有私钥就算验证通过。</li>
<li><strong>消息事件</strong>：每条推送带 <code>X-Signature</code> 头，值是 <code>Ed25519(私钥, "https://bots.qq.com" + 请求路径 + 时间戳 + body)</code> 的 base64。你本地按同样规则算一遍比对即可。</li>
</ul><p>没全链路加密、没自定义块长的 AES、没 XML 套娃。验签就是验签，发消息走官方 API。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> </span><span>*</span><span><span> </span><span>as</span><span> </span></span><span>ed</span><span> </span><span>from</span><span> </span><span>"@noble/ed25519"</span><span>;</span></div></div><div><div><div>2</div></div><div><span>import</span><span> { </span><span>sha512</span><span> } </span><span>from</span><span> </span><span>"@noble/hashes/sha2.js"</span><span>;</span></div></div><div><div><div>3</div></div><div><span><span>ed</span><span>.</span></span><span>hashes</span><span>.</span><span>sha512</span><span> </span><span>=</span><span><span> </span><span>sha512</span><span>; </span></span><span>// ⚠️ Workers 里必须手动注入，否则 ed.signAsync 会找不到哈希函数</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>// 握手</span></div></div><div><div><div>6</div></div><div><span>const</span><span> </span><span>sd</span><span> </span><span>=</span><span> </span><span>"plain_token"</span><span> </span><span>+</span><span><span> </span><span>plain_token</span><span> </span></span><span>+</span><span><span> </span><span>event_ts</span><span>;</span></span></div></div><div><div><div>7</div></div><div><span>const</span><span> </span><span>sig</span><span> </span><span>=</span><span> </span><span>await</span><span><span> </span><span>ed</span><span>.</span></span><span>signAsync</span><span>(</span><span>new</span><span> </span><span>TextEncoder</span><span>().</span><span>encode</span><span><span>(</span><span>sd</span><span>), </span><span>privKey</span><span>);</span></span></div></div><div><div><div>8</div></div><div><span>return</span><span><span> </span><span>Response</span><span>.</span></span><span>json</span><span><span>({ </span><span>plain_token</span><span>, </span></span><span>signature</span><span>:</span><span> </span><span>b64</span><span><span>(</span><span>sig</span><span>) });</span></span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>// 消息验签</span></div></div><div><div><div>11</div></div><div><span>const</span><span> </span><span>concat</span><span> </span><span>=</span><span> </span><span>"https://bots.qq.com"</span><span> </span><span>+</span><span><span> </span><span>url</span><span>.</span></span><span>pathname</span><span> </span><span>+</span><span><span> </span><span>timestamp</span><span> </span></span><span>+</span><span><span> </span><span>body</span><span>;</span></span></div></div><div><div><div>12</div></div><div><span>const</span><span> </span><span>sigBytes</span><span> </span><span>=</span><span> </span><span>base64ToBytes</span><span><span>(</span><span>signatureHeader</span><span>); </span></span><span>// X-Signature 头取出的 base64 解码</span></div></div><div><div><div>13</div></div><div><span>const</span><span> </span><span>ok</span><span> </span><span>=</span><span> </span><span>await</span><span><span> </span><span>ed</span><span>.</span></span><span>verifyAsync</span><span><span>(</span><span>sigBytes</span><span>, </span></span><span>new</span><span> </span><span>TextEncoder</span><span>().</span><span>encode</span><span><span>(</span><span>concat</span><span>), </span><span>pubKey</span><span>);</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>一个差点栽的坑：<code>@noble/ed25519</code> 在 Cloudflare Workers 里<strong>必须手动把 <code>sha512</code> 挂上去</strong>（<code>ed.hashes.sha512 = sha512</code>）。不挂的话签名走的是它内部默认实现，在 Workers 运行时下会报「hash 未定义」——这个报错信息非常误导性，查了半天才反应过来是注入没做。</p></section>
<section><h2>关键一步：把「接平台」和「写业务」拆开<a href="#关键一步把接平台和写业务拆开"><span>#</span></a></h2><p>我一开始的想法是「再加个 Worker 专门跑 QQ」。一想不对——企微和 QQ 干的是同一件事：解析用户意图 → 发博文 / 动态 / 友链。平台差异只在「收消息、验身份、回消息」这三步。</p><p>于是我把结构改成了这样：</p><div><div><div><div><span><p>AES解密+SHA1验签</p></span></div><div><span><p>Ed25519验签</p></span></div><div><span></span></div><div><span></span></div><div><span><p>dispatch/publish/githubPut</p></span></div><div><span><p>handleFriendLink</p></span></div><div><span><p>企业微信回调</p></span></div><div><span><p>wxbot/wx.js 适配器</p></span></div><div><span><p>QQ Webhook</p></span></div><div><span><p>worker.js 内联 QQ 适配器</p></span></div><div><span><p>共享业务层 src/shared.js</p></span></div><div><span><p>GitHub 仓库 构建发布</p></span></div><div><span><p>check-flink 真源</p></span></div>
</div><div><div><span><p>AES解密+SHA1验签</p></span></div><div><span><p>Ed25519验签</p></span></div><div><span></span></div><div><span></span></div><div><span><p>dispatch/publish/githubPut</p></span></div><div><span><p>handleFriendLink</p></span></div><div><span><p>企业微信回调</p></span></div><div><span><p>wxbot/wx.js 适配器</p></span></div><div><span><p>QQ Webhook</p></span></div><div><span><p>worker.js 内联 QQ 适配器</p></span></div><div><span><p>共享业务层 src/shared.js</p></span></div><div><span><p>GitHub 仓库 构建发布</p></span></div><div><span><p>check-flink 真源</p></span></div>
</div></div></div><p>共享业务层 <code>src/shared.js</code> 只认一条统一结构 <code>{msgType, content, fromUser}</code>，根本不关心消息来自企微还是 QQ。两个平台各自把自家协议翻译成这条结构，丢给 <code>dispatch</code> 就完事。</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// worker.js 入口（节选）</span></div></div><div><div><div>2</div></div><div><span>export</span><span> </span><span>default</span><span> {</span></div></div><div><div><div>3</div></div><div><span>  </span><span>async</span><span> </span><span>fetch</span><span><span>(</span><span>request</span><span>, </span><span>env</span><span>, </span><span>ctx</span><span>) {</span></span></div></div><div><div><div>4</div></div><div><span>    </span><span>const</span><span> </span><span>url</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>URL</span><span><span>(</span><span>request</span><span>.</span></span><span>url</span><span>);</span></div></div><div><div><div>5</div></div><div><span>    </span><span>if</span><span><span> (</span><span>url</span><span>.</span></span><span>pathname</span><span> </span><span>===</span><span><span> </span><span>env</span><span>.</span></span><span>QQ_CALLBACK_PATH</span><span>) </span><span>return</span><span> </span><span>handleQQCallback</span><span>(</span><span>request</span><span>, </span><span>env</span><span>, </span><span>ctx</span><span>);</span></div></div><div><div><div>6</div></div><div><span>    </span><span>if</span><span><span> (</span><span>url</span><span>.</span></span><span>pathname</span><span> </span><span>===</span><span> </span><span>"/__qqmenu"</span><span>)  </span><span>return</span><span> </span><span>qqSetMenu</span><span>(</span><span>env</span><span>);   </span><span>// QQ 自定义菜单</span></div></div><div><div><div>7</div></div><div><span>    </span><span>if</span><span><span> (</span><span>url</span><span>.</span></span><span>pathname</span><span> </span><span>===</span><span> </span><span>"/__qqpanel"</span><span>) </span><span>return</span><span> </span><span>qqSetPanel</span><span>(</span><span>env</span><span>);  </span><span>// QQ 指令面板</span></div></div><div><div><div>8</div></div><div><span>    </span><span>if</span><span><span> (</span><span>url</span><span>.</span></span><span>pathname</span><span> </span><span>===</span><span> </span><span>"/api/apply-friend"</span><span>) </span><span>return</span><span> </span><span>handleApplyFriend</span><span>(</span><span>request</span><span>, </span><span>env</span><span>);</span></div></div><div><div><div>9</div></div><div><span>    </span><span>if</span><span><span> (</span><span>url</span><span>.</span></span><span>pathname</span><span> </span><span>===</span><span> </span><span>"/friends.json"</span><span>) </span><span>return</span><span> </span><span>r2Friends</span><span>(</span><span>env</span><span>);</span></div></div><div><div><div>10</div></div><div><span>    </span><span>// 其余交给企微适配器；它内部对「非企微请求」返回 null，由这里兜底 404</span></div></div><div><div><div>11</div></div><div><span>    </span><span>const</span><span> </span><span>wxRes</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>handleWechat</span><span>(</span><span>request</span><span>, </span><span>env</span><span>, </span><span>url</span><span>, </span><span>ctx</span><span>);</span></div></div><div><div><div>12</div></div><div><span>    </span><span>if</span><span> (</span><span>wxRes</span><span>) </span><span>return</span><span> </span><span>wxRes</span><span>;</span></div></div><div><div><div>13</div></div><div><span>    </span><span>return</span><span> </span><span>new</span><span> </span><span>Response</span><span>(</span><span>"not found"</span><span>, { </span><span>status</span><span>:</span><span> </span><span>404</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></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>这里有个我挺满意的小设计：<strong>企微适配器不负责「路由归属」</strong>。它 <code>export</code> 一个 <code>handleWechat</code>，把企微相关的 GET 验签、POST 解密、各调试端点全收进去；对于「这请求根本不是企微的」情况返回 <code>null</code>，交给 root 兜底 404。这样 root 的 <code>fetch</code> 永远是「先 QQ、再各业务端点、再丢给企微、最后 404」，顺序清晰，互不越界。</p></section>
<section><h2>代码分离的取舍<a href="#代码分离的取舍"><span>#</span></a></h2><p>我原本把企微逻辑整个抽成了独立部署的 <code>wxbot/worker.js</code>，后来觉得没必要——独立部署意味着企微回调地址要改、要管两份绑定、出问题时查两个地方。</p><p>最后方案是<strong>只分代码目录，不分架构</strong>：把企微那一坨挪进 <code>wxbot/wx.js</code>，root 用 <code>import { handleWechat }</code> 调它。线上还是<strong>同一个 Worker</strong>，企微回调地址一个字都不用动，QQ 和企微在同一进程里共存。对外部（企微后台、QQ 开放平台）来说，什么都没变。</p><div><figure><figcaption><span>wxbot/wx.js</span></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> { </span><span>dispatch</span><span> } </span><span>from</span><span> </span><span>"../src/shared.js"</span><span>;</span></div></div><div><div><div>2</div></div><div><span>export</span><span> </span><span>async</span><span> </span><span>function</span><span> </span><span>handleWechat</span><span><span>(</span><span>request</span><span>, </span><span>env</span><span>, </span><span>url</span><span>, </span><span>ctx</span><span>) {</span></span></div></div><div><div><div>3</div></div><div><span>  </span><span>// 企微全套：验签 / 解密 / 被动回包 / cf+hybrid 双模式 / 调试端点</span></div></div><div><div><div>4</div></div><div><span>  </span><span>// 非企微请求 → return null</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>哪天真不想留企微了，删掉这一行 import 和这个文件夹就行，<strong>QQ 半边一行都不用碰</strong>。反过来想留企微、砍 QQ 也一样。平台成了可插拔的模块。</p></section>
<section><h2>几个踩过的坑<a href="#几个踩过的坑"><span>#</span></a></h2><ul>
<li><strong>Ed25519 的 sha512 注入</strong>：上面提过，Workers 里忘了 <code>ed.hashes.sha512 = sha512</code> 会莫名其妙报错，记住就好。</li>
<li><strong>X-Signature 的拼接串</strong>：必须是 <code>"https://bots.qq.com" + path + timestamp + body</code> 原样拼，少一段、顺序错、把 query 也算进去，验签全失败。建议直接拿官方示例的拼接方式对齐，别自己发挥。</li>
<li><strong>企微收不到图片</strong>：企微的 <code>media/get</code> 受「可信 IP 名单」限制，Worker 出口 IP 不在名单里，下载素材会报 60020。所以图片一律改成链接写（<code>封面：外链</code>、<code>![说明](外链)</code>），适配器里根本不存图，只引导。这点 QQ 反而省心，能直接发图。</li>
<li><strong>命令路由的正则</strong>：早期我用 <code>/^\/(友链|friend|fl)(\s.*)?$/</code> 匹配，结果 <code>.</code> 不匹配换行，一发多行信息块就被当成普通博文吞了。改成「前缀式」<code>/^\/(友链|friend|fl)(?:\s|$)/</code> 才行。</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/7/</id>
      <title type="text">我把友链系统重做成了一套自动巡检 + 自助申请的玩意</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://x1anyu.cn/posts/7/"/>
      <summary type="text">友链这东西看着简单，维护起来全是琐碎。我干脆把它拆成两个仓库，真源只留一份，申请走 Issue 模板机器人核验，每天定时巡检谁挂了。整套跑在 GitHub Actions 上，零服务器成本。</summary>
      <content type="html"><![CDATA[<p>友链每次改都要重新部署还是很麻烦的，特别是像我这种懒人。</p>
<p>我后来实在烦了，干脆重做了一套：<strong>真源只留一份，申请走 GitHub Issue 模板让机器人核验反链，每天定时巡检谁挂了自动提醒</strong>。博客侧只管展示，检测逻辑全在另一个仓库里跑。</p>
<p>检测系统这套（<code>check-flink</code>）是我基于 <a href="https://github.com/fqzlr/check-flink" target="_blank">fqzlr/check-flink</a> 改造的——在原版友链巡检基础上，加了自助申请核验、SSRF 防护和自动同步。</p>
<a href="https://github.com/fqzlr/check-flink" target="_blank"><div><div><div><div></div><div>fqzlr</div></div><div>/</div><div>check-flink</div></div><div></div></div><div>⚙️检查友链链接是否可连，可以大幅度减少检查工作量。</div><div><div>—</div><div>—</div><div>MIT</div><span>Python</span></div></a>
<a href="https://github.com/ImYufish/check-flink" target="_blank"><div><div><div><div></div><div>ImYufish</div></div><div>/</div><div>check-flink</div></div><div></div></div><div>⚙️检查友链链接是否可连，可以大幅度减少检查工作量。</div><div><div>—</div><div>—</div><div>MIT</div><span>Python</span></div></a>
<section><h2>整体思路：两个仓库，一个真源<a href="#整体思路两个仓库一个真源"><span>#</span></a></h2><p><strong>友链清单两边各存一份：真源放在 <code>check-flink</code>，本地这份只作兜底</strong>。</p><div><div><div><div><span><p>static/friends.json 真源</p></span></div><div><span><p>博客构建期 fetch</p></span></div><div><span><p>定时巡检回写状态</p></span></div><div><span><p>apply-friend.yml 解析+验证</p></span></div><div><span><p>push main 触发</p></span></div><div><span><p>check-flink 仓库</p></span></div><div><span><p>部署到 friends.yufish.cn</p></span></div><div><span><p>my-blog 友链页</p></span></div><div><span><p>GitHub Issue 申请</p></span></div><div><span><p>check_links.yml 重新部署</p></span></div>
</div><div><div><span><p>static/friends.json 真源</p></span></div><div><span><p>博客构建期 fetch</p></span></div><div><span><p>定时巡检回写状态</p></span></div><div><span><p>apply-friend.yml 解析+验证</p></span></div><div><span><p>push main 触发</p></span></div><div><span><p>check-flink 仓库</p></span></div><div><span><p>部署到 friends.yufish.cn</p></span></div><div><span><p>my-blog 友链页</p></span></div><div><span><p>GitHub Issue 申请</p></span></div><div><span><p>check_links.yml 重新部署</p></span></div>
</div></div></div><ul>
<li><code>check-flink</code>：友链的「数据库」加检测系统。真源是 <code>static/friends.json</code>，部署到 <code>friends.yufish.cn</code>；两个 workflow 管「申请核验」和「定时巡检」。</li>
<li><code>my-blog</code>：博客本身（Firefly 主题，Astro）。友链页构建期 <code>fetch</code> 远程清单，本地 <code>friendsConfig.ts</code> 只是断网兜底。</li>
</ul><p>为啥拆两个仓库？因为检测系统要读写 Issue、强推 <code>page</code> 分支，权限和操作都跟博客构建没关系。拆开后我博客仓库保持干净，检测系统那边怎么折腾都不影响线上站点。</p></section>
<section><h2>真源只留一份，边界一开始就定死<a href="#真源只留一份边界一开始就定死"><span>#</span></a></h2><p>友链清单我只留一份真源，在 check-flink 的 <code>static/friends.json</code>。博客本地的 <code>friendsConfig.ts</code> 从一开始就是「断网 / 404 时的保底」，平时根本不参与显示——因为 <code>friends.astro</code> 的 <code>loadFriends()</code> 和客户端 <code>refreshFriends()</code> 都只读远程 <code>friends.yufish.cn/friends.json</code>，那个端点就是 check-flink 部署出来的。</p><p><strong>加 / 改 / 删友链，只动 <code>check-flink/static/friends.json</code> 一处</strong>，博客侧全自动跟随。</p><p>博客这边 <code>loadFriends()</code> 就是「远程优先、本地兜底」：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>async</span><span> </span><span>function</span><span> </span><span>loadFriends</span><span>()</span><span>:</span><span> </span><span>Promise</span><span>&lt;</span><span>RemoteFriend</span><span>[]&gt; {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>useRemoteFriends</span><span>) {</span></span></div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>console</span><span>.</span><span>log</span><span>(</span><span>"[friends] 已配置 useRemote=false，仅使用本地 friendsConfig.ts"</span><span>);</span></div></div><div><div><div>4</div></div><div><span>    </span><span>return</span><span> </span><span>getEnabledFriends</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>try</span><span> {</span></div></div><div><div><div>7</div></div><div><span>    </span><span>const</span><span> </span><span>res</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>fetch</span><span>(</span><span>"https://friends.yufish.cn/friends.json"</span><span>, {</span></div></div><div><div><div>8</div></div><div><span>      </span><span>signal</span><span>:</span><span><span> </span><span>AbortSignal</span><span>.</span></span><span>timeout</span><span>(</span><span>15000</span><span>),</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>});</span></div></div><div><div><div>10</div></div><div><span>    </span><span>if</span><span><span> (</span><span>res</span><span>.</span></span><span>ok</span><span>) {</span></div></div><div><div><div>11</div></div><div><span>      </span><span>const</span><span> </span><span>data</span><span> </span><span>=</span><span> (</span><span>await</span><span><span> </span><span>res</span><span>.</span></span><span>json</span><span><span>()) </span><span>as</span><span> { </span><span>friends</span></span><span><span>?</span><span>:</span></span><span> </span><span>RemoteFriend</span><span>[] };</span></div></div><div><div><div>12</div></div><div><span>      </span><span>const</span><span> </span><span>remote</span><span> </span><span>=</span><span><span> (</span><span>data</span><span>.</span></span><span>friends</span><span> </span><span>||</span><span> [])</span></div></div><div><div><div>13</div></div><div><span><span>        </span></span><span>.</span><span>filter</span><span><span>((</span><span>f</span><span>) </span></span><span>=&gt;</span><span><span> </span><span>f</span><span>.</span></span><span>enabled</span><span> </span><span>!==</span><span> </span><span>false</span><span>)</span></div></div><div><div><div>14</div></div><div><span><span>        </span></span><span>.</span><span>sort</span><span><span>((</span><span>a</span><span>, </span><span>b</span><span>) </span></span><span>=&gt;</span><span><span> (</span><span>b</span><span>.</span></span><span>weight</span><span> </span><span>??</span><span> </span><span>0</span><span>) </span><span>-</span><span><span> (</span><span>a</span><span>.</span></span><span>weight</span><span> </span><span>??</span><span> </span><span>0</span><span>));</span></div></div><div><div><div>15</div></div><div><span>      </span><span>if</span><span><span> (</span><span>remote</span><span>.</span></span><span>length</span><span> </span><span>&gt;</span><span> </span><span>0</span><span>) </span><span>return</span><span><span> </span><span>remote</span><span>;</span></span></div></div><div><div><div>16</div></div><div><span><span>      </span></span><span>console</span><span>.</span><span>warn</span><span>(</span><span>"[friends] friends.json 为空，回退本地配置"</span><span>);</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>} </span><span>else</span><span> {</span></div></div><div><div><div>18</div></div><div><span><span>      </span></span><span>console</span><span>.</span><span>warn</span><span>(</span><span>`[friends] friends.json HTTP </span><span>${</span><span>res</span><span>.</span><span>status</span><span>}</span><span>，回退本地配置`</span><span>);</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>20</div></div><div><span><span>  </span></span><span>} </span><span>catch</span><span><span> (</span><span>e</span><span>) {</span></span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>console</span><span>.</span><span>warn</span><span>(</span><span>"[friends] 读取远程 friends.json 失败，回退本地配置:"</span><span><span>, </span><span>e</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>return</span><span> </span><span>getEnabledFriends</span><span>();</span></div></div><div><div><div>24</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>这个开关的值来自 <code>friendsPageConfig.useRemote</code>，在组件里叫 <code>useRemoteFriends</code>。</p></section>
<section><h2>自助申请：Issue 模板 + 机器人核验<a href="#自助申请issue-模板--机器人核验"><span>#</span></a></h2><p>申请入口做成 GitHub Issue 模板（<code>friend-link.yml</code>）。申请人填网站名称 / 链接 / 描述 / 头像 / 友链页 URL，提交后 <code>apply-friend.yml</code> 自动跑三步：</p><ol>
<li><strong>解析</strong>：从 Issue body 按中文标签提字段，顺手清洗用户粘贴的 URL（去 markdown 链接语法、补 <code>https://</code> 前缀），跟现有清单去重。</li>
<li><strong>核验</strong>：调 <code>verify-friend.mjs</code>，用 Playwright 打开对方友链页，等 JS 渲染完 + 滚到底触发懒加载，再看页面里有没有指向我站的真实超链接（<code>href</code> / <code>data-url</code>）。顺带查可访问性。</li>
<li><strong>写入 / 拒收</strong>：通过就写进 <code>static/friends.json</code>（带 <code>issue_id</code>），评论并关 Issue；不通过（不可达 / 没反链）就打 <code>待更新</code> 标签、保持开放，对方修完在 Issue 下回一句就能重验。</li>
</ol><p>友链页上的「自助申请」按钮直接跳这个模板：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>a</span></div></div><div><div><div>2</div></div><div><span>  </span><span>href</span><span>=</span><span>"https://github.com/ImYufish/check-flink/issues/new?template=friend-link.yml"</span></div></div><div><div><div>3</div></div><div><span>  </span><span>target</span><span>=</span><span>"_blank"</span></div></div><div><div><div>4</div></div><div><span>  </span><span>rel</span><span>=</span><span>"noopener noreferrer"</span></div></div><div><div><div>5</div></div><div><span>  </span><span>class</span><span>=</span><span>"shrink-0 inline-flex items-center gap-1 rounded-lg bg-(--primary) px-3 py-1.5 text-xs font-medium text-white no-underline opacity-100 hover:opacity-80 transition-opacity"</span></div></div><div><div><div>6</div></div><div><span>&gt;</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>&lt;</span><span>svg</span><span> </span><span>class</span><span>=</span><span>"w-3.5 h-3.5"</span><span> </span><span>fill</span><span>=</span><span>"currentColor"</span><span> </span><span>viewBox</span><span>=</span><span>"0 0 24 24"</span><span> </span><span>aria-hidden</span><span>=</span><span>"true"</span><span>&gt;&lt;</span><span>path</span><span> </span><span>d</span><span>=</span><span>"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"</span><span>/&gt;&lt;/</span><span>svg</span><span>&gt;</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>&lt;/</span><span>a</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>每天定时巡检，谁挂了自动提醒<a href="#每天定时巡检谁挂了自动提醒"><span>#</span></a></h2><p><code>check_links.yml</code> 里状态检测设了每天两次（cron <code>0 1 * * *</code> / <code>0 13 * * *</code>）；另外有个单独 job 每 6 天截一次友链主页。检测完把结果写回 <code>result.json</code> 强推 <code>page</code> 分支，博客友链页读这个状态来标「异常 / 已失效」。</p><p>巡检发现异常（不可达 / 没反链），会去 <code>static/friends.json</code> 查这条友链的 <code>issue_id</code>，在对应 Issue 下评论并打 <code>待更新</code>；等对方修好、下次巡检正常了，自动摘标签并评论「已恢复」。当然友链是自助申请的才有<code>issue_id</code>，自己添加的没有。
另外，截图和检测是两个 job，都会强推 <code>page</code> 分支，所以加了 <code>concurrency</code> 串行，免得并发覆盖把数据搞丢：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>concurrency</span><span>:</span></div></div><div><div><div>2</div></div><div><span>  </span><span>group</span><span>: </span><span>check-links</span></div></div><div><div><div>3</div></div><div><span>  </span><span>cancel-in-progress</span><span>: </span><span>false</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>另外我让 <code>push: [main]</code> 也触发检测——手动在 <code>static/friends.json</code> 加一条，push 后 CI 立刻核验 + 部署，不用干等下一个定时点。</p></section>
<section><h2>安全闸门：SSRF<a href="#安全闸门ssrf"><span>#</span></a></h2><p>检测系统跑在公开仓库，谁都能在公开 Issue 里提交任意 URL。如果验证器直接 <code>fetch</code> 这些地址，等于给人开了个内网探测跳板、内网服务随便打。所以 <code>verify-friend.mjs</code> 在做任何网络访问之前，先过一道闸门：scheme 白名单 + 私有 / 保留地址拦截。</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>isPrivateHost</span><span><span>(</span><span>ip</span><span>) {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>ip</span><span>) </span></span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span><span> (</span><span>ip</span><span>.</span></span><span>includes</span><span>(</span><span>":"</span><span>)) {</span></div></div><div><div><div>4</div></div><div><span>    </span><span>const</span><span> </span><span>v</span><span> </span><span>=</span><span><span> </span><span>ip</span><span>.</span></span><span>toLowerCase</span><span>();</span></div></div><div><div><div>5</div></div><div><span>    </span><span>if</span><span><span> (</span><span>v</span><span> </span></span><span>===</span><span> </span><span>"::1"</span><span> </span><span>||</span><span><span> </span><span>v</span><span> </span></span><span>===</span><span> </span><span>"::"</span><span> </span><span>||</span><span><span> </span><span>v</span><span> </span></span><span>===</span><span> </span><span>"0:0:0:0:0:0:0:0"</span><span>) </span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>6</div></div><div><span>    </span><span>if</span><span><span> (</span><span>v</span><span>.</span></span><span>startsWith</span><span>(</span><span>"fe80"</span><span>)) </span><span>return</span><span> </span><span>true</span><span>;          </span><span>// 链路本地</span></div></div><div><div><div>7</div></div><div><span>    </span><span>if</span><span><span> (</span><span>v</span><span>.</span></span><span>startsWith</span><span>(</span><span>"fc"</span><span>) </span><span>||</span><span><span> </span><span>v</span><span>.</span></span><span>startsWith</span><span>(</span><span>"fd"</span><span>)) </span><span>return</span><span> </span><span>true</span><span>; </span><span>// 唯一本地</span></div></div><div><div><div>8</div></div><div><span>    </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>10</div></div><div><span>  </span><span>const</span><span> </span><span>p</span><span> </span><span>=</span><span><span> </span><span>ip</span><span>.</span></span><span>split</span><span>(</span><span>"."</span><span>).</span><span>map</span><span><span>(</span><span>Number</span><span>);</span></span></div></div><div><div><div>11</div></div><div><span>  </span><span>if</span><span><span> (</span><span>p</span><span>.</span></span><span>length</span><span> </span><span>!==</span><span> </span><span>4</span><span> </span><span>||</span><span><span> </span><span>p</span><span>.</span></span><span>some</span><span><span>(</span><span>Number</span><span>.</span></span><span>isNaN</span><span>)) </span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>12</div></div><div><span>  </span><span>if</span><span><span> (</span><span>p</span><span>[</span></span><span>0</span><span>] </span><span>===</span><span> </span><span>0</span><span>) </span><span>return</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>13</div></div><div><span>  </span><span>if</span><span><span> (</span><span>p</span><span>[</span></span><span>0</span><span>] </span><span>===</span><span> </span><span>10</span><span>) </span><span>return</span><span> </span><span>true</span><span>;                     </span><span>// 10.x</span></div></div><div><div><div>14</div></div><div><span>  </span><span>if</span><span><span> (</span><span>p</span><span>[</span></span><span>0</span><span>] </span><span>===</span><span> </span><span>127</span><span>) </span><span>return</span><span> </span><span>true</span><span>;                    </span><span>// 回环</span></div></div><div><div><div>15</div></div><div><span>  </span><span>if</span><span><span> (</span><span>p</span><span>[</span></span><span>0</span><span>] </span><span>===</span><span> </span><span>169</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>p</span><span>[</span></span><span>1</span><span>] </span><span>===</span><span> </span><span>254</span><span>) </span><span>return</span><span> </span><span>true</span><span>;    </span><span>// 链路本地 / 云元数据</span></div></div><div><div><div>16</div></div><div><span>  </span><span>if</span><span><span> (</span><span>p</span><span>[</span></span><span>0</span><span>] </span><span>===</span><span> </span><span>172</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>p</span><span>[</span></span><span>1</span><span>] </span><span>&gt;=</span><span> </span><span>16</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>p</span><span>[</span></span><span>1</span><span>] </span><span>&lt;=</span><span> </span><span>31</span><span>) </span><span>return</span><span> </span><span>true</span><span>; </span><span>// 172.16-31.x</span></div></div><div><div><div>17</div></div><div><span>  </span><span>if</span><span><span> (</span><span>p</span><span>[</span></span><span>0</span><span>] </span><span>===</span><span> </span><span>192</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>p</span><span>[</span></span><span>1</span><span>] </span><span>===</span><span> </span><span>168</span><span>) </span><span>return</span><span> </span><span>true</span><span>;    </span><span>// 192.168.x</span></div></div><div><div><div>18</div></div><div><span>  </span><span>return</span><span> </span><span>false</span><span>;</span></div></div><div><div><div>19</div></div><div><span>}</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span>// 任何网络访问前先校验</span></div></div><div><div><div>22</div></div><div><span>if</span><span> (</span><span>!</span><span>isHttpUrl</span><span><span>(</span><span>target</span><span>) </span></span><span>||</span><span> (</span><span>await</span><span> </span><span>isPrivateHost</span><span>(</span><span>getHostname</span><span><span>(</span><span>target</span><span>)))) {</span></span></div></div><div><div><div>23</div></div><div><span><span>  </span></span><span>out</span><span>.</span><span>reason</span><span> </span><span>=</span><span> </span><span>"友链地址不合法或指向内网/保留地址，已拒绝访问（安全策略）"</span><span>;</span></div></div><div><div><div>24</div></div><div><span><span>  </span></span><span>fs</span><span>.</span><span>writeFileSync</span><span><span>(</span><span>outputPath</span><span>, </span></span><span>JSON</span><span>.</span><span>stringify</span><span><span>(</span><span>out</span><span>, </span></span><span>null</span><span>, </span><span>2</span><span>));</span></div></div><div><div><div>25</div></div><div><span><span>  </span></span><span>process</span><span>.</span><span>exit</span><span>(</span><span>0</span><span>);</span></div></div><div><div><div>26</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>重点：不只拦 IP 字面量，还要 <code>dns.lookup</code> 把域名解析出来再判私有——攻击者可以拿一个公网域名指向内网 IP 绕过纯字符串判断。<code>isPrivateHost</code> 解析失败也保守拒绝。Playwright 启动失败降级到纯 <code>fetch</code> 检测，那也是在 SSRF 校验通过之后才发生的。</p></section>
<section><h2>本地兜底 + 自动同步，不用我天天手动改<a href="#本地兜底--自动同步不用我天天手动改"><span>#</span></a></h2><p>远程是真源，但本地 <code>friendsConfig.ts</code> 也得留一份兜底（断网 / 远程 404 时友链页不至于空白）。问题是这份兜底怎么跟真源保持一致——总不能每次手动改两处。</p><p>我在 <code>my-blog</code> 里加了 <code>scripts/sync-friends.mjs</code>，构建 / 构建前从 <code>friends.yufish.cn/friends.json</code> 拉取，只重写 <code>friendsConfig</code> 数组，其它（<code>friendsPageConfig</code> / <code>getEnabledFriends</code>）原样不动。任何失败都只告警跳过、不阻塞构建：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 用锚点精确定位 friendsConfig 数组，而非脆弱正则：</span></div></div><div><div><div>2</div></div><div><span>// 起点 = "export const friendsConfig: FriendLink[] = ["</span></div></div><div><div><div>3</div></div><div><span>// 终点 = "export const getEnabledFriends"</span></div></div><div><div><div>4</div></div><div><span>const</span><span> </span><span>startMarker</span><span> </span><span>=</span><span> </span><span>"export const friendsConfig: FriendLink[] = ["</span><span>;</span></div></div><div><div><div>5</div></div><div><span>const</span><span> </span><span>endMarker</span><span> </span><span>=</span><span> </span><span>"export const getEnabledFriends"</span><span>;</span></div></div><div><div><div>6</div></div><div><span>const</span><span> </span><span>si</span><span> </span><span>=</span><span><span> </span><span>file</span><span>.</span></span><span>indexOf</span><span><span>(</span><span>startMarker</span><span>);</span></span></div></div><div><div><div>7</div></div><div><span>const</span><span> </span><span>ei</span><span> </span><span>=</span><span><span> </span><span>file</span><span>.</span></span><span>indexOf</span><span><span>(</span><span>endMarker</span><span>);</span></span></div></div><div><div><div>8</div></div><div><span>if</span><span><span> (</span><span>si</span><span> </span></span><span>===</span><span> </span><span>-</span><span>1</span><span> </span><span>||</span><span><span> </span><span>ei</span><span> </span></span><span>===</span><span> </span><span>-</span><span>1</span><span> </span><span>||</span><span><span> </span><span>ei</span><span> </span></span><span>&lt;</span><span><span> </span><span>si</span><span>) { </span></span><span>/* 跳过 */</span><span> }</span></div></div><div><div><div>9</div></div><div><span>const</span><span> </span><span>before</span><span> </span><span>=</span><span><span> </span><span>file</span><span>.</span></span><span>slice</span><span>(</span><span>0</span><span><span>, </span><span>si</span><span>);</span></span></div></div><div><div><div>10</div></div><div><span>const</span><span> </span><span>after</span><span> </span><span>=</span><span><span> </span><span>file</span><span>.</span></span><span>slice</span><span><span>(</span><span>ei</span><span>);</span></span></div></div><div><div><div>11</div></div><div><span>await</span><span> </span><span>writeFile</span><span>(</span><span>SRC</span><span><span>, </span><span>before</span><span> </span></span><span>+</span><span><span> </span><span>replacement</span><span> </span></span><span>+</span><span> </span><span>"</span><span>\n</span><span>"</span><span> </span><span>+</span><span><span> </span><span>after</span><span>, </span></span><span>"utf8"</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这里还做了字段兜底：远程某条缺 <code>weight</code> / <code>enabled</code> 时补 <code>0</code> / <code>true</code>，否则会写出不合法的 TS，让 <code>astro build</code> 类型检查挂掉、整站部署失败。</p><p>同步脚本我也挂进了 GitHub Actions（<code>sync-friends.yml</code>），每天跑一次，有变才 commit 回仓库，无变就不动。从此本地兜底自动跟随真源，我只用改 check-flink 一处。</p></section>
<section><h2>数据源开关：远程 / 本地想切就切<a href="#数据源开关远程--本地想切就切"><span>#</span></a></h2><p>有时候我想临时冻结友链（调试、或远程不可达时不想依赖它），加了个开关，放在 <code>friendsPageConfig.useRemote</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>friendsPageConfig</span><span>:</span><span> </span><span>FriendsPageConfig</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>// ...</span></div></div><div><div><div>3</div></div><div><span>  </span><span>// true（默认）= 使用远程 friends.yufish.cn/friends.json</span></div></div><div><div><div>4</div></div><div><span>  </span><span>// false = 仅用本地 friendsConfig.ts</span></div></div><div><div><div>5</div></div><div><span>  </span><span>useRemote</span><span>:</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>6</div></div><div><span>};</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这开关有个坑我记一下：<strong>必须两处都拦</strong>。构建期 <code>loadFriends()</code> 要拦，客户端 <code>refreshFriends()</code> 也得拦。客户端脚本读不到 TS 常量，所以用 Astro 的 <code>define:vars</code> 把值注进去：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>script</span><span> </span><span>is:inline</span><span> </span><span>define:vars</span><span>=</span><span>{</span><span><span>{ </span><span>USE_REMOTE</span></span><span>:</span><span><span> </span><span>useRemoteFriends</span><span> }</span></span><span>}</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span>  </span><span>async</span><span> </span><span>function</span><span> </span><span>refreshFriends</span><span>() {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span>USE_REMOTE</span><span>) {</span></div></div><div><div><div>4</div></div><div><span><span>      </span></span><span>console</span><span>.</span><span>log</span><span>(</span><span>"[friends] 已配置仅用本地友链，跳过远程刷新"</span><span>);</span></div></div><div><div><div>5</div></div><div><span>      </span><span>return</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></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>&lt;/</span><span>script</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>只拦构建期不够——会出现「构建期读本地、页面加载完客户端又偷偷拉远程盖掉」的假象。开关放在 <code>friendsPageConfig</code> 里，而 sync 脚本的锚点是 <code>friendsConfig</code> 数组到 <code>getEnabledFriends</code>，开关在区间之外，自动同步永远碰不到它。</p></section>
<section><h2>几个需要注意的点<a href="#几个需要注意的点"><span>#</span></a></h2><ol>
<li><strong>真源边界别搞混</strong>：友链只改 check-flink 的 <code>static/friends.json</code>。博客本地 <code>friendsConfig.ts</code> 只是兜底，改它页面不会显示、检测系统也扫不到。</li>
<li><strong>锚点定位比正则稳</strong>：一开始用 <code>[\s\S]*?\];</code> 切 <code>friendsConfig</code> 数组，结果数组里某条数据自带 <code>];</code> 导致切片错位、整脚本跳过。改成按 <code>export const friendsConfig: FriendLink[] = [</code> 和 <code>export const getEnabledFriends</code> 两个锚点 <code>indexOf</code> 切片，再没错位过。</li>
<li><strong>开关要双处控</strong>：<code>useRemote</code> 只拦构建期不够，客户端 <code>refreshFriends()</code> 也得拦，否则出现「本地构建、远程覆盖」的假象。</li>
</ol></section>
<section><h2>现在加一条友链的标准动作<a href="#现在加一条友链的标准动作"><span>#</span></a></h2><p>只有一步：在 check-flink 的 <code>static/friends.json</code> 加一条，或者走 Issue 自助申请模板填表提交。剩下的——核验反链、定时巡检、状态提醒、博客侧同步、断网兜底——全自动。</p><p>整套跑在 GitHub Actions 上，公共仓库免费，零成本。</p><p><em>上面这些实现都在我的两个仓库 <code>check-flink</code> 和 <code>my-blog</code> 里，感兴趣可以自己翻。</em></p></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/6/</id>
      <title type="text">如何搭建企业微信机器人，用机器人发博文、动态</title>
      <published>2026-08-15T00:00:00.000Z</published>
      <updated>2026-08-15T00:00:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/6/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>没服务器又想随手发博客，这篇记录我搭的企业微信机器人：给机器人发一条消息，几分钟后它就变成博客上的一篇文章，全程不碰后台。</p>
<section><h2>整体是怎么跑起来的<a href="#整体是怎么跑起来的"><span>#</span></a></h2><p>先看整条链路。从你发消息到文章上线，中间一共四跳：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>企业微信（收消息 + 被动回复）</span></div></div><div><div><div>2</div></div><div><span><span>   </span></span><span>│  解密后的 XML</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>EdgeOne 边缘函数（解密 / 验签 / 加密回包）</span></div></div><div><div><div>5</div></div><div><span><span>   </span></span><span>│  /internal 转发（hybrid 模式）</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>Cloudflare Worker（攒草稿 → 拼 markdown → 写 GitHub）</span></div></div><div><div><div>8</div></div><div><span><span>   </span></span><span>│  commit 一个 .md 文件</span></div></div><div><div><div>9</div></div><div><span><span>   </span></span><span>▼</span></div></div><div><div><div>10</div></div><div><span>GitHub（Actions 自动构建部署博客）</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>最绕的是「被动回复」。企业微信要求你在收到回调后 5 秒内把回复写进响应体，而且必须是加密的 XML。真实的写博客动作（调 GitHub API、等部署）绝对不能在 5 秒里同步做完，否则超时企微就当你没回。</p><p>我的做法是：边缘函数收到消息，先解密、立刻拼一句「已记录」加密回包返回（满足 5 秒），再在后台把内容丢给 Worker 去写 GitHub。回不回消息和写不写文章是两条线，互不阻塞。</p><div><div><div></div><div>Warning</div></div><div><p>EdgeOne 边缘函数，http 响应返回之后，普通 fetch 会被运行时强制终止。
向后端 Cloudflare Worker 转发任务，必须使用 event.waitUntil(fetch(…)) 包裹转发逻辑。
如果不包，企微能收到 “已记录” 回复，但后续写 GitHub 逻辑直接不执行，很难排查。</p></div></div><hr /></section>
<section><h2>踩坑实录<a href="#踩坑实录"><span>#</span></a></h2><section><h3>坑一：以为是源站被墙，其实是加密算法写错了<a href="#坑一以为是源站被墙其实是加密算法写错了"><span>#</span></a></h3><p>最早我全放在 Cloudflare 上，企业微信回调死活访问不通。当时直觉是「CF 源站在国内被掐了」，于是把入口迁到 EdgeOne（国内 CDN，理论上更稳）。</p><p>结果迁过来还是不通。最后一行行查，发现是我自己写的 AES 加解密有 bug——企微回调用的是 AES-256-CBC + PKCS7，密文前面还要拼 16 字节随机串和 4 字节消息长度，回包还要按它的格式再加密一遍。只要哪一步字节对不上，企微就直接当你非法请求，表现就是「回调不通」。</p><p>修好之后我才确认：Cloudflare 源站本来是通的，当初根本不用换入口。为了不依赖任何第三方库（CF / EO 的运行时都不一定让你装包），我直接用了纯 JS 实现，核心是拿 Web Crypto 的 AES-ECB 当 AES-256-CBC 的轮函数，自己拼 CBC 链和 PKCS7 填充：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// // Web Crypto 不直接提供 AES‑256‑CBC，基于原生 AES‑ECB 手动实现 CBC 模式，无第三方依赖</span></div></div><div><div><div>2</div></div><div><span>async</span><span> </span><span>function</span><span> </span><span>aes256cbc</span><span><span>(</span><span>key32</span><span>, </span><span>iv16</span><span>, </span><span>data</span><span>, </span><span>decrypt</span><span>) {</span></span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>wkey</span><span> </span><span>=</span><span> </span><span>await</span><span><span> </span><span>crypto</span><span>.</span></span><span>subtle</span><span>.</span><span>importKey</span><span>(</span><span>"raw"</span><span><span>, </span><span>key32</span><span>, </span></span><span>"AES-ECB"</span><span>, </span><span>false</span><span>, [</span><span>"encrypt"</span><span>, </span><span>"decrypt"</span><span>]);</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>pad</span><span>  </span><span>=</span><span><span> (</span><span>b</span><span>) </span></span><span>=&gt;</span><span> { </span><span>const</span><span> </span><span>n</span><span> </span><span>=</span><span> </span><span>32</span><span> </span><span>-</span><span><span> (</span><span>b</span><span>.</span></span><span>length</span><span> </span><span>%</span><span> </span><span>32</span><span>); </span><span>const</span><span> </span><span>p</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>Uint8Array</span><span><span>(</span><span>n</span><span>).</span></span><span>fill</span><span><span>(</span><span>n</span><span>); </span></span><span>const</span><span> </span><span>o</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>Uint8Array</span><span><span>(</span><span>b</span><span>.</span></span><span>length</span><span> </span><span>+</span><span><span> </span><span>n</span><span>); </span><span>o</span><span>.</span></span><span>set</span><span><span>(</span><span>b</span><span>); </span><span>o</span><span>.</span></span><span>set</span><span><span>(</span><span>p</span><span>, </span><span>b</span><span>.</span></span><span>length</span><span>); </span><span>return</span><span><span> </span><span>o</span><span>; };</span></span></div></div><div><div><div>5</div></div><div><span>  </span><span>const</span><span> </span><span>unpad</span><span> </span><span>=</span><span><span> (</span><span>b</span><span>) </span></span><span>=&gt;</span><span><span> </span><span>b</span><span>.</span></span><span>subarray</span><span>(</span><span>0</span><span><span>, </span><span>b</span><span>.</span></span><span>length</span><span> </span><span>-</span><span><span> </span><span>b</span><span>[</span><span>b</span><span>.</span></span><span>length</span><span> </span><span>-</span><span> </span><span>1</span><span>]);</span></div></div><div><div><div>6</div></div><div><span>  </span><span>let</span><span><span> </span><span>prev</span><span> </span></span><span>=</span><span><span> </span><span>iv16</span><span>, </span><span>out</span><span> </span></span><span>=</span><span> </span><span>new</span><span> </span><span>Uint8Array</span><span><span>(</span><span>decrypt</span><span> </span></span><span>?</span><span><span> </span><span>data</span><span> </span></span><span>:</span><span> </span><span>pad</span><span><span>(</span><span>data</span><span>));</span></span></div></div><div><div><div>7</div></div><div><span>  </span><span>const</span><span> </span><span>step</span><span> </span><span>=</span><span><span> </span><span>decrypt</span><span> </span></span><span>?</span><span><span> </span><span>data</span><span>.</span></span><span>length</span><span> </span><span>/</span><span> </span><span>16</span><span> </span><span>:</span><span><span> </span><span>out</span><span>.</span></span><span>length</span><span> </span><span>/</span><span> </span><span>16</span><span>;</span></div></div><div><div><div>8</div></div><div><span>  </span><span>for</span><span> (</span><span>let</span><span><span> </span><span>i</span><span> </span></span><span>=</span><span> </span><span>0</span><span><span>; </span><span>i</span><span> </span></span><span>&lt;</span><span><span> </span><span>step</span><span>; </span><span>i</span></span><span>++</span><span>) {</span></div></div><div><div><div>9</div></div><div><span>    </span><span>const</span><span> </span><span>blk</span><span> </span><span>=</span><span><span> </span><span>out</span><span>.</span></span><span>subarray</span><span><span>(</span><span>i</span><span> </span></span><span>*</span><span> </span><span>16</span><span><span>, </span><span>i</span><span> </span></span><span>*</span><span> </span><span>16</span><span> </span><span>+</span><span> </span><span>16</span><span>);</span></div></div><div><div><div>10</div></div><div><span>    </span><span>const</span><span> </span><span>d</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>Uint8Array</span><span>(</span><span>await</span><span><span> </span><span>crypto</span><span>.</span></span><span>subtle</span><span><span>[</span><span>decrypt</span><span> </span></span><span>?</span><span> </span><span>"decrypt"</span><span> </span><span>:</span><span> </span><span>"encrypt"</span><span>]({ </span><span>name</span><span>:</span><span> </span><span>"AES-ECB"</span><span><span> }, </span><span>wkey</span><span>, </span><span>blk</span><span>));</span></span></div></div><div><div><div>11</div></div><div><span>    </span><span>for</span><span> (</span><span>let</span><span><span> </span><span>j</span><span> </span></span><span>=</span><span> </span><span>0</span><span><span>; </span><span>j</span><span> </span></span><span>&lt;</span><span> </span><span>16</span><span><span>; </span><span>j</span></span><span>++</span><span><span>) </span><span>d</span><span>[</span><span>j</span><span>] </span></span><span>^=</span><span><span> </span><span>prev</span><span>[</span><span>j</span><span>];</span></span></div></div><div><div><div>12</div></div><div><span>    </span><span>if</span><span><span> (</span><span>decrypt</span><span>) </span><span>prev</span><span> </span></span><span>=</span><span><span> </span><span>blk</span><span>.</span></span><span>slice</span><span>(); </span><span>else</span><span><span> { </span><span>prev</span><span> </span></span><span>=</span><span><span> </span><span>d</span><span>.</span></span><span>slice</span><span><span>(); </span><span>out</span><span>.</span></span><span>set</span><span><span>(</span><span>d</span><span>, </span><span>i</span><span> </span></span><span>*</span><span> </span><span>16</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>return</span><span><span> </span><span>decrypt</span><span> </span></span><span>?</span><span> </span><span>unpad</span><span><span>(</span><span>out</span><span>) </span></span><span>:</span><span><span> </span><span>out</span><span>;</span></span></div></div><div><div><div>15</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>这段在 Cloudflare Worker 和 EdgeOne 边缘函数里各放了一份，逻辑完全一样，企微都能正确解开。也是因为它没有 npm 依赖，后面做纯 CF / 纯 EO 才能直接复制过去用。</p></section><section><h3>坑二：回包里多写一个 AgentID，企微直接静默丢弃<a href="#坑二回包里多写一个-agentid企微直接静默丢弃"><span>#</span></a></h3><p>这个坑最麻烦，因为它不报错。</p><p>被动回复的 XML 结构企微是有标准 schema 的，里面没有 AgentID 这个字段。我当时顺手把 agentid 也塞进了回包（想着反正带上呗），结果企微收下后什么都不回、什么都不报错，表现就是「收不到任何回复」。我对着日志看了半天，加密对了、验签对了、回包也返回 200 了，就是没动静。</p><p>最后发现是那个多余的 AgentID 字段让企微判定回包非法，整条静默丢掉。把回包 XML 里的 AgentID 删掉、重部署，机器人就能收到消息了。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 回包内层 XML：千万别带 AgentID</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>xml</span><span> </span><span>=</span><span> </span><span>`&lt;xml&gt;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>&lt;Encrypt&gt;&lt;![CDATA[</span><span>${</span><span>encrypt</span><span>}</span><span>]]&gt;&lt;/Encrypt&gt;</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>&lt;MsgSignature&gt;</span><span>${</span><span>sig</span><span>}</span><span>&lt;/MsgSignature&gt;</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>&lt;TimeStamp&gt;</span><span>${</span><span>ts</span><span>}</span><span>&lt;/TimeStamp&gt;</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>&lt;Nonce&gt;</span><span>${</span><span>nonce</span><span>}</span><span>&lt;/Nonce&gt;</span></div></div><div><div><div>7</div></div><div><span>&lt;/xml&gt;`</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><hr /></section></section>
<section><h2>机器人到底能干什么<a href="#机器人到底能干什么"><span>#</span></a></h2><p>指令集现在是这样的，发文字给它就会攒成一个草稿，指令控制行为：</p>

<table><thead><tr><th>指令</th><th>作用</th></tr></thead><tbody><tr><td><code>/done</code> 或 <code>/发布</code></td><td>把当前草稿发布成文章或动态</td></tr><tr><td><code>/取消</code> <code>/清空</code> <code>/cancel</code></td><td>丢弃草稿，下次从头写</td></tr><tr><td><code>/动态</code></td><td>切到动态模式（短内容，文件名用时间戳）</td></tr><tr><td><code>/文章</code></td><td>切到文章模式（文件名用标题）</td></tr><tr><td><code>/tags</code></td><td>设置文章标签，如 <code>/tags: 折腾, 教程</code></td></tr><tr><td><code>/状态</code></td><td>看看当前草稿攒了多少、什么模式</td></tr><tr><td><code>/预览</code></td><td>返回将要发布的 markdown 原文，不发</td></tr><tr><td><code>/修改</code></td><td>把草稿全文甩回给你，下次回复整体替换重算</td></tr><tr><td><code>/帮助</code></td><td>打印上面这一张表</td></tr></tbody></table><p>文字里以「键: 值」开头的行会自动识别成文章属性，中英文键名都认（<code>标题：</code> / <code>tags:</code> 都行），剩下的当正文。几个常用的点：</p><ul>
<li>文件名：动态固定 <code>2026-08-14-020613.md</code>；文章用 FILENAME_FORMAT 开关——<code>title</code>（默认）就是纯标题 <code>标题.md</code>，<code>date</code> 就是 <code>2026-08-14-标题.md</code>。</li>
<li>自定义链接：写 <code>slug: 05</code>，发布后返回 <code>https://你的域名/posts/05</code>；不设 slug 就退回文件名链接。</li>
<li>动态专属：还能写 <code>location: 广西</code> 和 <code>pinned: true</code>，直接进 frontmatter。</li>
<li>正文插链接：标准 Markdown <code>[文字](url)</code> 就行，链接行不会被误吞。</li>
<li>封面：明确写 <code>封面：https://...</code> 才用；不写就 <code>image: api</code>（随机封面），发的图只进正文不抢封面。</li>
</ul><p>标记（@提及 / #话题标签）目前不可用，正文直接写文字即可。</p><hr /></section>
<section><h2>三种部署模式<a href="#三种部署模式"><span>#</span></a></h2><p>用环境变量 DEPLOY_MODE 切换角色。现在三种模式随时换：</p><ol>
<li>
<p><strong>hybrid（默认）</strong></p>
<p>EdgeOne 当入口解密回包，真正的写博客交给 Cloudflare Worker 的 <code>/internal</code>。国内延迟最低，两套东西各司其职。</p>
</li>
<li>
<p><strong>eo（纯 EdgeOne）</strong></p>
<p>EdgeOne 自己解密、自己 dispatch、自己写 GitHub，草稿存 EdgeOne 的 KV（绑定名 <code>DRAFTS</code>），Worker 彻底退休。入口本来就在 EO、解密回包也已跑通，只补「写 GitHub + KV 存草稿」两步，是改动最小的一个。</p>
<div><div><div></div><div>Tip</div></div><div><p>EdgeOne 的 KV 存储仅规则引擎形态边缘函数支持，普通 JS 边缘函数无法绑定 KV；切换为 eo 模式部署前先确认你的边缘函数类型。</p></div></div>
<p>代码已存档在仓库 <code>edge-functions/wxbot.js</code>（与图床反代 <code>index.js</code> 分立），部署时作为企微入口边缘函数发布即可。</p>
</li>
<li>
<p><strong>cf（纯 Cloudflare）</strong></p>
<p>反过来，回调直接打 Cloudflare Worker，Worker 自己解密 + 回包 + 写 GitHub。需要把 EO 那份纯 JS AES 加密回包逻辑搬进 Worker（已经搬了，且跑了加解密往返测试，企微能正确解开）。</p>
</li>
</ol><p>切换就是改环境变量，业务逻辑同源、指令集完全一致：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># EdgeOne 侧（index.js 的环境变量）</span></div></div><div><div><div>2</div></div><div><span>DEPLOY_MODE</span><span> = </span><span>"hybrid"</span><span>   </span><span># hybrid / eo</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span># Cloudflare 侧（wrangler.toml 的 [vars]）</span></div></div><div><div><div>5</div></div><div><span>DEPLOY_MODE</span><span> = </span><span>"hybrid"</span><span>   </span><span># hybrid / cf</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>切到 cf 时，企业微信回调的入口域名得从 EdgeOne 换成 Cloudflare 的 Worker 域名——所以「换模式」不只是改个 env，还牵动域名指向，部署时要一起改。</p><p>纯 EO 那个 KV 绑定我在 EdgeOne 规则引擎形态里实测过能跑；万一你控制台那边 KV 绑定名不是 DRAFTS，回去补一下绑定就行，代码不用动。</p><hr /></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/5/</id>
      <title type="text">用企业微信机器人发博文、动态</title>
      <published>2026-08-13T00:00:00.000Z</published>
      <updated>2026-08-13T00:00:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/5/"/>
      <summary type="text">这是一篇使用微信机器人发布的博文~</summary>
      <content type="html"><![CDATA[<p>这是一篇使用微信机器人发布的博文~</p>
<p>昨天逛别人友链，看到有人写了篇「用微信发博文」的文章，点进去扫了一眼，觉得挺有意思。今天自己研究了一下，发现原作者的方案需要一台服务器——而我没有，这条路直接走不通。</p>
<p>那就换个思路。企业微信的机器人不用服务器也能收消息；再借 EdgeOne 的边缘函数把消息转到 Cloudflare Worker，Worker 负责把内容拼成 markdown 丢进 GitHub，GitHub Actions 一跑，博客就自动发出来了。</p>
<p>整条链路全白嫖，零成本把整个项目跑起来了 ♫꒰･◡･๑꒱</p>
<blockquote><p>如果你有服务器，原作者的方案更简单，可以参考：<a href="https://blog.tsh520.cn/posts/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/%E5%BE%AE%E4%BF%A1%E5%8F%91%E5%B8%83%E5%8D%9A%E5%AE%A2%E5%8A%A8%E6%80%81%E6%95%99%E7%A8%8B/" target="_blank">把博客后台搬进微信：随手一条消息就能发动态、笔记、足迹</a></p></blockquote>
<p>一开始我其实打算全丢在 Cloudflare 上部署的。结果卡在企业微信机器人的 API 回调，怎么配都访问不通。当时以为是 Cloudflare 源站被墙了，就把入口换成了 EdgeOne。</p>
<p>后来排查才发现，根本不是源站的问题，是我自己加密算法没写好。也就是说，当初可能压根不用换入口。这个坑后面专门写一篇，顺便研究下能不能弄出「纯 CF」和「纯 EO」两个更干净的版本。</p>
<p>发动态是真方便：切到动态模式随手一句就发，出门在外不用再登后台。</p>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/4/</id>
      <title type="text">给博客接入一言（Hitokoto）：四种展示位的统一思路</title>
      <published>2026-08-08T00:00:00.000Z</published>
      <updated>2026-08-19T00:00:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/4/"/>
      <summary type="text">弄完天气组件之后，侧边栏总觉得还差点意思。一言（Hitokoto）这种一句小句子很适合我~</summary>
      <content type="html"><![CDATA[<p>弄完天气组件之后，侧边栏总觉得还差点意思。一言（Hitokoto）这种一句小句子很适合：侧边栏挂一条、首页横幅打字机刷一句、页脚来一句、文章里随手插一句。</p>
<section><h2>两个核心决定<a href="#两个核心决定"><span>#</span></a></h2><p><strong>1. 语句从哪来：local 打包 vs remote 拉取</strong></p><p><code>sentences-bundle</code> 按 <code>a~l</code> 共 12 类分文件存。组件里两种模式靠 <code>bundleSource</code> 切换：</p><ul>
<li><code>local</code>：构建时打包进 <code>public/hitokoto/hitokoto.json</code>，零外部依赖，加载最快；</li>
<li><code>remote</code>：运行时直接拉 jsDelivr 远程包，不想跑构建就选它，但多一个 <code>fallbackToLocal</code> 开关。</li>
</ul><p><strong>2. 四类展示位统一</strong></p><p>侧边栏 widget、首页横幅、页脚、文章内 <code>::hitokoto</code> 指令，<strong>共用同一个配置中心 <code>hitokotoConfig</code> 和同一个 fetcher <code>src/utils/hitokoto.ts</code></strong>。区别只在”展示参数”和”装数据的容器”。横幅额外有个独立 <code>banner</code> 块（独立开关 + 分类白名单 + 打字机），其它三个读全局开关再用各自 <code>data-*</code> 覆盖。</p><p>一句话概括：<strong>一个配置中心 + 一个 fetcher + N 个展示位各自接线</strong>。把开关收拢到 <code>hitokotoConfig</code> 之后，想调展示、换来源、加展示位都是局部改动，不会牵一发动全身。</p><div><div><div><div><span><p>local</p></span></div><div><span><p>remote</p></span></div><div><span><p>失败</p></span></div><div><span><p>开</p></span></div><div><span><p>关</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>bundleSource</p></span></div><div><span><p>本地包</p></span></div><div><span><p>拉远程包</p></span></div><div><span><p>fallbackToLocal</p></span></div><div><span><p>侧边栏/页脚/文章内隐藏</p></span></div><div><span><p>展示一条</p></span></div><div><span><p>侧边栏</p></span></div><div><span><p>横幅打字机</p></span></div><div><span><p>页脚</p></span></div><div><span><p>文章内指令</p></span></div>
</div><div><div><span><p>local</p></span></div><div><span><p>remote</p></span></div><div><span><p>失败</p></span></div><div><span><p>开</p></span></div><div><span><p>关</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>bundleSource</p></span></div><div><span><p>本地包</p></span></div><div><span><p>拉远程包</p></span></div><div><span><p>fallbackToLocal</p></span></div><div><span><p>侧边栏/页脚/文章内隐藏</p></span></div><div><span><p>展示一条</p></span></div><div><span><p>侧边栏</p></span></div><div><span><p>横幅打字机</p></span></div><div><span><p>页脚</p></span></div><div><span><p>文章内指令</p></span></div>
</div></div></div></section>
<section><h2>几个真坑<a href="#几个真坑"><span>#</span></a></h2><p><strong>1. 缓存没 TTL → 换不了句子</strong></p><p>早期直接裸数组缓存，部署更新了 <code>hitokoto.json</code> 对已缓存老访客无效，表现就是”换不了句子”。加 6 小时 TTL 后，老访客最多 6 小时内自动拿到新库；旧格式裸数组兼容成 <code>ts=0</code>，自然失效重拉。缓存 key 要含 <code>bundleSource + 启用分类 + remoteUrl</code>——你关了某分类 key 就变，缓存立刻失效，不会显示关掉的类别。</p><p><strong>2. fallbackToLocal 的两种行为别混</strong></p><p>开关开 → 退本地包，组件照常显示；开关关且远程挂 → 侧边栏/页脚/文章内直接隐藏，但<strong>首页横幅副标题要切到主题 TypewriterText 预设节点</strong>（无预设才隐藏）。这两类行为不一样，写回退逻辑时得分开处理。</p><p><strong>3. 页脚在 Swup 容器外</strong></p><p>其它展示位在 Swup 容器里，页面切换 DOM 重渲染会重新触发绑定；页脚不在，跨页面不换 DOM。所以每次导航要把它的 <code>dataset.filled</code> 重置成 <code>"0"</code> 强制重拉，否则翻页后那句不更新。</p><p><strong>4. 打字机按码点切分</strong></p><p><code>Array.from(full)</code> 而不是 <code>String[i]</code>，否则中文/emoji 会截一半乱码。</p><p><strong>5. remoteUrl 必须 @master</strong></p><p><code>sentences-bundle</code> 没 release tag，<code>@latest</code> 在 jsDelivr 直接 404。另外六个语言文件的 <code>i18nKey</code> 漏一个，<code>astro check</code> 就报翻译缺 key，部署前过一遍 check。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/3/</id>
      <title type="text">给博客侧边栏加一个天气组件</title>
      <published>2026-08-05T00:00:00.000Z</published>
      <updated>2026-08-19T00:00:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/3/"/>
      <summary type="text">侧边栏一直空着一块位置，想着放个天气小组件挺实用——一眼看到温度、空气质量，点开看湿度风力。</summary>
      <content type="html"><![CDATA[<p>侧边栏一直空着一块位置，想着放个天气小组件挺实用——一眼看到温度、空气质量，点开能看湿度和风力。做完发现坑不少：Astro 的 scoped 样式对运行时注入的 DOM 不生效、localStorage 缓存解包把数据搞丢、Swup 页面切换脚本不重入。</p>
<p>组件基于 <strong>Firefly 主题</strong>开发，主要用到它的侧边栏 widget 注册体系。</p>
<section><h2>核心思路<a href="#核心思路"><span>#</span></a></h2><p>博客是纯静态站，天气数据只能在浏览器里 <code>fetch</code>，没法构建时拿。这决定了两件事：接口得允许跨域，国内那些要登录、要 Key、还常限频的基本可以排除。</p><p>最后定了四个源，原则是能免 Key 就免 Key、国内优先：</p>

<table><thead><tr><th>数据源</th><th>Key</th><th>角色</th></tr></thead><tbody><tr><td>uapis.cn</td><td>不需要</td><td>国内主力，单接口返回全部，反射 Origin 放行跨域</td></tr><tr><td>和风天气</td><td>需要</td><td>兜底，实时更准</td></tr><tr><td>Open-Meteo</td><td>不需要</td><td>坐标出中国包围盒就用，免费不限频</td></tr><tr><td>wttr.in</td><td>不需要</td><td>全球兜底，不带空气质量</td></tr></tbody></table><p><strong>整个组件最关键的设计是四源归一化</strong>：不管哪个源，全部 map 成同一套 <code>{cur, daily, aqi, city}</code>，渲染层完全不关心数据从哪来。后面换源、加源都不用动 UI。配置中心（weatherConfig）+ fetcher（拉 + 归一化 + 缓存）+ 渲染三层分离，widget 只管画。</p><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>uapis</p></span></div><div><span><p>归一化</p></span></div><div><span><p>和风</p></span></div><div><span><p>Open-Meteo</p></span></div><div><span><p>wttr</p></span></div><div><span><p>统一结构 {cur,daily,aqi,city}</p></span></div><div><span><p>渲染层</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>uapis</p></span></div><div><span><p>归一化</p></span></div><div><span><p>和风</p></span></div><div><span><p>Open-Meteo</p></span></div><div><span><p>wttr</p></span></div><div><span><p>统一结构 {cur,daily,aqi,city}</p></span></div><div><span><p>渲染层</p></span></div>
</div></div></div></section>
<section><h2>几个真正卡人的点<a href="#几个真正卡人的点"><span>#</span></a></h2><p><strong>1. scoped 样式对动态 DOM 不生效</strong></p><p>组件运行时往 DOM 塞内容，Astro 的 scoped <code>&lt;style&gt;</code> 只对编译期存在的节点打 hash，运行时注入的吃不到。表现就是样式明明写对了，刷新出来没效果。解法要么用 <code>:global()</code>，要么把样式丢进 <code>&lt;style is:global&gt;</code> 或干脆 inline。这个坑不在逻辑，而在「为什么样式不生效」这个排查方向上。</p><p><strong>2. Swup 重入</strong></p><p>Firefly 用 Swup 做 SPA 跳转，脚本不重载但 DOM 会换。所有客户端脚本要 <code>document.addEventListener("astro:page-load", fn)</code> 重新绑定，首屏再直接 <code>fn()</code> 跑一次（防错过首次事件），并用 <code>dataset.bound</code> 之类的标志位防重复绑。不这么干，翻几页事件就绑了好几遍，打字机动画还会两段打架——用递增 token 作废上一段动画：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>let</span><span><span> </span><span>_token</span><span> </span></span><span>=</span><span> </span><span>0</span><span>;</span></div></div><div><div><div>2</div></div><div><span>function</span><span> </span><span>loop</span><span>() {</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>my</span><span> </span><span>=</span><span> </span><span>++</span><span><span>_token</span><span>;</span></span></div></div><div><div><div>4</div></div><div><span>  </span><span>// 循环里每步先判断 if (my !== _token) return;</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div><span><span>document</span><span>.</span></span><span>addEventListener</span><span>(</span><span>"astro:page-load"</span><span><span>, </span><span>loop</span><span>);</span></span></div></div><div><div><div>7</div></div><div><span>loop</span><span>();</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><strong>3. 缓存别裸存数组</strong></p><p>localStorage 里直接 <code>setItem(JSON.stringify(arr))</code> 看着省事，结构一变老访客解包就丢数据或报错。我存 <code>{ts, data}</code>，带 6 小时 TTL，结构变了老缓存自动失效重拉：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>ok</span><span> </span><span>=</span><span><span> </span><span>Array</span><span>.</span></span><span>isArray</span><span><span>(</span><span>obj</span><span>?.</span></span><span>data</span><span>) </span><span>&amp;&amp;</span><span><span> </span><span>Date</span><span>.</span></span><span>now</span><span>() </span><span>-</span><span><span> (</span><span>obj</span><span>?.</span></span><span>ts</span><span> </span><span>||</span><span> </span><span>0</span><span>) </span><span>&lt;</span><span> </span><span>TTL</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>单位与映射的坑<a href="#单位与映射的坑"><span>#</span></a></h2><ul>
<li>和风风速是 m/s，要 ×3.6 成 km/h；Open-Meteo 用 <code>wind_speed_unit=kmh</code> 直接是 km/h；wttr 的 <code>windspeedKmph</code> 已是 km/h。别混。</li>
<li>风向 16 方位压成 8 方位：<code>Math.round(((deg + 22.5) % 360) / 45) % 8</code>。</li>
<li>beaufort 等级用速度中点表近似，展示够用，别追求精确。</li>
<li>空气质量：uapis 直接给 aqi 值，和风走 <code>/airquality</code>，Open-Meteo 没有 AQI 得另算，兜底层级要想清楚。</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/2/</id>
      <title type="text">Firefly-Markdown：为 Firefly 博客打造的零依赖 Markdown 编辑器</title>
      <published>2026-07-28T00:00:00.000Z</published>
      <updated>2026-07-28T00:00:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/2/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>写 Firefly 博客时，最烦的往往不是写正文，而是手工写 FrontMatter：字段一堆、引号冒号要转义，写错一处整个页面就崩。所以做了 Firefly-Markdown——一个配 Firefly 主题的所见即所得本地 Markdown 编辑器。</p>
<section><h2>它是什么<a href="#它是什么"><span>#</span></a></h2><p>纯前端、零外部依赖、离线可用。把文件放在同一目录，双击 <code>index.html</code> 就能用，不需要 Node、不需要构建，也不依赖网络。反正就是写篇随笔，不想先起一套构建流程，打开就能写，关掉就收工。</p></section>
<section><h2>能做什么<a href="#能做什么"><span>#</span></a></h2><ul>
<li>零依赖 / 离线可用：无框架、无构建、无 CDN，断网照常工作。</li>
<li>预览模拟博客样式：解析器是自己写的，预览效果和 Firefly 大致相同，包括封面、标题、描述、标签这些文章头部卡片，支持滚动同步。</li>
<li>FrontMatter 可视化：标题、描述、标签、分类、封面（随机图 / 自定义）、许可证、密码保护等字段全做成表单，自动转义，一键导出标准 MD。</li>
<li>扩展语法：Expressive Code 风格代码块（窗口框、行标记、ANSI 着色、折叠、代码组、diff）、KaTeX 公式（已本地打包，离线可用）、提示块、Mermaid / PlantUML、文章卡片、内部链接。</li>
<li>编辑体验：工具栏、<code>/</code> 斜杠命令、查找替换、富文本粘贴、文档库多文章管理、自动保存。</li>
</ul></section>
<section><h2>项目地址<a href="#项目地址"><span>#</span></a></h2><a href="https://github.com/ImYufish/Firefly-Markdown" target="_blank"><div><div><div><div></div><div>ImYufish</div></div><div>/</div><div>Firefly-Markdown</div></div><div></div></div><div>专为Firefly主题设计，可视化填写文章信息、一键生成标准 YAML FrontMatter 博客文章，开箱即用。</div><div><div>—</div><div>—</div><div>no-license</div><span>JavaScript</span></div></a><p>克隆或下载后双击 <code>index.html</code> 就能开始用。遇到问题或者想加点什么，欢迎去仓库提 Issue。</p></section>
<section><h2>可选的服务器模式<a href="#可选的服务器模式"><span>#</span></a></h2><a href="https://github.com/ImYufish/firefly-markdown-worker" target="_blank"><div><div><div><div></div><div>ImYufish</div></div><div>/</div><div>firefly-markdown-worker</div></div><div></div></div><div>Firefly-Markdown backend: Cloudflare Workers + Hono + D1 + R2 (GitHub OAuth login + Contents API proxy)</div><div><div>—</div><div>—</div><div>no-license</div><span>JavaScript</span></div></a><p>默认是纯前端：在设置里粘贴一个 GitHub PAT（Personal Access Token，需要 <code>repo</code> 权限），就能把文章直接推到你博客仓库的 <code>src/content/posts</code>，保存即发布。</p><p>不想在浏览器里暴露 PAT 的话，可以启用服务器模式：</p><ul>
<li>部署一个 Cloudflare Worker 作后端（代码在 <code>firefly-markdown-worker</code>），负责 OAuth 登录并代理 GitHub Contents API；</li>
<li>编辑器里填好后端地址，用 GitHub 账号 OAuth 登录，全程不接触 PAT。</li>
</ul><blockquote><p>用 PAT 直连不需要任何后端，只有想走 OAuth 登录时才用得上它。</p></blockquote></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/1/</id>
      <title type="text">世界，您好！</title>
      <published>2022-04-01T23:23:28.000Z</published>
      <updated>2026-06-11T14:35:18.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/1/"/>
      <summary type="text"></summary>
      <content type="html"><![CDATA[<p>欢迎使用WordPress。这是您的第一篇文章。编辑或删除它，然后开始写作吧！</p>
<p>欢迎使用 Astro ，这是您的第一篇文章。编辑或删除它，然后尽情Ciallo~</p>]]></content>
    </entry>
</feed>
