<?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-10-09T17:28:33.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/20/</id>
      <title type="text">给博客写了个缓存预热脚本</title>
      <published>2026-10-09T17:28:33.000Z</published>
      <updated>2026-10-09T17:28:33.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/20/"/>
      <summary type="text">博客部署在 EdgeOne 免费版上，没有预热 API。手里有台服务器能公网出访，就写了个脚本模拟真实访问把边缘缓存填一遍，顺带做了并发、重试、缓存命中统计和二次验证。</summary>
      <content type="html"><![CDATA[<p>站点现在部署在 EdgeOne 上，免费版。免费版没有缓存预热 API。<code>CreatePrefetchTask</code> 要个人版往上才有，白嫖党用不了。</p>
<p>但我手里有台轻量云服务器能稳定公网出访。预热说白了就是让边缘节点先把内容缓存上——官方不给我接口，我自己模拟一次访问不就完了？从 sitemap 把页面 URL 全拉出来，逐个走公网 GET 一遍，请求打到 EdgeOne 边缘，没命中就回源，回源完边缘就把这份内容存下来，下一次真实访客来就是 HIT，道理就这么朴素。</p>
<p>缺点是预热只能预热服务器周围的节点，再远一些就没办法了。</p>
<section><h2>怎么模拟”预热”<a href="#怎么模拟预热"><span>#</span></a></h2><p>其实就一个动作：curl 公网 GET 一遍。得强调”公网”——本地 curl 127.0.0.1 或者内网地址根本不经过 EdgeOne，缓存自然填不上。所以 <code>SITE</code> 必须填对外域名 <code>https://x1anyu.cn</code>，脚本里还专门做了去末尾斜杠的防呆，并且把 <code>.</code> 转义成正则，后面过滤本站 URL 用得上。</p><p>单次请求我用 <code>req</code> 包了一层：带超时、失败指数退避重试，再从响应头抠出 <code>EO-Cache-Status</code> 判断命中情况。</p><div><div><div><figure><figcaption><span></span><span>Terminal window</span><span>展开</span><span>收起</span></figcaption><pre><code><div><div><div>1</div></div><div><span>req</span><span>() {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>local</span><span> </span><span>url</span><span>=</span><span>"</span><span>$1</span><span>"</span><span> </span><span>out</span><span>=</span><span>"</span><span><span>${2</span><span>:-/</span></span><span>dev</span><span>/</span><span>null</span><span>}</span><span>"</span></div></div><div><div><div>3</div></div><div><span>  </span><span>local</span><span> </span><span>attempt</span><span>=</span><span>0</span><span> </span><span>code</span><span>=</span><span>000</span><span> </span><span>cache</span><span>=</span><span>"UNKNOWN"</span><span> </span><span>wait</span><span>=</span><span>1</span></div></div><div><div><div>4</div></div><div><span>  </span><span>local</span><span> </span><span>hdr</span><span>=</span><span>"</span><span>$TMPDIR</span><span>/hdr.</span><span>$$</span><span>"</span></div></div><div><div><div>5</div></div><div><span>  </span><span>while</span><span> </span><span>:</span><span>; </span><span>do</span></div></div><div><div><div>6</div></div><div><span>    </span><span>:</span><span> &gt; </span><span>"</span><span>$hdr</span><span>"</span></div></div><div><div><div>7</div></div><div><span>    </span><span>code</span><span>=</span><span>"$(</span><span>curl</span><span> </span><span>-sS</span><span> </span><span>--compressed</span><span> </span><span>-m</span><span> "</span><span>$TIMEOUT</span><span>" </span><span>-A</span><span> "</span><span>$UA</span><span>" </span><span>\</span></div></div><div><div><div>8</div></div><div><span>              </span><span>-D</span><span> "</span><span>$hdr</span><span>" </span><span>-o</span><span> "</span><span>$out</span><span>" </span><span>-w</span><span> '%{http_code}' "</span><span>$url</span><span>" </span><span>2&gt;</span><span>/dev/null </span><span>||</span><span> </span><span>echo</span><span> </span><span>000</span><span>)"</span></div></div><div><div><div>9</div></div><div><span>    </span><span>cache</span><span>=</span><span>"$(</span><span>grep</span><span> </span><span>-i</span><span> '^EO-Cache-Status:' "</span><span>$hdr</span><span>" </span><span>2&gt;</span><span>/dev/null </span><span>\</span></div></div><div><div><div>10</div></div><div><span>              </span><span>|</span><span> </span><span>head</span><span> </span><span>-n1</span><span> </span><span>|</span><span> </span><span>sed</span><span> </span><span>-E</span><span> 's/^[^:]+:[[:space:]]*//; s/\r$//')"</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>[ </span><span>-z</span><span> </span><span>"</span><span>$cache</span><span>"</span><span> ] &amp;&amp; </span><span>cache</span><span>=</span><span>"UNKNOWN"</span></div></div><div><div><div>12</div></div><div><span>    </span><span>if</span><span> [ </span><span>"</span><span><span>${</span><span>code</span></span><span>:</span><span>0</span><span>:</span><span><span>1</span><span>}</span></span><span>"</span><span><span> </span><span>=</span><span> </span></span><span>"2"</span><span> ] || [ </span><span>"</span><span><span>${</span><span>code</span></span><span>:</span><span>0</span><span>:</span><span><span>1</span><span>}</span></span><span>"</span><span><span> </span><span>=</span><span> </span></span><span>"3"</span><span> ]; </span><span>then</span></div></div><div><div><div>13</div></div><div><span>      </span><span>echo</span><span> </span><span>"</span><span>$code</span><span>|</span><span>$cache</span><span>"</span><span>; </span><span>return</span><span> </span><span>0</span></div></div><div><div><div>14</div></div><div><span>    </span><span>fi</span></div></div><div><div><div>15</div></div><div><span>    </span><span>attempt</span><span><span>=</span><span>$((</span></span><span>attempt+1</span><span>))</span></div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>[ </span><span>"</span><span>$attempt</span><span>"</span><span><span> </span><span>-gt</span><span> </span></span><span>"</span><span>$RETRY</span><span>"</span><span> ] &amp;&amp; { </span><span>echo</span><span> </span><span>"</span><span>$code</span><span>|</span><span>$cache</span><span>"</span><span>; </span><span>return</span><span> </span><span>1</span><span>; }</span></div></div><div><div><div>17</div></div><div><span>    </span><span>sleep</span><span> </span><span>"</span><span>$wait</span><span>"</span><span>; </span><span>wait</span><span><span>=</span><span>$((</span></span><span>wait</span><span>*2</span><span>))</span></div></div><div><div><div>18</div></div><div><span>  </span><span>done</span></div></div><div><div><div>19</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>缓存状态看响应头里的 <code>EO-Cache-Status</code>，值是 <code>HIT</code> / <code>MISS</code> / <code>RefreshHit</code> 这类。首次预热 MISS 是正常的——本来就是去填缓存的；跑完再打一遍才是看命中率的时机，后面说。</p></section>
<section><h2>页面预热完，资源也得管<a href="#页面预热完资源也得管"><span>#</span></a></h2><p>光预热 HTML 不够。页面里引用的 css/js/图片/字体，第一次访问时也照样回源。所以每拉完一个页面，我把响应体里本站的静态资源地址全抠出来，先收集，最后统一去重再预热一遍。公共 css/js 全站就一份，去重后只拉一次，省得重复打。</p></section>
<section><h2>一个真坑：别把文章里的文件名当成资源<a href="#一个真坑别把文章里的文件名当成资源"><span>#</span></a></h2><p>从 HTML 抓资源 URL，最早我用的是”抓所有带引号的路径字符串”，结果翻车了——文章代码块里写的示例文件名（比如 <code>wxbot.js</code>）会被当成真资源去请求，平白多发一堆 404。</p><p>后来改成只认 <code>href=</code> / <code>src=</code> / <code>srcset=</code> 这几个真实属性，再叠一道扩展名白名单和”只收本站路径”的过滤。这么一缩范围，正文和代码块里纯文字提到的文件名自然就进不来了。提取逻辑现在是这样的：</p><div><div><div><figure><figcaption><span></span><span>Terminal window</span><span>展开</span><span>收起</span></figcaption><pre><code><div><div><div>1</div></div><div><span>extract_assets</span><span>() {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>local</span><span> </span><span>html</span><span>=</span><span>"</span><span>$1</span><span>"</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>4</div></div><div><span>    </span><span>grep</span><span> </span><span>-oE</span><span> </span><span>'(href|src)="[^"]+"'</span><span> </span><span>"</span><span>$html</span><span>"</span><span> | </span><span>sed</span><span> </span><span>-E</span><span> </span><span>'s/^(href|src)="//; s/"$//'</span></div></div><div><div><div>5</div></div><div><span>    </span><span>grep</span><span> </span><span>-oE</span><span> </span><span>'srcset="[^"]+"'</span><span> </span><span>"</span><span>$html</span><span>"</span><span> | </span><span>sed</span><span> </span><span>-E</span><span> </span><span>'s/^srcset="//; s/"$//'</span><span> | </span><span>tr</span><span> </span><span>','</span><span> </span><span>'\n'</span><span> | </span><span>awk</span><span> </span><span>'{print $1}'</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>} | </span><span>while</span><span> </span><span>read</span><span> </span><span>-r</span><span> </span><span>u</span><span>; </span><span>do</span></div></div><div><div><div>7</div></div><div><span><span>        </span></span><span>[ </span><span>-z</span><span> </span><span>"</span><span>$u</span><span>"</span><span> ] &amp;&amp; </span><span>continue</span></div></div><div><div><div>8</div></div><div><span>        </span><span>u</span><span>=</span><span>"</span><span><span>${</span><span>u</span></span><span>%%</span><span>\#</span><span>*</span><span>}</span><span>"</span><span>; </span><span>u</span><span>=</span><span>"</span><span><span>${</span><span>u</span></span><span>%%</span><span>\?</span><span>*</span><span>}</span><span>"</span></div></div><div><div><div>9</div></div><div><span>        </span><span>case</span><span> </span><span>"</span><span>$u</span><span>"</span><span> </span><span>in</span></div></div><div><div><div>10</div></div><div><span>          </span><span>//</span><span>*</span><span>)  </span><span>u</span><span>=</span><span>"https:</span><span>$u</span><span>"</span><span> ;;</span></div></div><div><div><div>11</div></div><div><span>          </span><span>/</span><span>*</span><span>)   </span><span>u</span><span>=</span><span>"</span><span>$SITE$u</span><span>"</span><span> ;;</span></div></div><div><div><div>12</div></div><div><span>          </span><span>http</span><span>*</span><span>) ;;</span></div></div><div><div><div>13</div></div><div><span><span>          </span></span><span>*)    </span><span>continue</span><span> ;;</span></div></div><div><div><div>14</div></div><div><span>        </span><span>esac</span></div></div><div><div><div>15</div></div><div><span>        </span><span>case</span><span> </span><span>"</span><span>$u</span><span>"</span><span> </span><span>in</span><span> </span><span>"</span><span>$SITE</span><span>"</span><span>*</span><span>) ;; *) </span><span>continue</span><span> ;; </span><span>esac</span></div></div><div><div><div>16</div></div><div><span>        </span><span>case</span><span> </span><span>"</span><span>$u</span><span>"</span><span> </span><span>in</span></div></div><div><div><div>17</div></div><div><span>          </span><span>*</span><span>.css</span><span>|*</span><span>.js</span><span>|*</span><span>.png</span><span>|*</span><span>.jpg</span><span>|*</span><span>.jpeg</span><span>|*</span><span>.webp</span><span>|*</span><span>.gif</span><span>|*</span><span>.svg</span><span>|*</span><span>.ico</span><span>|*</span><span>.woff</span><span>|*</span><span>.woff2</span><span>|*</span><span>.ttf</span><span>|*</span><span>.otf</span><span>|*</span><span>.avif</span><span>)</span></div></div><div><div><div>18</div></div><div><span>            </span><span>echo</span><span> </span><span>"</span><span>$u</span><span>"</span><span> ;;</span></div></div><div><div><div>19</div></div><div><span>        </span><span>esac</span></div></div><div><div><div>20</div></div><div><span>      </span><span>done</span></div></div><div><div><div>21</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section>
<section><h2>脚本的几样功能<a href="#脚本的几样功能"><span>#</span></a></h2><ul>
<li>并发：默认 <code>CONCURRENCY=4</code>，用 <code>xargs -P</code> 拉起。想串行就设 1。</li>
<li>重试：<code>RETRY=2</code>，失败指数退避（1 秒、2 秒…），网络抖一下不至于整批挂掉。</li>
<li>统计：跑完按 <code>EO-Cache-Status</code> 汇总命中分布，顺手把非 2xx 的 URL 单独列出来，方便一眼看出哪些没预热成功。</li>
<li>二次验证：<code>VERIFY=1</code> 会等几秒让边缘生效，再把所有请求过的 URL 打一遍，统计 HIT 占比——这是检验”预热到底填上没有”最直接的方式。</li>
<li>干跑：<code>DRY_RUN=1</code> 只列 URL 不发请求，先确认采集到的页面数对不对，再真跑。</li>
</ul><p>所有配置项都能用环境变量覆盖，比如只预热前 20 条、并发 2：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>EO_WARM_LIMIT=20 EO_WARM_CONCURRENCY=2 bash /opt/eo-warm.sh</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>缓存状态是 UNKNOWN 不一定是你的问题<a href="#缓存状态是-unknown-不一定是你的问题"><span>#</span></a></h2><p>日志里 <code>EO-Cache-Status</code> 出现 <code>UNKNOWN</code>，说明那个请求根本没拿到缓存头。两种情况都不算脚本错：要么域名没解析到 EdgeOne（走的是别的回源），要么那个资源配置成了不缓存。脚本只是如实记下来，得你自己对着 DNS 和 EO 规则查。非 2xx 的 URL 我单独列了明细，缓存状态分布里也能看清有多少 MISS，方便定位。</p></section>
<section><h2>完整脚本<a href="#完整脚本"><span>#</span></a></h2><p>挂在 1Panel 的「计划任务 → Shell 脚本」里跑，<code>bash /opt/eo-warm.sh</code> 就行。完整版如下：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#!/usr/bin/env bash</span></div></div><div><div><div>2</div></div><div><span># eo-warm.sh —— EdgeOne（免费版）缓存预热脚本（含缓存命中统计 + 二次验证）</span></div></div><div><div><div>3</div></div><div><span>#</span></div></div><div><div><div>4</div></div><div><span># 原理：EdgeOne 免费版无预热 API，用「模拟真实访问」代替。</span></div></div><div><div><div>5</div></div><div><span>#       从 sitemap / robots.txt / 本地列表取 URL，走公网 GET 一遍，</span></div></div><div><div><div>6</div></div><div><span>#       命中边缘未命中则回源，边缘缓存被填充；页面引用的本站静态资源顺带预热。</span></div></div><div><div><div>7</div></div><div><span>#       通过响应头 EO-Cache-Status 判断 HIT / MISS / RefreshHit。</span></div></div><div><div><div>8</div></div><div><span>#</span></div></div><div><div><div>9</div></div><div><span># 用法：</span></div></div><div><div><div>10</div></div><div><span>#   bash /opt/eo-warm.sh</span></div></div><div><div><div>11</div></div><div><span>#   EO_WARM_LIMIT=20 EO_WARM_CONCURRENCY=2 bash /opt/eo-warm.sh</span></div></div><div><div><div>12</div></div><div><span>#   EO_WARM_DRY_RUN=1 bash /opt/eo-warm.sh          # 只列 URL</span></div></div><div><div><div>13</div></div><div><span>#   EO_WARM_VERIFY=1 bash /opt/eo-warm.sh           # 预热后再跑一遍验证命中率</span></div></div><div><div><div>14</div></div><div><span>set</span><span> </span><span>-u</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>###################### 配置：直接改这里的值即可 ######################</span></div></div><div><div><div>17</div></div><div><span>SITE</span><span>=</span><span>"https://x1anyu.cn"</span><span>           </span><span># 站点地址，不带末尾斜杠；必须走公网域名</span></div></div><div><div><div>18</div></div><div><span>SITEMAP</span><span>=</span><span>"/sitemap-index.xml"</span><span>       </span><span># sitemap 入口</span></div></div><div><div><div>19</div></div><div><span>ROBOTS</span><span>=</span><span>"/robots.txt"</span><span>               </span><span># robots 兜底（SITEMAP 取不到时用）</span></div></div><div><div><div>20</div></div><div><span>LOCAL_LIST</span><span>=</span><span>""</span><span>                      </span><span># 本地 URL 列表文件（可选，优先级最高）</span></div></div><div><div><div>21</div></div><div><span>DELAY</span><span>=</span><span>1</span><span>                            </span><span># 每个请求间隔（秒）</span></div></div><div><div><div>22</div></div><div><span>CONCURRENCY</span><span>=</span><span>4</span><span>                      </span><span># 并发数；1 = 串行。免费版建议 ≤4</span></div></div><div><div><div>23</div></div><div><span>RETRY</span><span>=</span><span>2</span><span>                            </span><span># 失败重试次数</span></div></div><div><div><div>24</div></div><div><span>TIMEOUT</span><span>=</span><span>20</span><span>                         </span><span># 单请求超时（秒）</span></div></div><div><div><div>25</div></div><div><span>LIMIT</span><span>=</span><span>0</span><span>                            </span><span># 只预热前 N 条页面；0 = 全量</span></div></div><div><div><div>26</div></div><div><span>WARM_ASSETS</span><span>=</span><span>1</span><span>                      </span><span># 1 = 顺带预热本站静态资源；0 = 只预热页面</span></div></div><div><div><div>27</div></div><div><span>LOG_LEVEL</span><span>=</span><span>"info"</span><span>                   </span><span># debug | info | warn | error</span></div></div><div><div><div>28</div></div><div><span>DRY_RUN</span><span>=</span><span>0</span><span>                          </span><span># 1 = 只列 URL 不发请求</span></div></div><div><div><div>29</div></div><div><span>VERIFY</span><span>=</span><span>0</span><span>                           </span><span># 1 = 预热后再跑一轮，统计 EO-Cache-Status</span></div></div><div><div><div>30</div></div><div><span>VERIFY_WAIT</span><span>=</span><span>5</span><span>                      </span><span># 二次验证前等待秒数</span></div></div><div><div><div>31</div></div><div><span>UA</span><span>=</span><span>"Mozilla/5.0 (compatible; SiteWarmer/1.0; +</span><span><span>${</span><span>SITE</span><span>}</span></span><span>)"</span></div></div><div><div><div>32</div></div><div><span>############################################################</span></div></div><div><div><div>33</div></div><div>
</div></div><div><div><div>34</div></div><div><span>###################### 环境变量覆盖 ######################</span></div></div><div><div><div>35</div></div><div><span>SITE</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_SITE</span></span><span>:-</span><span><span>$SITE</span><span>}</span></span><span>"</span></div></div><div><div><div>36</div></div><div><span>SITEMAP</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_SITEMAP</span></span><span>:-</span><span><span>$SITEMAP</span><span>}</span></span><span>"</span></div></div><div><div><div>37</div></div><div><span>ROBOTS</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_ROBOTS</span></span><span>:-</span><span><span>$ROBOTS</span><span>}</span></span><span>"</span></div></div><div><div><div>38</div></div><div><span>LOCAL_LIST</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_LOCAL_LIST</span></span><span>:-</span><span><span>$LOCAL_LIST</span><span>}</span></span><span>"</span></div></div><div><div><div>39</div></div><div><span>DELAY</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_DELAY</span></span><span>:-</span><span><span>$DELAY</span><span>}</span></span><span>"</span></div></div><div><div><div>40</div></div><div><span>CONCURRENCY</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_CONCURRENCY</span></span><span>:-</span><span><span>$CONCURRENCY</span><span>}</span></span><span>"</span></div></div><div><div><div>41</div></div><div><span>RETRY</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_RETRY</span></span><span>:-</span><span><span>$RETRY</span><span>}</span></span><span>"</span></div></div><div><div><div>42</div></div><div><span>TIMEOUT</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_TIMEOUT</span></span><span>:-</span><span><span>$TIMEOUT</span><span>}</span></span><span>"</span></div></div><div><div><div>43</div></div><div><span>LIMIT</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_LIMIT</span></span><span>:-</span><span><span>$LIMIT</span><span>}</span></span><span>"</span></div></div><div><div><div>44</div></div><div><span>WARM_ASSETS</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_ASSETS</span></span><span>:-</span><span><span>$WARM_ASSETS</span><span>}</span></span><span>"</span></div></div><div><div><div>45</div></div><div><span>LOG_LEVEL</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_LOG_LEVEL</span></span><span>:-</span><span><span>$LOG_LEVEL</span><span>}</span></span><span>"</span></div></div><div><div><div>46</div></div><div><span>DRY_RUN</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_DRY_RUN</span></span><span>:-</span><span><span>$DRY_RUN</span><span>}</span></span><span>"</span></div></div><div><div><div>47</div></div><div><span>VERIFY</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_VERIFY</span></span><span>:-</span><span><span>$VERIFY</span><span>}</span></span><span>"</span></div></div><div><div><div>48</div></div><div><span>VERIFY_WAIT</span><span>=</span><span>"</span><span><span>${</span><span>EO_WARM_VERIFY_WAIT</span></span><span>:-</span><span><span>$VERIFY_WAIT</span><span>}</span></span><span>"</span></div></div><div><div><div>49</div></div><div>
</div></div><div><div><div>50</div></div><div><span>SITE</span><span>=</span><span>"</span><span><span>${</span><span>SITE</span></span><span>%/</span><span>}</span><span>"</span><span>                   </span><span># 去掉末尾斜杠</span></div></div><div><div><div>51</div></div><div><span>SITE_RE</span><span>=</span><span>"$(</span><span>printf</span><span> '%s' "</span><span>$SITE</span><span>" </span><span>|</span><span> </span><span>sed</span><span> 's/\./\\./g')"</span><span>   </span><span># 用于 grep 的正则</span></div></div><div><div><div>52</div></div><div>
</div></div><div><div><div>53</div></div><div><span>TMPDIR</span><span>=</span><span>"$(</span><span>mktemp</span><span> </span><span>-d</span><span>)"</span></div></div><div><div><div>54</div></div><div><span>trap</span><span> </span><span>'rm -rf "$TMPDIR"'</span><span> </span><span>EXIT</span></div></div><div><div><div>55</div></div><div>
</div></div><div><div><div>56</div></div><div><span>PAGES</span><span>=</span><span>"</span><span>$TMPDIR</span><span>/pages.txt"</span></div></div><div><div><div>57</div></div><div><span>ASSETS</span><span>=</span><span>"</span><span>$TMPDIR</span><span>/assets.txt"</span></div></div><div><div><div>58</div></div><div><span>RESULTS</span><span>=</span><span>"</span><span>$TMPDIR</span><span>/results.txt"</span></div></div><div><div><div>59</div></div><div><span>VERIFY_RESULTS</span><span>=</span><span>"</span><span>$TMPDIR</span><span>/verify.txt"</span></div></div><div><div><div>60</div></div><div><span>:</span><span> &gt; </span><span>"</span><span>$PAGES</span><span>"</span><span>; </span><span>:</span><span> &gt; </span><span>"</span><span>$ASSETS</span><span>"</span><span>; </span><span>:</span><span> &gt; </span><span>"</span><span>$RESULTS</span><span>"</span><span>; </span><span>:</span><span> &gt; </span><span>"</span><span>$VERIFY_RESULTS</span><span>"</span></div></div><div><div><div>61</div></div><div>
</div></div><div><div><div>62</div></div><div><span>###################### 日志 ######################</span></div></div><div><div><div>63</div></div><div><span>_level_num</span><span>() {</span></div></div><div><div><div>64</div></div><div><span>  </span><span>case</span><span> </span><span>"</span><span>$1</span><span>"</span><span> </span><span>in</span></div></div><div><div><div>65</div></div><div><span>    </span><span>debug</span><span>) </span><span>echo</span><span> </span><span>0</span><span>;; </span><span>info</span><span>) </span><span>echo</span><span> </span><span>1</span><span>;; </span><span>warn</span><span>) </span><span>echo</span><span> </span><span>2</span><span>;; </span><span>error</span><span>) </span><span>echo</span><span> </span><span>3</span><span>;; *) </span><span>echo</span><span> </span><span>1</span><span>;;</span></div></div><div><div><div>66</div></div><div><span>  </span><span>esac</span></div></div><div><div><div>67</div></div><div><span>}</span></div></div><div><div><div>68</div></div><div><span>LOG_NUM</span><span>=</span><span>"$(</span><span>_level_num</span><span> "</span><span>$LOG_LEVEL</span><span>")"</span></div></div><div><div><div>69</div></div><div><span>log</span><span>() {</span></div></div><div><div><div>70</div></div><div><span>  </span><span>local</span><span> </span><span>lvl</span><span>=</span><span>"</span><span>$1</span><span>"</span><span>; </span><span>shift</span></div></div><div><div><div>71</div></div><div><span><span>  </span></span><span>[ </span><span>"$(</span><span>_level_num</span><span> "</span><span>$lvl</span><span>")"</span><span><span> </span><span>-lt</span><span> </span></span><span>"</span><span>$LOG_NUM</span><span>"</span><span> ] &amp;&amp; </span><span>return</span><span> </span><span>0</span></div></div><div><div><div>72</div></div><div><span>  </span><span>printf</span><span> </span><span>'[%s] [%s] %s\n'</span><span> </span><span>"$(</span><span>date</span><span> '+%F %T')"</span><span> </span><span>"</span><span>$lvl</span><span>"</span><span> </span><span>"</span><span>$*</span><span>"</span><span> &gt;&amp;2</span></div></div><div><div><div>73</div></div><div><span>}</span></div></div><div><div><div>74</div></div><div>
</div></div><div><div><div>75</div></div><div><span>###################### 工具函数 ######################</span></div></div><div><div><div>76</div></div><div><span># 单次请求：输出 "状态码|缓存状态"，重试走指数退避</span></div></div><div><div><div>77</div></div><div><span>req</span><span>() {</span></div></div><div><div><div>78</div></div><div><span>  </span><span>local</span><span> </span><span>url</span><span>=</span><span>"</span><span>$1</span><span>"</span><span> </span><span>out</span><span>=</span><span>"</span><span><span>${2</span><span>:-/</span></span><span>dev</span><span>/</span><span>null</span><span>}</span><span>"</span></div></div><div><div><div>79</div></div><div><span>  </span><span>local</span><span> </span><span>attempt</span><span>=</span><span>0</span><span> </span><span>code</span><span>=</span><span>000</span><span> </span><span>cache</span><span>=</span><span>"UNKNOWN"</span><span> </span><span>wait</span><span>=</span><span>1</span></div></div><div><div><div>80</div></div><div><span>  </span><span>local</span><span> </span><span>hdr</span><span>=</span><span>"</span><span>$TMPDIR</span><span>/hdr.</span><span>$$</span><span>"</span></div></div><div><div><div>81</div></div><div>
</div></div><div><div><div>82</div></div><div><span>  </span><span>while</span><span> </span><span>:</span><span>; </span><span>do</span></div></div><div><div><div>83</div></div><div><span>    </span><span>:</span><span> &gt; </span><span>"</span><span>$hdr</span><span>"</span></div></div><div><div><div>84</div></div><div><span>    </span><span>code</span><span>=</span><span>"$(</span><span>curl</span><span> </span><span>-sS</span><span> </span><span>--compressed</span><span> </span><span>-m</span><span> "</span><span>$TIMEOUT</span><span>" </span><span>-A</span><span> "</span><span>$UA</span><span>" </span><span>\</span></div></div><div><div><div>85</div></div><div><span>              </span><span>-D</span><span> "</span><span>$hdr</span><span>" </span><span>\</span></div></div><div><div><div>86</div></div><div><span>              </span><span>-o</span><span> "</span><span>$out</span><span>" </span><span>-w</span><span> '%{http_code}' "</span><span>$url</span><span>" </span><span>2&gt;</span><span>/dev/null </span><span>||</span><span> </span><span>echo</span><span> </span><span>000</span><span>)"</span></div></div><div><div><div>87</div></div><div>
</div></div><div><div><div>88</div></div><div><span>    </span><span># 提取 EO-Cache-Status（不区分大小写，容忍空格）</span></div></div><div><div><div>89</div></div><div><span>    </span><span>cache</span><span>=</span><span>"$(</span><span>grep</span><span> </span><span>-i</span><span> '^EO-Cache-Status:' "</span><span>$hdr</span><span>" </span><span>2&gt;</span><span>/dev/null </span><span>\</span></div></div><div><div><div>90</div></div><div><span>              </span><span>|</span><span> </span><span>head</span><span> </span><span>-n1</span><span> </span><span>\</span></div></div><div><div><div>91</div></div><div><span>              </span><span>|</span><span> </span><span>sed</span><span> </span><span>-E</span><span> 's/^[^:]+:[[:space:]]*//; s/\r$//')"</span></div></div><div><div><div>92</div></div><div><span><span>    </span></span><span>[ </span><span>-z</span><span> </span><span>"</span><span>$cache</span><span>"</span><span> ] &amp;&amp; </span><span>cache</span><span>=</span><span>"UNKNOWN"</span></div></div><div><div><div>93</div></div><div>
</div></div><div><div><div>94</div></div><div><span>    </span><span>if</span><span> [ </span><span>"</span><span><span>${</span><span>code</span></span><span>:</span><span>0</span><span>:</span><span><span>1</span><span>}</span></span><span>"</span><span><span> </span><span>=</span><span> </span></span><span>"2"</span><span> ] || [ </span><span>"</span><span><span>${</span><span>code</span></span><span>:</span><span>0</span><span>:</span><span><span>1</span><span>}</span></span><span>"</span><span><span> </span><span>=</span><span> </span></span><span>"3"</span><span> ]; </span><span>then</span></div></div><div><div><div>95</div></div><div><span>      </span><span>echo</span><span> </span><span>"</span><span>$code</span><span>|</span><span>$cache</span><span>"</span><span>; </span><span>return</span><span> </span><span>0</span></div></div><div><div><div>96</div></div><div><span>    </span><span>fi</span></div></div><div><div><div>97</div></div><div><span>    </span><span>attempt</span><span><span>=</span><span>$((</span></span><span>attempt+1</span><span>))</span></div></div><div><div><div>98</div></div><div><span>    </span><span>if</span><span> [ </span><span>"</span><span>$attempt</span><span>"</span><span><span> </span><span>-gt</span><span> </span></span><span>"</span><span>$RETRY</span><span>"</span><span> ]; </span><span>then</span></div></div><div><div><div>99</div></div><div><span>      </span><span>echo</span><span> </span><span>"</span><span>$code</span><span>|</span><span>$cache</span><span>"</span><span>; </span><span>return</span><span> </span><span>1</span></div></div><div><div><div>100</div></div><div><span>    </span><span>fi</span></div></div><div><div><div>101</div></div><div><span>    </span><span>sleep</span><span> </span><span>"</span><span>$wait</span><span>"</span></div></div><div><div><div>102</div></div><div><span>    </span><span>wait</span><span><span>=</span><span>$((</span></span><span>wait</span><span>*2</span><span>))</span></div></div><div><div><div>103</div></div><div><span>  </span><span>done</span></div></div><div><div><div>104</div></div><div><span>}</span></div></div><div><div><div>105</div></div><div>
</div></div><div><div><div>106</div></div><div><span># 仅取缓存状态（验证用，不关心 body）</span></div></div><div><div><div>107</div></div><div><span>req_cache_only</span><span>() {</span></div></div><div><div><div>108</div></div><div><span>  </span><span>local</span><span> </span><span>url</span><span>=</span><span>"</span><span>$1</span><span>"</span></div></div><div><div><div>109</div></div><div><span>  </span><span>local</span><span> </span><span>hdr</span><span>=</span><span>"</span><span>$TMPDIR</span><span>/hdr.</span><span>$$</span><span>"</span></div></div><div><div><div>110</div></div><div><span>  </span><span>:</span><span> &gt; </span><span>"</span><span>$hdr</span><span>"</span></div></div><div><div><div>111</div></div><div><span>  </span><span>curl</span><span> </span><span>-sS</span><span> </span><span>--compressed</span><span> </span><span>-m</span><span> </span><span>"</span><span>$TIMEOUT</span><span>"</span><span> </span><span>-A</span><span> </span><span>"</span><span>$UA</span><span>"</span><span> </span><span>\</span></div></div><div><div><div>112</div></div><div><span>    </span><span>-D</span><span> </span><span>"</span><span>$hdr</span><span>"</span><span> </span><span>-o</span><span> </span><span>/dev/null</span><span> </span><span>"</span><span>$url</span><span>"</span><span> 2&gt;</span><span>/dev/null</span><span> || </span><span>true</span></div></div><div><div><div>113</div></div><div><span>  </span><span>local</span><span> </span><span>cache</span></div></div><div><div><div>114</div></div><div><span>  </span><span>cache</span><span>=</span><span>"$(</span><span>grep</span><span> </span><span>-i</span><span> '^EO-Cache-Status:' "</span><span>$hdr</span><span>" </span><span>2&gt;</span><span>/dev/null </span><span>\</span></div></div><div><div><div>115</div></div><div><span>            </span><span>|</span><span> </span><span>head</span><span> </span><span>-n1</span><span> </span><span>\</span></div></div><div><div><div>116</div></div><div><span>            </span><span>|</span><span> </span><span>sed</span><span> </span><span>-E</span><span> 's/^[^:]+:[[:space:]]*//; s/\r$//')"</span></div></div><div><div><div>117</div></div><div><span><span>  </span></span><span>[ </span><span>-z</span><span> </span><span>"</span><span>$cache</span><span>"</span><span> ] &amp;&amp; </span><span>cache</span><span>=</span><span>"UNKNOWN"</span></div></div><div><div><div>118</div></div><div><span>  </span><span>echo</span><span> </span><span>"</span><span>$cache</span><span>"</span></div></div><div><div><div>119</div></div><div><span>}</span></div></div><div><div><div>120</div></div><div>
</div></div><div><div><div>121</div></div><div><span># 从 HTML 里提取本站静态资源</span></div></div><div><div><div>122</div></div><div><span>extract_assets</span><span>() {</span></div></div><div><div><div>123</div></div><div><span>  </span><span>local</span><span> </span><span>html</span><span>=</span><span>"</span><span>$1</span><span>"</span></div></div><div><div><div>124</div></div><div><span><span>  </span></span><span>{</span></div></div><div><div><div>125</div></div><div><span>    </span><span>grep</span><span> </span><span>-oE</span><span> </span><span>'(href|src)="[^"]+"'</span><span> </span><span>"</span><span>$html</span><span>"</span><span> | </span><span>sed</span><span> </span><span>-E</span><span> </span><span>'s/^(href|src)="//; s/"$//'</span></div></div><div><div><div>126</div></div><div><span>    </span><span>grep</span><span> </span><span>-oE</span><span> </span><span>'srcset="[^"]+"'</span><span>     </span><span>"</span><span>$html</span><span>"</span><span> | </span><span>sed</span><span> </span><span>-E</span><span> </span><span>'s/^srcset="//; s/"$//'</span><span> </span><span>\</span></div></div><div><div><div>127</div></div><div><span><span>      </span></span><span>| </span><span>tr</span><span> </span><span>','</span><span> </span><span>'\n'</span><span> | </span><span>awk</span><span> </span><span>'{print $1}'</span></div></div><div><div><div>128</div></div><div><span><span>  </span></span><span>} | </span><span>while</span><span> </span><span>read</span><span> </span><span>-r</span><span> </span><span>u</span><span>; </span><span>do</span></div></div><div><div><div>129</div></div><div><span><span>        </span></span><span>[ </span><span>-z</span><span> </span><span>"</span><span>$u</span><span>"</span><span> ] &amp;&amp; </span><span>continue</span></div></div><div><div><div>130</div></div><div><span>        </span><span>u</span><span>=</span><span>"</span><span><span>${</span><span>u</span></span><span>%%</span><span>\#</span><span>*</span><span>}</span><span>"</span><span>; </span><span>u</span><span>=</span><span>"</span><span><span>${</span><span>u</span></span><span>%%</span><span>\?</span><span>*</span><span>}</span><span>"</span></div></div><div><div><div>131</div></div><div><span>        </span><span>case</span><span> </span><span>"</span><span>$u</span><span>"</span><span> </span><span>in</span></div></div><div><div><div>132</div></div><div><span>          </span><span>//</span><span>*</span><span>)  </span><span>u</span><span>=</span><span>"https:</span><span>$u</span><span>"</span><span> ;;</span></div></div><div><div><div>133</div></div><div><span>          </span><span>/</span><span>*</span><span>)   </span><span>u</span><span>=</span><span>"</span><span>$SITE$u</span><span>"</span><span> ;;</span></div></div><div><div><div>134</div></div><div><span>          </span><span>http</span><span>*</span><span>) ;;</span></div></div><div><div><div>135</div></div><div><span><span>          </span></span><span>*)    </span><span>continue</span><span> ;;</span></div></div><div><div><div>136</div></div><div><span>        </span><span>esac</span></div></div><div><div><div>137</div></div><div><span>        </span><span>case</span><span> </span><span>"</span><span>$u</span><span>"</span><span> </span><span>in</span><span> </span><span>"</span><span>$SITE</span><span>"</span><span>*</span><span>) ;; *) </span><span>continue</span><span> ;; </span><span>esac</span></div></div><div><div><div>138</div></div><div><span>        </span><span>case</span><span> </span><span>"</span><span>$u</span><span>"</span><span> </span><span>in</span></div></div><div><div><div>139</div></div><div><span>          </span><span>*</span><span>.css</span><span>|*</span><span>.js</span><span>|*</span><span>.png</span><span>|*</span><span>.jpg</span><span>|*</span><span>.jpeg</span><span>|*</span><span>.webp</span><span>|*</span><span>.gif</span><span>|*</span><span>.svg</span><span>|*</span><span>.ico</span><span>|*</span><span>.woff</span><span>|*</span><span>.woff2</span><span>|*</span><span>.ttf</span><span>|*</span><span>.otf</span><span>|*</span><span>.avif</span><span>)</span></div></div><div><div><div>140</div></div><div><span>            </span><span>echo</span><span> </span><span>"</span><span>$u</span><span>"</span><span> ;;</span></div></div><div><div><div>141</div></div><div><span>        </span><span>esac</span></div></div><div><div><div>142</div></div><div><span>      </span><span>done</span></div></div><div><div><div>143</div></div><div><span>}</span></div></div><div><div><div>144</div></div><div>
</div></div><div><div><div>145</div></div><div><span>###################### 1. 采集页面 URL ######################</span></div></div><div><div><div>146</div></div><div><span>log</span><span> </span><span>info</span><span> </span><span>"开始预热：</span><span>$SITE</span><span>"</span></div></div><div><div><div>147</div></div><div>
</div></div><div><div><div>148</div></div><div><span># 1.1 本地列表优先</span></div></div><div><div><div>149</div></div><div><span>if</span><span><span> [ </span><span>-n</span><span> </span></span><span>"</span><span>$LOCAL_LIST</span><span>"</span><span><span> ] &amp;&amp; [ </span><span>-f</span><span> </span></span><span>"</span><span>$LOCAL_LIST</span><span>"</span><span> ]; </span><span>then</span></div></div><div><div><div>150</div></div><div><span>  </span><span>log</span><span> </span><span>info</span><span> </span><span>"从本地列表读取：</span><span>$LOCAL_LIST</span><span>"</span></div></div><div><div><div>151</div></div><div><span>  </span><span>grep</span><span> </span><span>-E</span><span> </span><span>'^https?://'</span><span> </span><span>"</span><span>$LOCAL_LIST</span><span>"</span><span> &gt;&gt; </span><span>"</span><span>$PAGES</span><span>"</span></div></div><div><div><div>152</div></div><div><span>fi</span></div></div><div><div><div>153</div></div><div>
</div></div><div><div><div>154</div></div><div><span># 1.2 sitemap（递归），失败时兜底 robots.txt</span></div></div><div><div><div>155</div></div><div><span>if</span><span><span> [ </span><span>!</span><span> </span><span>-s</span><span> </span></span><span>"</span><span>$PAGES</span><span>"</span><span> ]; </span><span>then</span></div></div><div><div><div>156</div></div><div><span>  </span><span>STACK</span><span><span>=</span><span>(</span></span><span>"</span><span><span>${</span><span>SITE</span><span>}${</span><span>SITEMAP</span><span>}</span></span><span>"</span><span>)</span></div></div><div><div><div>157</div></div><div><span>  </span><span>DEPTH_MAP</span><span><span>=</span><span>(</span></span><span>"0"</span><span>)</span></div></div><div><div><div>158</div></div><div><span>  </span><span>while</span><span> [ </span><span>"</span><span>${</span><span>#</span><span>STACK</span><span>[</span><span>@</span><span>]</span><span>}</span><span>"</span><span><span> </span><span>-gt</span><span> </span></span><span>0</span><span> ]; </span><span>do</span></div></div><div><div><div>159</div></div><div><span>    </span><span>url</span><span>=</span><span>"</span><span><span>${</span><span>STACK</span></span><span>[0]</span><span>}</span><span>"</span><span>; </span><span>depth</span><span>=</span><span>"</span><span><span>${</span><span>DEPTH_MAP</span></span><span>[0]</span><span>}</span><span>"</span></div></div><div><div><div>160</div></div><div><span>    </span><span>STACK</span><span><span>=</span><span>(</span></span><span>"</span><span><span>${</span><span>STACK</span></span><span>[</span><span>@</span><span>]</span><span>:</span><span><span>1</span><span>}</span></span><span>"</span><span>); </span><span>DEPTH_MAP</span><span><span>=</span><span>(</span></span><span>"</span><span><span>${</span><span>DEPTH_MAP</span></span><span>[</span><span>@</span><span>]</span><span>:</span><span><span>1</span><span>}</span></span><span>"</span><span>)</span></div></div><div><div><div>161</div></div><div><span><span>    </span></span><span>[ </span><span>"</span><span>$depth</span><span>"</span><span><span> </span><span>-gt</span><span> </span></span><span>3</span><span> ] &amp;&amp; { </span><span>log</span><span> </span><span>debug</span><span> </span><span>"sitemap 递归过深，跳过：</span><span>$url</span><span>"</span><span>; </span><span>continue</span><span>; }</span></div></div><div><div><div>162</div></div><div>
</div></div><div><div><div>163</div></div><div><span>    </span><span>body</span><span>=</span><span>"$(</span><span>curl</span><span> </span><span>-sS</span><span> </span><span>--compressed</span><span> </span><span>-m</span><span> "</span><span>$TIMEOUT</span><span>" </span><span>-A</span><span> "</span><span>$UA</span><span>" "</span><span>$url</span><span>" </span><span>2&gt;</span><span>/dev/null </span><span>||</span><span> </span><span>true</span><span>)"</span></div></div><div><div><div>164</div></div><div><span>    </span><span>if</span><span><span> [ </span><span>-z</span><span> </span></span><span>"</span><span>$body</span><span>"</span><span> ]; </span><span>then</span></div></div><div><div><div>165</div></div><div><span>      </span><span>log</span><span> </span><span>warn</span><span> </span><span>"无法拉取 sitemap：</span><span>$url</span><span>"</span></div></div><div><div><div>166</div></div><div><span>      </span><span>continue</span></div></div><div><div><div>167</div></div><div><span>    </span><span>fi</span></div></div><div><div><div>168</div></div><div><span>    </span><span>if</span><span> </span><span>printf</span><span> </span><span>'%s'</span><span> </span><span>"</span><span>$body</span><span>"</span><span> | </span><span>grep</span><span> </span><span>-q</span><span> </span><span>"&lt;sitemapindex"</span><span>; </span><span>then</span></div></div><div><div><div>169</div></div><div><span>      </span><span>while</span><span> </span><span>IFS</span><span><span>=</span><span> </span></span><span>read</span><span> </span><span>-r</span><span> </span><span>sub</span><span>; </span><span>do</span></div></div><div><div><div>170</div></div><div><span><span>        </span></span><span>[ </span><span>-z</span><span> </span><span>"</span><span>$sub</span><span>"</span><span> ] &amp;&amp; </span><span>continue</span></div></div><div><div><div>171</div></div><div><span>        </span><span>STACK</span><span><span>+=</span><span>(</span></span><span>"</span><span>$sub</span><span>"</span><span>); </span><span>DEPTH_MAP</span><span><span>+=</span><span>(</span></span><span>"$((</span><span>depth+1</span><span>))"</span><span>)</span></div></div><div><div><div>172</div></div><div><span>      </span><span>done</span><span> &lt; </span><span>&lt;(</span><span>printf</span><span> '%s' "</span><span>$body</span><span>" </span><span>|</span><span> </span><span>grep</span><span> </span><span>-oE</span><span> '&lt;loc&gt;[^&lt;]+&lt;/loc&gt;' </span><span>|</span><span> </span><span>sed</span><span> </span><span>-E</span><span> 's#&lt;/?loc&gt;##g')</span></div></div><div><div><div>173</div></div><div><span>    </span><span>else</span></div></div><div><div><div>174</div></div><div><span>      </span><span>printf</span><span> </span><span>'%s'</span><span> </span><span>"</span><span>$body</span><span>"</span><span> | </span><span>grep</span><span> </span><span>-oE</span><span> </span><span>'&lt;loc&gt;[^&lt;]+&lt;/loc&gt;'</span><span> | </span><span>sed</span><span> </span><span>-E</span><span> </span><span>'s#&lt;/?loc&gt;##g'</span><span> &gt;&gt; </span><span>"</span><span>$PAGES</span><span>"</span></div></div><div><div><div>175</div></div><div><span>    </span><span>fi</span></div></div><div><div><div>176</div></div><div><span>  </span><span>done</span></div></div><div><div><div>177</div></div><div><span>fi</span></div></div><div><div><div>178</div></div><div>
</div></div><div><div><div>179</div></div><div><span># 1.3 robots.txt 兜底</span></div></div><div><div><div>180</div></div><div><span>if</span><span><span> [ </span><span>!</span><span> </span><span>-s</span><span> </span></span><span>"</span><span>$PAGES</span><span>"</span><span> ]; </span><span>then</span></div></div><div><div><div>181</div></div><div><span>  </span><span>log</span><span> </span><span>warn</span><span> </span><span>"sitemap 无结果，尝试 robots.txt 兜底"</span></div></div><div><div><div>182</div></div><div><span>  </span><span>body</span><span>=</span><span>"$(</span><span>curl</span><span> </span><span>-sS</span><span> </span><span>--compressed</span><span> </span><span>-m</span><span> "</span><span>$TIMEOUT</span><span>" </span><span>-A</span><span> "</span><span>$UA</span><span>" "</span><span><span>${</span><span>SITE</span><span>}${</span><span>ROBOTS</span><span>}</span></span><span>" </span><span>2&gt;</span><span>/dev/null </span><span>||</span><span> </span><span>true</span><span>)"</span></div></div><div><div><div>183</div></div><div><span>  </span><span>if</span><span><span> [ </span><span>-n</span><span> </span></span><span>"</span><span>$body</span><span>"</span><span> ]; </span><span>then</span></div></div><div><div><div>184</div></div><div><span>    </span><span>while</span><span> </span><span>IFS</span><span><span>=</span><span> </span></span><span>read</span><span> </span><span>-r</span><span> </span><span>sub</span><span>; </span><span>do</span></div></div><div><div><div>185</div></div><div><span><span>      </span></span><span>[ </span><span>-z</span><span> </span><span>"</span><span>$sub</span><span>"</span><span> ] &amp;&amp; </span><span>continue</span></div></div><div><div><div>186</div></div><div><span>      </span><span>sub_body</span><span>=</span><span>"$(</span><span>curl</span><span> </span><span>-sS</span><span> </span><span>--compressed</span><span> </span><span>-m</span><span> "</span><span>$TIMEOUT</span><span>" </span><span>-A</span><span> "</span><span>$UA</span><span>" "</span><span>$sub</span><span>" </span><span>2&gt;</span><span>/dev/null </span><span>||</span><span> </span><span>true</span><span>)"</span></div></div><div><div><div>187</div></div><div><span>      </span><span>printf</span><span> </span><span>'%s'</span><span> </span><span>"</span><span>$sub_body</span><span>"</span><span> | </span><span>grep</span><span> </span><span>-oE</span><span> </span><span>'&lt;loc&gt;[^&lt;]+&lt;/loc&gt;'</span><span> | </span><span>sed</span><span> </span><span>-E</span><span> </span><span>'s#&lt;/?loc&gt;##g'</span><span> &gt;&gt; </span><span>"</span><span>$PAGES</span><span>"</span></div></div><div><div><div>188</div></div><div><span>    </span><span>done</span><span> &lt; </span><span>&lt;(</span><span>printf</span><span> '%s' "</span><span>$body</span><span>" </span><span>|</span><span> </span><span>awk</span><span> </span><span>-F</span><span>': *' 'tolower($1)=="sitemap" {print $2}')</span></div></div><div><div><div>189</div></div><div><span>  </span><span>fi</span></div></div><div><div><div>190</div></div><div><span>fi</span></div></div><div><div><div>191</div></div><div>
</div></div><div><div><div>192</div></div><div><span># 去重 + 只保留本站</span></div></div><div><div><div>193</div></div><div><span>if</span><span><span> [ </span><span>-s</span><span> </span></span><span>"</span><span>$PAGES</span><span>"</span><span> ]; </span><span>then</span></div></div><div><div><div>194</div></div><div><span>  </span><span>grep</span><span> </span><span>-E</span><span> </span><span>"^</span><span><span>${</span><span>SITE_RE</span><span>}</span></span><span>"</span><span> </span><span>"</span><span>$PAGES</span><span>"</span><span> | </span><span>sort</span><span> </span><span>-u</span><span> &gt; </span><span>"</span><span>$PAGES</span><span>.clean"</span><span> || </span><span>true</span></div></div><div><div><div>195</div></div><div><span>  </span><span>mv</span><span> </span><span>"</span><span>$PAGES</span><span>.clean"</span><span> </span><span>"</span><span>$PAGES</span><span>"</span></div></div><div><div><div>196</div></div><div><span>fi</span></div></div><div><div><div>197</div></div><div>
</div></div><div><div><div>198</div></div><div><span>TOTAL</span><span><span>=</span><span>$(</span></span><span>wc</span><span> </span><span>-l</span><span> </span><span>&lt;</span><span> </span><span>"</span><span>$PAGES</span><span>"</span><span> </span><span>2&gt;/dev/null</span><span> || </span><span>echo</span><span> </span><span>0</span><span>)</span></div></div><div><div><div>199</div></div><div><span>if</span><span> [ </span><span>"</span><span>$TOTAL</span><span>"</span><span><span> </span><span>-eq</span><span> </span></span><span>0</span><span> ]; </span><span>then</span></div></div><div><div><div>200</div></div><div><span>  </span><span>log</span><span> </span><span>error</span><span> </span><span>"未采集到任何页面 URL，退出"</span></div></div><div><div><div>201</div></div><div><span>  </span><span>exit</span><span> </span><span>1</span></div></div><div><div><div>202</div></div><div><span>fi</span></div></div><div><div><div>203</div></div><div><span>if</span><span> [ </span><span>"</span><span>$LIMIT</span><span>"</span><span><span> </span><span>-gt</span><span> </span></span><span>0</span><span> ] &amp;&amp; [ </span><span>"</span><span>$TOTAL</span><span>"</span><span><span> </span><span>-gt</span><span> </span></span><span>"</span><span>$LIMIT</span><span>"</span><span> ]; </span><span>then</span></div></div><div><div><div>204</div></div><div><span>  </span><span>head</span><span> </span><span>-n</span><span> </span><span>"</span><span>$LIMIT</span><span>"</span><span> </span><span>"</span><span>$PAGES</span><span>"</span><span> &gt; </span><span>"</span><span>$PAGES</span><span>.lim"</span><span> &amp;&amp; </span><span>mv</span><span> </span><span>"</span><span>$PAGES</span><span>.lim"</span><span> </span><span>"</span><span>$PAGES</span><span>"</span></div></div><div><div><div>205</div></div><div><span>fi</span></div></div><div><div><div>206</div></div><div><span>COUNT</span><span><span>=</span><span>$(</span></span><span>wc</span><span> </span><span>-l</span><span> &lt; </span><span>"</span><span>$PAGES</span><span>"</span><span>)</span></div></div><div><div><div>207</div></div><div><span>log</span><span> </span><span>info</span><span> </span><span>"采集完成：sitemap/robots 共 </span><span>$TOTAL</span><span> 条，本次预热 </span><span>$COUNT</span><span> 条（并发 </span><span>$CONCURRENCY</span><span>，重试 </span><span>$RETRY</span><span>）"</span></div></div><div><div><div>208</div></div><div>
</div></div><div><div><div>209</div></div><div><span>if</span><span> [ </span><span>"</span><span>$DRY_RUN</span><span>"</span><span><span> </span><span>=</span><span> </span></span><span>"1"</span><span> ]; </span><span>then</span></div></div><div><div><div>210</div></div><div><span>  </span><span>log</span><span> </span><span>info</span><span> </span><span>"DRY-RUN 模式，仅输出 URL："</span></div></div><div><div><div>211</div></div><div><span>  </span><span>cat</span><span> </span><span>"</span><span>$PAGES</span><span>"</span></div></div><div><div><div>212</div></div><div><span>  </span><span>exit</span><span> </span><span>0</span></div></div><div><div><div>213</div></div><div><span>fi</span></div></div><div><div><div>214</div></div><div>
</div></div><div><div><div>215</div></div><div><span>###################### 2. 预热页面（并发） ######################</span></div></div><div><div><div>216</div></div><div><span>warm_page</span><span>() {</span></div></div><div><div><div>217</div></div><div><span>  </span><span>local</span><span> </span><span>page</span><span>=</span><span>"</span><span>$1</span><span>"</span></div></div><div><div><div>218</div></div><div><span>  </span><span>local</span><span> </span><span>body</span><span>=</span><span>"</span><span>$TMPDIR</span><span>/page.</span><span>$$</span><span>.html"</span></div></div><div><div><div>219</div></div><div><span>  </span><span>local</span><span> </span><span>result</span><span> </span><span>code</span><span> </span><span>cache</span></div></div><div><div><div>220</div></div><div><span>  </span><span>result</span><span>=</span><span>"$(</span><span>req</span><span> "</span><span>$page</span><span>" "</span><span>$body</span><span>")"</span><span> || </span><span>true</span></div></div><div><div><div>221</div></div><div><span>  </span><span>code</span><span>=</span><span>"</span><span><span>${</span><span>result</span></span><span>%%</span><span>|</span><span>*</span><span>}</span><span>"</span></div></div><div><div><div>222</div></div><div><span>  </span><span>cache</span><span>=</span><span>"</span><span><span>${</span><span>result</span></span><span>##*</span><span>|</span><span>}</span><span>"</span></div></div><div><div><div>223</div></div><div><span>  </span><span>printf</span><span> </span><span>'PAGE\t%s\t%s\t%s\n'</span><span> </span><span>"</span><span>$code</span><span>"</span><span> </span><span>"</span><span>$cache</span><span>"</span><span> </span><span>"</span><span>$page</span><span>"</span><span> &gt;&gt; </span><span>"</span><span>$RESULTS</span><span>"</span></div></div><div><div><div>224</div></div><div><span>  </span><span>log</span><span> </span><span>info</span><span> </span><span>"PAGE </span><span>$code</span><span> [</span><span>$cache</span><span>]  </span><span>$page</span><span>"</span></div></div><div><div><div>225</div></div><div>
</div></div><div><div><div>226</div></div><div><span>  </span><span>if</span><span> [ </span><span>"</span><span>$WARM_ASSETS</span><span>"</span><span><span> </span><span>=</span><span> </span></span><span>"1"</span><span> ] &amp;&amp; [ </span><span>"</span><span><span>${</span><span>code</span></span><span>:</span><span>0</span><span>:</span><span><span>1</span><span>}</span></span><span>"</span><span><span> </span><span>=</span><span> </span></span><span>"2"</span><span> ]; </span><span>then</span></div></div><div><div><div>227</div></div><div><span>    </span><span>extract_assets</span><span> </span><span>"</span><span>$body</span><span>"</span><span> &gt;&gt; </span><span>"</span><span>$ASSETS</span><span>"</span></div></div><div><div><div>228</div></div><div><span>  </span><span>fi</span></div></div><div><div><div>229</div></div><div><span>  </span><span>rm</span><span> </span><span>-f</span><span> </span><span>"</span><span>$body</span><span>"</span></div></div><div><div><div>230</div></div><div><span><span>  </span></span><span>[ </span><span>"</span><span>$DELAY</span><span>"</span><span><span> </span><span>-gt</span><span> </span></span><span>0</span><span> ] &amp;&amp; </span><span>sleep</span><span> </span><span>"</span><span>$DELAY</span><span>"</span></div></div><div><div><div>231</div></div><div><span>}</span></div></div><div><div><div>232</div></div><div><span>export</span><span> </span><span>-f</span><span> </span><span>warm_page</span><span> </span><span>req</span><span> </span><span>extract_assets</span><span> </span><span>log</span><span> </span><span>_level_num</span></div></div><div><div><div>233</div></div><div><span>export</span><span> </span><span>UA</span><span> </span><span>TIMEOUT</span><span> </span><span>RETRY</span><span> </span><span>DELAY</span><span> </span><span>RESULTS</span><span> </span><span>ASSETS</span><span> </span><span>TMPDIR</span><span> </span><span>WARM_ASSETS</span><span> </span><span>SITE</span><span> </span><span>LOG_NUM</span></div></div><div><div><div>234</div></div><div>
</div></div><div><div><div>235</div></div><div><span>log</span><span> </span><span>info</span><span> </span><span>"开始预热页面…"</span></div></div><div><div><div>236</div></div><div><span>if</span><span> [ </span><span>"</span><span>$CONCURRENCY</span><span>"</span><span><span> </span><span>-le</span><span> </span></span><span>1</span><span> ]; </span><span>then</span></div></div><div><div><div>237</div></div><div><span>  </span><span>while</span><span> </span><span>IFS</span><span><span>=</span><span> </span></span><span>read</span><span> </span><span>-r</span><span> </span><span>p</span><span>; </span><span>do</span><span><span> [ </span><span>-n</span><span> </span></span><span>"</span><span>$p</span><span>"</span><span> ] &amp;&amp; </span><span>warm_page</span><span> </span><span>"</span><span>$p</span><span>"</span><span>; </span><span>done</span><span> &lt; </span><span>"</span><span>$PAGES</span><span>"</span></div></div><div><div><div>238</div></div><div><span>else</span></div></div><div><div><div>239</div></div><div><span>  </span><span># shellcheck disable=SC2016</span></div></div><div><div><div>240</div></div><div><span>  </span><span>xargs</span><span> </span><span>-a</span><span> </span><span>"</span><span>$PAGES</span><span>"</span><span> </span><span>-I</span><span>{} -P "$CONCURRENCY" bash -c 'warm_page "$@"' _ {}</span></div></div><div><div><div>241</div></div><div><span>fi</span></div></div><div><div><div>242</div></div><div>
</div></div><div><div><div>243</div></div><div><span>###################### 3. 预热静态资源（去重 + 并发） ######################</span></div></div><div><div><div>244</div></div><div><span>if</span><span> [ </span><span>"</span><span>$WARM_ASSETS</span><span>"</span><span><span> </span><span>=</span><span> </span></span><span>"1"</span><span><span> ] &amp;&amp; [ </span><span>-s</span><span> </span></span><span>"</span><span>$ASSETS</span><span>"</span><span> ]; </span><span>then</span></div></div><div><div><div>245</div></div><div><span>  </span><span>sort</span><span> </span><span>-u</span><span> </span><span>"</span><span>$ASSETS</span><span>"</span><span> &gt; </span><span>"</span><span>$ASSETS</span><span>.clean"</span><span> &amp;&amp; </span><span>mv</span><span> </span><span>"</span><span>$ASSETS</span><span>.clean"</span><span> </span><span>"</span><span>$ASSETS</span><span>"</span></div></div><div><div><div>246</div></div><div><span>  </span><span>ASSET_COUNT</span><span><span>=</span><span>$(</span></span><span>wc</span><span> </span><span>-l</span><span> &lt; </span><span>"</span><span>$ASSETS</span><span>"</span><span>)</span></div></div><div><div><div>247</div></div><div><span>  </span><span>log</span><span> </span><span>info</span><span> </span><span>"开始预热 </span><span>$ASSET_COUNT</span><span> 个静态资源…"</span></div></div><div><div><div>248</div></div><div>
</div></div><div><div><div>249</div></div><div><span>  </span><span>warm_asset</span><span>() {</span></div></div><div><div><div>250</div></div><div><span>    </span><span>local</span><span> </span><span>a</span><span>=</span><span>"</span><span>$1</span><span>"</span><span> </span><span>result</span><span> </span><span>code</span><span> </span><span>cache</span></div></div><div><div><div>251</div></div><div><span>    </span><span>result</span><span>=</span><span>"$(</span><span>req</span><span> "</span><span>$a</span><span>")"</span><span> || </span><span>true</span></div></div><div><div><div>252</div></div><div><span>    </span><span>code</span><span>=</span><span>"</span><span><span>${</span><span>result</span></span><span>%%</span><span>|</span><span>*</span><span>}</span><span>"</span></div></div><div><div><div>253</div></div><div><span>    </span><span>cache</span><span>=</span><span>"</span><span><span>${</span><span>result</span></span><span>##*</span><span>|</span><span>}</span><span>"</span></div></div><div><div><div>254</div></div><div><span>    </span><span>printf</span><span> </span><span>'ASSET\t%s\t%s\t%s\n'</span><span> </span><span>"</span><span>$code</span><span>"</span><span> </span><span>"</span><span>$cache</span><span>"</span><span> </span><span>"</span><span>$a</span><span>"</span><span> &gt;&gt; </span><span>"</span><span>$RESULTS</span><span>"</span></div></div><div><div><div>255</div></div><div><span>    </span><span>log</span><span> </span><span>info</span><span> </span><span>"ASSET </span><span>$code</span><span> [</span><span>$cache</span><span>]  </span><span>$a</span><span>"</span></div></div><div><div><div>256</div></div><div><span><span>    </span></span><span>[ </span><span>"</span><span>$DELAY</span><span>"</span><span><span> </span><span>-gt</span><span> </span></span><span>0</span><span> ] &amp;&amp; </span><span>sleep</span><span> </span><span>"</span><span>$DELAY</span><span>"</span></div></div><div><div><div>257</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>258</div></div><div><span>  </span><span>export</span><span> </span><span>-f</span><span> </span><span>warm_asset</span></div></div><div><div><div>259</div></div><div><span>  </span><span>if</span><span> [ </span><span>"</span><span>$CONCURRENCY</span><span>"</span><span><span> </span><span>-le</span><span> </span></span><span>1</span><span> ]; </span><span>then</span></div></div><div><div><div>260</div></div><div><span>    </span><span>while</span><span> </span><span>IFS</span><span><span>=</span><span> </span></span><span>read</span><span> </span><span>-r</span><span> </span><span>a</span><span>; </span><span>do</span><span><span> [ </span><span>-n</span><span> </span></span><span>"</span><span>$a</span><span>"</span><span> ] &amp;&amp; </span><span>warm_asset</span><span> </span><span>"</span><span>$a</span><span>"</span><span>; </span><span>done</span><span> &lt; </span><span>"</span><span>$ASSETS</span><span>"</span></div></div><div><div><div>261</div></div><div><span>  </span><span>else</span></div></div><div><div><div>262</div></div><div><span>    </span><span>xargs</span><span> </span><span>-a</span><span> </span><span>"</span><span>$ASSETS</span><span>"</span><span> </span><span>-I</span><span>{} -P "$CONCURRENCY" bash -c 'warm_asset "$@"' _ {}</span></div></div><div><div><div>263</div></div><div><span>  </span><span>fi</span></div></div><div><div><div>264</div></div><div><span>fi</span></div></div><div><div><div>265</div></div><div>
</div></div><div><div><div>266</div></div><div><span>###################### 4. 预热结果统计 ######################</span></div></div><div><div><div>267</div></div><div><span>log</span><span> </span><span>info</span><span> </span><span>"预热完成，统计如下："</span></div></div><div><div><div>268</div></div><div><span>if</span><span><span> [ </span><span>-s</span><span> </span></span><span>"</span><span>$RESULTS</span><span>"</span><span> ]; </span><span>then</span></div></div><div><div><div>269</div></div><div><span>  </span><span>awk</span><span> </span><span>-F</span><span>'\t'</span><span> </span><span>'</span></div></div><div><div><div>270</div></div><div><span><span>    </span></span><span>{ total++; cls=substr($2,1,1); cache=$3</span></div></div><div><div><div>271</div></div><div><span><span>      </span></span><span>if (cls=="2") ok++; else if (cls=="3") redir++; else if (cls=="4") c4++; else if (cls=="5") c5++; else fail++</span></div></div><div><div><div>272</div></div><div><span><span>      </span></span><span>cstat[cache]++</span></div></div><div><div><div>273</div></div><div><span><span>      </span></span><span>key=$1" "$2" ["$3"]"; cnt[key]++</span></div></div><div><div><div>274</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>275</div></div><div><span><span>    </span></span><span>END {</span></div></div><div><div><div>276</div></div><div><span><span>      </span></span><span>printf "  总计: %d\n", total</span></div></div><div><div><div>277</div></div><div><span><span>      </span></span><span>printf "  成功(2xx): %d  重定向(3xx): %d  客户端错(4xx): %d  服务端错(5xx): %d  失败: %d\n", ok, redir, c4, c5, fail</span></div></div><div><div><div>278</div></div><div><span><span>      </span></span><span>print  "  --- 缓存状态分布（首次预热，MISS 属正常）---"</span></div></div><div><div><div>279</div></div><div><span><span>      </span></span><span>for (c in cstat) printf "  %-12s %d\n", c, cstat[c]</span></div></div><div><div><div>280</div></div><div><span><span>      </span></span><span>print  "  --- PAGE/ASSET 明细 ---"</span></div></div><div><div><div>281</div></div><div><span><span>      </span></span><span>for (k in cnt) printf "  %-32s %d\n", k, cnt[k]</span></div></div><div><div><div>282</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>283</div></div><div><span><span>  </span></span><span>'</span><span> </span><span>"</span><span>$RESULTS</span><span>"</span><span> | </span><span>sort</span></div></div><div><div><div>284</div></div><div><span>  </span><span>echo</span><span> </span><span>"  --- 非 2xx 明细 ---"</span><span> &gt;&amp;2</span></div></div><div><div><div>285</div></div><div><span>  </span><span>awk</span><span> </span><span>-F</span><span>'\t'</span><span> </span><span>'$2 !~ /^2/ {print "  "$2" ["$3"] "$4}'</span><span> </span><span>"</span><span>$RESULTS</span><span>"</span><span> &gt;&amp;2 || </span><span>true</span></div></div><div><div><div>286</div></div><div><span>else</span></div></div><div><div><div>287</div></div><div><span>  </span><span>log</span><span> </span><span>warn</span><span> </span><span>"无请求记录"</span></div></div><div><div><div>288</div></div><div><span>fi</span></div></div><div><div><div>289</div></div><div>
</div></div><div><div><div>290</div></div><div><span>###################### 5. 二次验证：缓存命中率 ######################</span></div></div><div><div><div>291</div></div><div><span>if</span><span> [ </span><span>"</span><span>$VERIFY</span><span>"</span><span><span> </span><span>=</span><span> </span></span><span>"1"</span><span> ]; </span><span>then</span></div></div><div><div><div>292</div></div><div><span>  </span><span>log</span><span> </span><span>info</span><span> </span><span>"等待 </span><span><span>${</span><span>VERIFY_WAIT</span><span>}</span></span><span>s 让边缘缓存生效，然后二次验证…"</span></div></div><div><div><div>293</div></div><div><span>  </span><span>sleep</span><span> </span><span>"</span><span>$VERIFY_WAIT</span><span>"</span></div></div><div><div><div>294</div></div><div>
</div></div><div><div><div>295</div></div><div><span>  </span><span># 汇总所有已请求的 URL（页面 + 资源）</span></div></div><div><div><div>296</div></div><div><span>  </span><span>ALL_URLS</span><span>=</span><span>"</span><span>$TMPDIR</span><span>/all_urls.txt"</span></div></div><div><div><div>297</div></div><div><span>  </span><span>awk</span><span> </span><span>-F</span><span>'\t'</span><span> </span><span>'{print $4}'</span><span> </span><span>"</span><span>$RESULTS</span><span>"</span><span> | </span><span>sort</span><span> </span><span>-u</span><span> &gt; </span><span>"</span><span>$ALL_URLS</span><span>"</span></div></div><div><div><div>298</div></div><div><span>  </span><span>V_TOTAL</span><span><span>=</span><span>$(</span></span><span>wc</span><span> </span><span>-l</span><span> &lt; </span><span>"</span><span>$ALL_URLS</span><span>"</span><span>)</span></div></div><div><div><div>299</div></div><div><span>  </span><span>log</span><span> </span><span>info</span><span> </span><span>"开始验证 </span><span>$V_TOTAL</span><span> 个 URL 的 EO-Cache-Status…"</span></div></div><div><div><div>300</div></div><div>
</div></div><div><div><div>301</div></div><div><span>  </span><span>verify_one</span><span>() {</span></div></div><div><div><div>302</div></div><div><span>    </span><span>local</span><span> </span><span>u</span><span>=</span><span>"</span><span>$1</span><span>"</span><span> </span><span>c</span></div></div><div><div><div>303</div></div><div><span>    </span><span>c</span><span>=</span><span>"$(</span><span>req_cache_only</span><span> "</span><span>$u</span><span>")"</span></div></div><div><div><div>304</div></div><div><span>    </span><span>printf</span><span> </span><span>'%s\t%s\n'</span><span> </span><span>"</span><span>$c</span><span>"</span><span> </span><span>"</span><span>$u</span><span>"</span><span> &gt;&gt; </span><span>"</span><span>$VERIFY_RESULTS</span><span>"</span></div></div><div><div><div>305</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>306</div></div><div><span>  </span><span>export</span><span> </span><span>-f</span><span> </span><span>verify_one</span><span> </span><span>req_cache_only</span></div></div><div><div><div>307</div></div><div><span>  </span><span>export</span><span> </span><span>VERIFY_RESULTS</span></div></div><div><div><div>308</div></div><div>
</div></div><div><div><div>309</div></div><div><span>  </span><span>if</span><span> [ </span><span>"</span><span>$CONCURRENCY</span><span>"</span><span><span> </span><span>-le</span><span> </span></span><span>1</span><span> ]; </span><span>then</span></div></div><div><div><div>310</div></div><div><span>    </span><span>while</span><span> </span><span>IFS</span><span><span>=</span><span> </span></span><span>read</span><span> </span><span>-r</span><span> </span><span>u</span><span>; </span><span>do</span><span><span> [ </span><span>-n</span><span> </span></span><span>"</span><span>$u</span><span>"</span><span> ] &amp;&amp; </span><span>verify_one</span><span> </span><span>"</span><span>$u</span><span>"</span><span>; </span><span>done</span><span> &lt; </span><span>"</span><span>$ALL_URLS</span><span>"</span></div></div><div><div><div>311</div></div><div><span>  </span><span>else</span></div></div><div><div><div>312</div></div><div><span>    </span><span>xargs</span><span> </span><span>-a</span><span> </span><span>"</span><span>$ALL_URLS</span><span>"</span><span> </span><span>-I</span><span>{} -P "$CONCURRENCY" bash -c 'verify_one "$@"' _ {}</span></div></div><div><div><div>313</div></div><div><span>  </span><span>fi</span></div></div><div><div><div>314</div></div><div>
</div></div><div><div><div>315</div></div><div><span>  </span><span>log</span><span> </span><span>info</span><span> </span><span>"验证完成，缓存命中统计："</span></div></div><div><div><div>316</div></div><div><span>  </span><span>if</span><span><span> [ </span><span>-s</span><span> </span></span><span>"</span><span>$VERIFY_RESULTS</span><span>"</span><span> ]; </span><span>then</span></div></div><div><div><div>317</div></div><div><span>    </span><span>awk</span><span> </span><span>-F</span><span>'\t'</span><span> </span><span>'</span></div></div><div><div><div>318</div></div><div><span><span>      </span></span><span>{ total++; cstat[$1]++ }</span></div></div><div><div><div>319</div></div><div><span><span>      </span></span><span>END {</span></div></div><div><div><div>320</div></div><div><span><span>        </span></span><span>printf "  验证总数: %d\n", total</span></div></div><div><div><div>321</div></div><div><span><span>        </span></span><span>for (c in cstat) {</span></div></div><div><div><div>322</div></div><div><span><span>          </span></span><span>pct = (total&gt;0) ? (cstat[c]*100.0/total) : 0</span></div></div><div><div><div>323</div></div><div><span><span>          </span></span><span>printf "  %-12s %d (%.1f%%)\n", c, cstat[c], pct</span></div></div><div><div><div>324</div></div><div><span><span>        </span></span><span>}</span></div></div><div><div><div>325</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>326</div></div><div><span><span>    </span></span><span>'</span><span> </span><span>"</span><span>$VERIFY_RESULTS</span><span>"</span><span> | </span><span>sort</span></div></div><div><div><div>327</div></div><div>
</div></div><div><div><div>328</div></div><div><span>    </span><span>echo</span><span> </span><span>"  --- 非 HIT 明细 ---"</span><span> &gt;&amp;2</span></div></div><div><div><div>329</div></div><div><span>    </span><span>awk</span><span> </span><span>-F</span><span>'\t'</span><span> </span><span>'$1 != "HIT" {print "  "$1"\t"$2}'</span><span> </span><span>"</span><span>$VERIFY_RESULTS</span><span>"</span><span> &gt;&amp;2 || </span><span>true</span></div></div><div><div><div>330</div></div><div><span>  </span><span>else</span></div></div><div><div><div>331</div></div><div><span>    </span><span>log</span><span> </span><span>warn</span><span> </span><span>"无验证记录"</span></div></div><div><div><div>332</div></div><div><span>  </span><span>fi</span></div></div><div><div><div>333</div></div><div><span>fi</span></div></div><div><div><div>334</div></div><div>
</div></div><div><div><div>335</div></div><div><span>log</span><span> </span><span>info</span><span> </span><span>"全部完成 ✅"</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>现在它每天自动跑两次，偶尔手动执行一次看命中率。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/19/</id>
      <title type="text">在 WSL 里折腾 1Panel：装环境、搬磁盘、后台保活</title>
      <published>2026-10-09T16:45:32.000Z</published>
      <updated>2026-10-09T16:45:32.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/19/"/>
      <summary type="text">在 WSL2 里装 1Panel 踩的坑：Ubuntu 24.04 出厂已开 systemd 别重复写、本地代理会卡死 apt、vhdx 不能软链得 export/import 物理搬家，以及用计划任务加 VBS 让分发关终端也不停。</summary>
      <content type="html"><![CDATA[<p>前阵子把 dsh 全套卸了，想重装个干净环境，顺手就想试下 1Panel。这是个开源服务器面板，官网看着顺眼，跑起来离不开 systemd 加 Docker。放真机装最稳，但我这台主力机不想直接动，于是琢磨丢进 WSL2 里玩。</p>
<p>WSL 有没有必要装。我的看法：本地想有个 Linux 环境跑点服务、试个面板，WSL2 是真省事，不用开虚拟机占一整块内存；但要是冲生产去，还是老老实实真机或云服务器。</p>
<section><h2>装 WSL2 和 Ubuntu<a href="#装-wsl2-和-ubuntu"><span>#</span></a></h2><p>管理员跑一句：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>wsl </span><span>--</span><span>install </span><span>-</span><span>d Ubuntu</span><span>-</span></span><span>24.04</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>装完重启，首次进会让你建普通用户。</p></section>
<section><h2>装 1Panel<a href="#装-1panel"><span>#</span></a></h2><p>官方 v2 在线安装：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>bash</span><span> </span><span>-c</span><span> </span><span>"$(</span><span>curl</span><span> </span><span>-sSL</span><span> https://resource.fit2cloud.com/1panel/package/v2/quick_start.sh)"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>我想让它非交互跑完，提前 export 了几个变量：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>PANEL_NON_INTERACTIVE</span><span>=</span><span>true</span></div></div><div><div><div>2</div></div><div><span>export</span><span> </span><span>PANEL_PORT</span><span>=</span><span>xxxx</span></div></div><div><div><div>3</div></div><div><span>export</span><span> </span><span>PANEL_ENTRANCE</span><span>=</span><span>xxxx</span></div></div><div><div><div>4</div></div><div><span>export</span><span> </span><span>PANEL_INSTALL_DOCKER</span><span>=</span><span>y</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>它会顺手把 Docker Engine 也装上，装完 <code>1pctl status</code> 看到 Core / Agent 都是 OK 就算装好了。</p></section>
<section><h2>两个真正的坑<a href="#两个真正的坑"><span>#</span></a></h2><p>Ubuntu 24.04 的镜像出厂 <code>/etc/wsl.conf</code> 里已经写了 <code>systemd=true</code>。我先前照老帖子 <code>echo "[boot]\nsystemd=true" | sudo tee -a /etc/wsl.conf</code>，结果启动直接报 <code>Duplicated config key 'boot.systemd'</code>，接着 <code>Failed to start the systemd user session</code>。把 wsl.conf 整理成干净的一份就好：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[boot]</span></div></div><div><div><div>2</div></div><div><span>systemd=true</span></div></div><div><div><div>3</div></div><div><span>[user]</span></div></div><div><div><div>4</div></div><div><span>default=yufish</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>顺带说一句，那个 <code>Failed to start the systemd user session</code> 多半是 WSL 客户端抢跑的误报。我查过 <code>journalctl -u user@1000.service</code>，实际正常启动，loginctl 里也有 active 会话。看到这行先验证再动手，别被它带着乱改。</p><p>另一个坑是 Windows 本地代理把 apt 卡死。我 Windows 侧挂着本地代理，WSL 启动报了句 <code>localhost 代理配置…NAT 模式不支持</code>，然后 apt / curl 全卡住。处理是把代理环境变量 unset 掉，再把 <code>/etc/apt/apt.conf.d/</code> 下带 proxy 的文件删了。</p></section>
<section><h2>把虚拟磁盘从 C 盘搬到 G 盘<a href="#把虚拟磁盘从-c-盘搬到-g-盘"><span>#</span></a></h2><p>WSL 的虚拟磁盘默认落在 C 盘 <code>AppData\Local\wsl\{guid}\ext4.vhdx</code>，会动态膨胀。我装完 1Panel 加 Docker 直接吃了近 3 个 G，C 盘受不了，挪到 G 盘。</p><p>vhdx 不能 symlink，WSL 是按绝对路径注册的，软链过去启动找不到。正确做法是物理搬家，用 export / import（目标目录得先 <code>mkdir</code> 建好，这两条命令都不会自动建目录）：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span><span>wsl </span><span>--</span><span>export Ubuntu</span><span>-</span></span><span>24.04</span><span><span> G:\wsl\ubuntu</span><span>-</span></span><span>24.04</span><span>\ubuntu.tar</span></div></div><div><div><div>2</div></div><div><span><span>wsl </span><span>--</span><span>unregister Ubuntu</span><span>-</span></span><span>24.04</span></div></div><div><div><div>3</div></div><div><span><span>wsl </span><span>--</span><span>import Ubuntu</span><span>-</span></span><span>24.04</span><span><span> G:\wsl\ubuntu</span><span>-</span></span><span>24.04</span><span><span>\ G:\wsl\ubuntu</span><span>-</span></span><span>24.04</span><span><span>\ubuntu.tar </span><span>--</span><span>version </span></span><span>2</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>import 之后默认用户会退回 root，在 wsl.conf 把 <code>[user] default=yufish</code> 写回去再 <code>wsl --shutdown</code> 重启就回来了。搬完 C 盘腾出两个多 G。</p></section>
<section><h2>关终端就停，怎么保活<a href="#关终端就停怎么保活"><span>#</span></a></h2><p>WSL 分发的生命周期跟用户会话绑一起，关了终端过会儿就 <code>Stopped</code>，1Panel 自然也访问不了。</p><p>我先试了 <code>Start-Process wsl -WindowStyle Hidden</code>，不行——它起的 wsl 是终端子进程，关终端被连带杀，顶多宽限几秒。</p><p>正解是计划任务，独立于会话。但中间又踩了 PowerShell 的参数坑：<code>New-ScheduledTaskAction</code> 的参数是 <code>-Argument</code>（单数、一个字符串），不是 <code>-ArgumentList</code>，写错直接报”找不到与参数名称匹配的参数”；<code>-RunLevel</code> 只有 <code>Limited</code> 和 <code>Highest</code>，没有 <code>Standard</code>，写了就枚举转换失败。</p><p>还有一点，计划任务就算设了 <code>Hidden</code>，AtLogOn 的交互任务照样拦不住 <code>wsl.exe</code> 那个控制台窗口弹出来。</p><p>最终能彻底静默的方案是套一层 VBS。建个 <code>wsl-keepalive.vbs</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Set WshShell = CreateObject("WScript.Shell")</span></div></div><div><div><div>2</div></div><div><span>WshShell.Run "wsl -d Ubuntu-24.04 -e bash -c ""exec sleep infinity""", 0, False</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>Run</code> 第二个参数 <code>0</code> 是 SW_HIDE，窗口彻底不显。计划任务改成执行 <code>wscript.exe</code>、参数是这个 vbs 路径、触发器 AtLogOn。这样下次登录自动拉起、后台常驻、不弹窗，关终端也不受影响。</p></section>
<section><h2>日常怎么管<a href="#日常怎么管"><span>#</span></a></h2><p>看状态 <code>wsl -l -v</code>，正常该是 Running；临时关用 <code>wsl --shutdown</code>；不想自动拉起就 <code>Disable-ScheduledTask -TaskName WSL-KeepAlive</code> 把计划任务禁用，别删那个 vbs，删了保活就没了。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/18/</id>
      <title type="text">博客内容分离到私有仓库，构建时自动同步</title>
      <published>2026-10-08T23:10:00.000Z</published>
      <updated>2026-10-08T23:10:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/18/"/>
      <summary type="text">主题仓库要开源，文章不想公开，于是把 src/content 搬进私有仓库、构建前自动拉回。同步脚本踩了浅克隆和 || true 吞失败的坑；内容仓库推送不触发 EdgeOne 重建，得自己补部署钩子。</summary>
      <content type="html"><![CDATA[<p>博客用的是 Firefly，fork 之后改了不少东西，想着把主题仓库开源出来。但仓库里的 <code>src/content</code> 装的是文章、动态、足迹、项目介绍，这些是要自己留着的。</p>
<p>一开始想的是清历史，用 filter-repo 把 content 从所有提交里抹掉。动手前先摸了下底，发现代价比想象中大：Firefly 上游的第一个提交就带着 content 目录，要清掉它就得改写根提交，接着全部提交的 SHA 都会变，之后再也无法和上游 merge。而且清历史也不等于内容消失了，远端 force push 之后，旧的提交在一段时间内还是能通过 SHA 访问到。</p>
<p>于是换了个思路：内容搬走，主仓库只留代码。</p>
<section><h2>结构<a href="#结构"><span>#</span></a></h2><ul>
<li>私有仓库 <code>my-blog-content</code>，目录结构原样镜像 <code>src/content/*</code></li>
<li>主仓库 <code>.gitignore</code> 里加一行 <code>src/content/</code></li>
<li><code>package.json</code> 的 <code>dev</code> 和 <code>build</code> 第一步都跑 <code>node scripts/sync-content.mjs</code></li>
</ul><p>本地开发时，<code>src/content</code> 就是那个私有仓库的工作副本，写文章、提交都在它自己的 git 里完成，主仓库全程不掺和。</p></section>
<section><h2>同步脚本的取舍<a href="#同步脚本的取舍"><span>#</span></a></h2><p>脚本要处理的情况只有几种，但每一种都有伤到数据的可能，所以原则定得比较死：宁可什么都不做，也不动你的文件。</p>

<table><thead><tr><th><code>src/content</code> 状态</th><th>行为</th></tr></thead><tbody><tr><td>不存在</td><td>clone 内容仓库</td></tr><tr><td>是工作副本且没有未提交改动</td><td><code>git pull --ff-only</code></td></tr><tr><td>有未提交改动</td><td>跳过更新，保留工作副本</td></tr><tr><td>存在但不是 git 仓库</td><td>默认不动（加 <code>--force</code> 才改名备份后重新克隆）</td></tr><tr><td>拉取失败但本地有内容</td><td>打警告，继续用现有的</td></tr><tr><td>拉取失败且本地没有内容</td><td>中止构建</td></tr></tbody></table><p>最后一条是刻意加的。构建平台拉不到内容时如果不中止，就会部署出一个没有文章的站点，这比构建失败更难发现。</p></section>
<section><h2>两个踩到的坑<a href="#两个踩到的坑"><span>#</span></a></h2><section><h3>浅克隆会让更新永久失效<a href="#浅克隆会让更新永久失效"><span>#</span></a></h3><p>内容仓库很小，一开始想着用 <code>--depth 1</code> 省点时间，同步时 <code>git fetch --depth 1</code> 再 <code>git merge --ff-only FETCH_HEAD</code>。</p><p>结果是永远更新不了，git 报历史不相干。原因是浅克隆拿到的是一棵孤立的树，fetch 回来的又是另一棵，两者没有共同祖先，<code>--ff-only</code> 直接判定不可快进。改成全量克隆就好了，反正仓库里只有几十个文本文件。</p></section><section><h3><code>|| true</code> 会吞掉它前面整段的失败<a href="#-true-会吞掉它前面整段的失败"><span>#</span></a></h3><p>原来的构建链是这种写法：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>...</span><span> &amp;&amp; </span><span>npx</span><span> </span><span>tsx</span><span> </span><span>scripts/build-hitokoto.ts</span><span> || </span><span>true</span><span> &amp;&amp; </span><span>npx</span><span> </span><span>tsx</span><span> </span><span>scripts/...</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>|| true</code> 绑定的是它前面那条完整的链。我把内容同步放到构建链最前面之后，同步失败也照样返回 0，构建继续往下跑，最后部署出去一个空站。</p><p>改成用括号把可选步骤圈起来：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>node</span><span> </span><span>scripts/sync-content.mjs</span><span> &amp;&amp; (</span><span>node</span><span> </span><span>scripts/sync-friends.mjs</span><span> &amp;&amp; </span><span>npx</span><span> </span><span>tsx</span><span> </span><span>...</span><span> || </span><span>true</span><span>) &amp;&amp; </span><span>...</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>同步这一步失败就会老实中断。</p></section></section>
<section><h2>把 content 移出索引<a href="#把-content-移出索引"><span>#</span></a></h2><p>工作区里的文件要留着（还要继续写文章），但主仓库不能再跟踪这个目录。</p><p>常规做法是 <code>git rm -r --cached src/content</code>，只是我这边的 git 工作区是映射进来的目录，之前有被 <code>git rm</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>ls-files</span><span> </span><span>-z</span><span> </span><span>--</span><span> </span><span>src/content</span><span> | </span><span>git</span><span> </span><span>update-index</span><span> </span><span>--force-remove</span><span> </span><span>-z</span><span> </span><span>--stdin</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>跑完 <code>git status</code> 会看到那批文件显示成 <code>D</code>，但磁盘上一个都没少。提交之后，主仓库就彻底不跟踪 <code>src/content</code> 了。</p></section>
<section><h2>内容推送了，站点却不更新<a href="#内容推送了站点却不更新"><span>#</span></a></h2><p>这个问题比较隐蔽。EdgeOne 的 Git 自动部署只监听主仓库，内容仓库推文章它完全不知道。表现就是文章推上去了，本地 dev 能看到，线上还是旧的。</p><p>办法是在内容仓库加一个 workflow，push 到 main 时主动打一次 EdgeOne 的部署钩子。几个细节：</p><ul>
<li>钩子是 POST 触发，URL 本身就带认证，不需要额外 header 或者 body，带不带都能正常返回</li>
<li>加了 <code>concurrency</code> 配置，连着推几篇只触发一次部署，不会排队构建好几遍</li>
<li>钩子 URL 放在仓库 Secret 里。没配的时候 workflow 只打一行 warning 就跳过，这样不会天天红叉，但也得记得配，否则就是静默失效</li>
</ul></section>
<section><h2>剩下的事<a href="#剩下的事"><span>#</span></a></h2><p>内容分离之后，有几处还指着老路径。写博客的机器人之前是往主仓库的 <code>src/content/posts</code> 推文章的，现在得改成指向内容仓库。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/17/</id>
      <title type="text">把 Aemeath 的站点统计页搬进我的博客</title>
      <published>2026-10-08T22:50:00.000Z</published>
      <updated>2026-10-08T22:50:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/17/"/>
      <summary type="text">想要个自己的数据统计页，就把 Aemeath 那套整页搬了过来。数据走 Umami 公开分享不用后端，但地图要换成合规的，推送节奏的口径也得按自己仓库重算，原作者写死的参数照抄会出错。</summary>
      <content type="html"><![CDATA[<p>之前博客的数据一直在 Umami 后台看，自己够用，但总想着有个能放出去的门面。后来刷到年华<a href="https://blog.amamo.top/posts/firefly-aemeath-analytics-port/" target="_blank">把 Aemeath 的站点统计页移植到 Firefly</a>移植统计页面的文章，版式挺喜欢的，就去原作者的页面看了一下怎么搬。作者把整套开源在 <a href="https://github.com/Jarvis0227/Aemeath" target="_blank">Jarvis0227/Aemeath</a>，MIT 协议。看了下也是 Astro 写的，跟 Firefly 同源，改起来的成本不大，于是动了手。</p>
<section><h2>数据是怎么拿到的<a href="#数据是怎么拿到的"><span>#</span></a></h2><p>Umami 后台可以给某个站点生成一条「公开分享」链接，拿到链接末尾那串 slug 之后，前端就能自己去读数据：</p><ol>
<li><code>GET /api/share/&lt;slug&gt;</code> 换回 <code>websiteId</code> 和一个 share token</li>
<li>带着 <code>x-umami-share-token</code> 去 <code>/api/websites/&lt;id&gt;/stats</code>、<code>/metrics</code> 取数</li>
</ol><p>整个流程只读，不需要管理员 token，也不用自己写个中间层转发。分享链接随时能在后台撤掉，所以 slug 被看到也没什么风险。</p><p>我把 slug 和实例地址放进了配置文件，另外留了一个「历史累计」的口子——要是以后换统计服务，可以把之前的数字并进展示，而不会去写 Umami。</p></section>
<section><h2>按自己情况改的三个地方<a href="#按自己情况改的三个地方"><span>#</span></a></h2><p>搬别人写好的代码，费劲的不是抄，是搞清楚哪些地方作者是按他自己仓库的情况写死的以及适配。这次碰到三处。</p><section><h3>别挂在全局 Layout 上<a href="#别挂在全局-layout-上"><span>#</span></a></h3><p>原版把统计组件挂在全局 Layout，因为它的侧栏要显示访问快照。我只在 <code>/analytics/</code> 这一页用它，所以把组件挂在了页面内部。</p></section><section><h3>地图要换成合规的<a href="#地图要换成合规的"><span>#</span></a></h3><p>原版的国内访客地图用的是维基系那套边界数据，缺南海诸岛，也没有九段线。本站是国内备案网站，需要考虑合规性，所以原样搬过来不合适，需要自己改一改地图。</p><p>数据源换成高德 DataV 的行政边界，用 Albers 等积圆锥投影（双标准纬线 25° 和 47°、中央经线 105°）自己生成 SVG。写脚本时碰到两个具体问题：</p><ul>
<li>九段线的数据是零宽轮廓，没有面积，用 <code>fill</code> 画出来什么都看不到，必须走 <code>stroke</code>。</li>
<li>南海插图里那堆岛礁点特别小，主图缩放之后直接消失，得给它们加 <code>vector-effect="non-scaling-stroke"</code>。</li>
</ul><p>另外还有个样式上的坑：底图那张 <code>&lt;img&gt;</code> 我一开始设了透明度，结果把叠在上面的插图也压没了。插图是挂在 overlay 层的，得单独处理。</p><p>脚本同时会回写页面里的三段几何数据，所以以后换底图、改投影，都必须重跑脚本，不能只换文件。</p></section><section><h3>推送节奏：原作者是按他自己仓库算的<a href="#推送节奏原作者是按他自己仓库算的"><span>#</span></a></h3><p>这块踩得最多。</p><p>第一个问题很低级：原版有个 <code>src/pages/api/github-pushes.json.ts</code>，我移植时只搬了页面，忘了这个接口文件，卡片一直在请求一个 404 地址，什么都不显示。这类页面依赖的 API 文件比较分散，搬的时候得对着原仓库的文件树逐个核对。</p><p>第二个问题是口径。原版算推送次数时有一行：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>Math</span><span>.</span></span><span>max</span><span>(</span><span>0</span><span><span>, </span><span>当天提交数</span><span> </span></span><span>-</span><span><span> </span><span>defaultActionRunsPerDay</span><span>)</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>defaultActionRunsPerDay</code> 是 2，注释写着「扣除每日 2 次固定 Actions」。这是原作者按他仓库的情况定的，他那边每天有两次定时任务提交，不扣掉的话统计出来的就不是「人推的」。</p><p>我仓库里没有这种固定提交。唯一的定时任务是同步友链的 workflow，每天跑一次，而且是检测到变化才提交。照抄那个 2 的后果是，某天我明明推了 1 次，<code>1 - 2</code> 被 <code>Math.max(0, ...)</code> 吃成 0。把本地的 24 次提交算了一遍，页面显示 6 次。</p><p>改成 0 之后数字对了，但很快发现第三个问题：数字对了，可它不是我想看的。</p><p>这个仓库是从 Firefly 上游 fork 的，master 历史里大部分提交是原作者和上游其他贡献者写的。53 周范围内 1446 条提交，真正属于我的只有 121 条。所以还得按提交者过滤，用邮箱和 GitHub 登录名做白名单，把别人的提交筛掉。</p><p>第四个问题出在构建环境。本地统计一切正常，部署上去数字少得离谱。原因是托管平台的构建环境里 <code>.git</code> 是浅克隆，<code>git log --since="53 weeks ago"</code> 只能看到最近二十来个提交。所以在回退到读本地 git 之前，得先补一次历史：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>git</span><span> </span><span>fetch</span><span> </span></span><span>--</span><span>deepen</span><span>=</span><span>2000</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>补深之后线上才拿到完整的提交量。</p></section><section><h3>一个纯前端的坑：属性名撞车<a href="#一个纯前端的坑属性名撞车"><span>#</span></a></h3><p>数据源改完，卡片里「次数」那一格还是空的，其它指标都正常。</p><p>翻代码，取元素是这么写的：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>count</span><span> </span><span>=</span><span><span> </span><span>panel</span><span>.</span></span><span>querySelector</span><span>(</span><span>"[data-push-count]"</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>看起来很对。但用浏览器数了一下选择器命中了几个元素：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[data-push-count] → 309</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>热力图的每个格子都写了 <code>data-push-count</code>（存当天提交数），而它们在 DOM 里排在底部那个 <code>&lt;strong&gt;</code> 前面。<code>querySelector</code> 只认第一个匹配，于是每次写入的数字都落进了一个空的热力图格子，底部那格永远是初始值。</p><p>改法是把底部那个换成唯一的名字，同时改掉对应的选择器。这类问题的症状是某个数字永远是空的，光读代码很难发现，得实际去数一下选择器命中几个。</p></section></section>
<section><h2>怎么确认数字是对的<a href="#怎么确认数字是对的"><span>#</span></a></h2><p>统计页最麻烦的地方在于「数字不对」有很多种可能：数据源少、口径错、映射错、还是渲染错。所以我基本是把每个数字都跟数据源直接算出来的值对一遍。</p><p>热力图点亮了多少格，就跟 <code>git log</code> 数出来的、44 周内有提交的天数比（201 对 202）；「平均间隔约 6 小时」就跟「1446 条提交除以 361 天」对。对不上再往回一层层找。这比盯着页面猜快得多。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/16/</id>
      <title type="text">评论区表情面板被裁、切换还抖，原因在 overflow</title>
      <published>2026-09-16T20:34:48.000Z</published>
      <updated>2026-09-16T20:34:48.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/16/"/>
      <summary type="text">Firefly 接了 Waline 评论，回复时点开表情面板，下半截和分类 tab 被主题的圆角卡片裁掉，切分类还会左右抖。这篇讲清楚根因和修法，从 DOM 排查到 overflow 的非法组合，对应上游</summary>
      <content type="html"><![CDATA[<p>Firefly 的评论区用的是 Waline。回复某条评论点开表情面板，弹层从输入框上方冒出来，结果下半截连同底部分类 tab 被主题的圆角卡片切了，只能看到半截。切分类时，表情网格和底下 tab 还会左右轻微挪一下，像在抖。</p>
<p>这俩看着像 Waline 的 bug，其实是主题样式的锅。我顺着 DOM 扒，根因全在 <code>overflow</code> 和布局尺寸上，修完提了上游PR<a href="https://github.com/CuteLeaf/Firefly/pull/636" target="_blank">#636</a> 。</p>
<section><h2>卡片被自己的 overflow 裁了<a href="#卡片被自己的-overflow-裁了"><span>#</span></a></h2><p>Waline 表情面板是绝对定位、浮在评论区之上。它祖宗链上有两层主题元素设了 <code>overflow: hidden</code>：评论卡片 <code>#post-comments</code> 和内容列。浮层要往纵向溢出内容列才能完整显示，可 <code>hidden</code> 把纵向也堵死了，下半截就被切。这不是 Waline 加的限制，是主题为了圆角裁剪在祖先上套的，属于自己裁自己。</p><p>我一开始想改 <code>overflow: visible</code>，但只能放纵向，横向还得裁——卡片有圆角装饰 SVG，横向不能溢出去把圆角撑破。然后栽了个跟头：<code>overflow-x: hidden</code> 配 <code>overflow-y: visible</code> 在浏览器里是非法组合。只要一个轴是 <code>visible</code>、另一个是 <code>hidden</code>/<code>scroll</code>/<code>auto</code>/<code>clip</code> 里任一非 visible 值，规范会强制把 <code>visible</code> 那轴降级成 <code>auto</code>，结果浮层还是被关进看不见的滚动容器，照样裁。</p><p>正确写法是 <code>overflow-x: clip</code> 配 <code>overflow-y: visible</code>——<code>clip</code> 只裁切、不生成滚动容器，不触发那条降级规则，也不影响列内 <code>sticky</code> 吸顶。我把卡片和内容列都改成这个组合，装饰性 SVG 背景挪到独立的 <code>absolute inset-0 overflow-hidden</code> 裁剪层，圆角交给那层，卡片本体只管放开纵向。</p><p>这活在老新架构上做法不一样。早一版主题内容列还叠了 <code>onload-animation</code> 的残留 transform，生成新层叠上下文把页脚抬到浮层之上，分类 tab 点不动，那版得给内容列加 <code>z-20</code> 压回去。后来上游重写主网格布局，页脚那层 transform 没了，层叠关系自然就对了，<code>z-20</code> 也就不需要了。我这次在新架构上改，没动 z-index。</p><div><div><div><div><span><p>规范强制 visible→auto</p></span></div><div><span></span></div><div><span><p>overflow-x: hidden + overflow-y: visible</p></span></div><div><span><p>浮层被关进滚动容器 → 仍被裁</p></span></div><div><span><p>overflow-x: clip + overflow-y: visible</p></span></div><div><span><p>只裁横向, 纵向放开 → 浮层完整显示</p></span></div>
</div><div><div><span><p>规范强制 visible→auto</p></span></div><div><span></span></div><div><span><p>overflow-x: hidden + overflow-y: visible</p></span></div><div><span><p>浮层被关进滚动容器 → 仍被裁</p></span></div><div><span><p>overflow-x: clip + overflow-y: visible</p></span></div><div><span><p>只裁横向, 纵向放开 → 浮层完整显示</p></span></div>
</div></div></div></section>
<section><h2>切换分类时左右抖<a href="#切换分类时左右抖"><span>#</span></a></h2><p>抖的来源有两处：滚动条槽位和面板高度。</p><p><code>.wl-tab-wrapper</code> 内容多时出现纵向滚动条，一出现挤掉约 6px 宽，里面的表情网格跟着重排，切到分类多的那组就晃一下。我给它加 <code>scrollbar-gutter: stable</code>，浏览器提前把滚动条槽位占住，有没有滚动条列宽都不变，网格就稳了。</p><p>另一处是高度。内容不足时网格区按内容收缩，比顶满矮 1px，切换时面板整体高低变一下，肉眼能觉出来在跳。我把 <code>.wl-tab-wrapper</code> 高度钉成 <code>145px</code>——这个数抄自 Waline 自身的 <code>max-height</code>，它内部对表情面板有这个上限，跟着对齐就不会出现钉太高反而比内容矮、或钉太矮内容被截。代价是它大版本改了 <code>max-height</code>，我这边的 145 得跟着改，属于手写耦合。</p><p>还有更隐蔽的：Waline 给选中 tab 加 <code>0 1px 1px</code> 边框（上 0、左右 1px、下 1px），激活的 tab 比未激活宽 2px、高 1px。切换时原来没边框的 tab 突然有了边框，相邻 tab 被顶着左右挪——这就是切换时左右抖最明显那一下。我给未选中 tab 补了条等宽透明边框 <code>border: solid transparent; border-width: 0 1px 1px</code>，占位尺寸统一了，切换时谁都不动。</p><hr /><p>这个面板是 Vue 渲染的，分类切换时 Waline 会销毁重建 <code>.wl-tab-wrapper</code> 节点。所以想在上面再加自定义（比如高亮常用表情、记上次选的分类），每次都得重新 query，不能缓存节点引用——缓存了拿到的是被 Vue 换掉的旧节点。</p></section>]]></content>
    </entry>
    <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">把 Sonic Topography 从 Electron 桌面应用搬进浏览器：砍掉主进程和本地后端，歌单改用 localStorage，音源换成 Meting。顺带填了封面跨域污染 canvas、移动端适配的坑。</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/12/</id>
      <title type="text">修复了 Firefly 隔离坏图文章的脚本，顺手填了五个坑</title>
      <published>2026-08-27T16:43:00.000Z</published>
      <updated>2026-08-27T16:43:00.000Z</updated>
      <author><name>羡鱼</name></author>
      <link rel="alternate" href="https://x1anyu.cn/posts/12/"/>
      <summary type="text">Firefly 主题里有个脚本专门把引用了不存在图片的文章挪进隔离目录，免得构建出一堆裂图。它原本漏扫、误判、还会在 Windows 上假移动。这篇记一下我填这五个坑时的思路和排查过程，对应上游</summary>
      <content type="html"><![CDATA[<p>实在不知道写什么，水一篇之前修复的脚本吧~</p>
<p>Firefly 仓库里有个 <code>scripts/quarantine-bad-posts.mjs</code>，作用挺直接：扫一遍 <code>src/content/posts</code>，把引用了不存在图片的文章挪到 <code>_quarantine/</code>。下划线开头的目录 Astro 不构建，裂图文章就进不了站点。我加它原本是图省事——文章越写越多，有回重构了资源目录，老文章里的相对图路径断了，构建出一堆裂图，我不想手动翻。</p>
<p>脚本大概是这么条流水线：</p>
<div><div><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>否</p></span></div><div><span><p>walk 递归收文章</p></span></div><div><span><p>gray-matter 拆 frontmatter / 正文</p></span></div><div><span><p>stripCode 先剥代码块</p></span></div><div><span><p>收图片：frontmatter.image + 正文里的图片</p></span></div><div><span><p>图片是本地路径且文件不存在?</p></span></div><div><span><p>renameSync 移进 _quarantine</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><p>是</p></span></div><div><span><p>否</p></span></div><div><span><p>walk 递归收文章</p></span></div><div><span><p>gray-matter 拆 frontmatter / 正文</p></span></div><div><span><p>stripCode 先剥代码块</p></span></div><div><span><p>收图片：frontmatter.image + 正文里的图片</p></span></div><div><span><p>图片是本地路径且文件不存在?</p></span></div><div><span><p>renameSync 移进 _quarantine</p></span></div><div><span><p>保留</p></span></div>
</div></div></div>
<p>结果它自己先出问题。第一次是我刚挪完图片目录，跑脚本想隔离坏图文章，构建完裂图还在，它报「0 篇隔离」，我没当回事。第二次更离谱：写了篇插图教程，正文拿代码块演示插图语法，跑完那篇教程直接被挪进隔离目录，没了。</p>
<p>这时候我才反应过来是脚本本身有毛病，不是我图片的锅。于是从头读了一遍，五个坑挨个填，提了上游 PR <a href="https://github.com/CuteLeaf/Firefly/pull/636" target="_blank">#564</a>。</p>
<section><h2><code>walk</code> 只收 index.md<a href="#walk-只收-indexmd"><span>#</span></a></h2><p>原递归判断 <code>f === "index.md"</code> 才收。可 Firefly 的文章目录三种形态混着用：专题子目录下的 <code>index.md</code>、顶层直接丢的 <code>xxx.md</code>、还有 <code>.mdx</code>。后两种全漏了——裂图要是出在顶层 <code>.md</code> 或任意 <code>.mdx</code> 上，脚本永远发现不了，还跟你报「一切正常」。我改成 md/mdx 都收，目录结构也留着，后面算相对路径还指着它。</p></section>
<section><h2><code>api</code> 随机封面和远程图被当坏图<a href="#api-随机封面和远程图被当坏图"><span>#</span></a></h2><p><code>data.image</code> 可能是主题内置的 <code>"api"</code>（随机封面，根本不是文件路径），也可能是 <code>https://</code> 外链。原代码只要 <code>typeof</code> 是 string 就当本地路径去 <code>existsSync</code>，这些全被判成图没了，文章白隔离。我抽了个 <code>isExternalUrl</code>，把 http(s)、协议相对的 <code>//</code>、<code>data:</code>/<code>mailto:</code> 这类 scheme 都算外链，frontmatter 和正文共用一份判断，<code>api</code> 单独跳过。</p><p>协议相对的 <code>//cdn.x/img.png</code> 在 Node 里既不是 http 也不是 https，但浏览器加载时是外链，这个得单独算，不然又会误判。</p></section>
<section><h2>正文里的示例代码被算成引用图<a href="#正文里的示例代码被算成引用图"><span>#</span></a></h2><p>扫正文用的是 <code>![alt](path)</code> 正则，可教程文章经常拿示例路径演示语法，那段在行内代码里，路径根本不存在。原脚本不分青红皂白全提，这类文章全被误隔离。我加了个 <code>stripCode</code>，先把围栏块、行内代码、还有「前面是空行的缩进块」删掉再提图。</p></section>
<section><h2>一篇 frontmatter 写崩，整轮跟着死<a href="#一篇-frontmatter-写崩整轮跟着死"><span>#</span></a></h2><p><code>matter(raw)</code> 原本写在外层，某篇 frontmatter 有重复 key 或 date 格式不对就抛错，整个脚本 <code>process.exit</code>。这比漏扫还难发现：一篇炸了后面全没扫，你以为跑完了其实只跑一半，裂图照样漏进构建。我包了层 try/catch，解析失败的这篇打印告警、照样隔离，然后继续跑下一轮。</p></section>
<section><h2>Windows 上文件其实没挪走<a href="#windows-上文件其实没挪走"><span>#</span></a></h2><p>移动用的是 <code>file.replace(POSTS_DIR, QUARANTINE_DIR)</code>。Linux 上 <code>POSTS_DIR</code> 是正斜杠，没问题；Windows 上 <code>path.join</code> 出来反斜杠，而 <code>POSTS_DIR</code> 字面量是正斜杠，replace 匹配不上，target 变成错路径，<code>renameSync</code> 要么抛异常要么挪到奇怪地方——看着像处理了，源文件其实还在原位，下次跑又隔离一遍。改成 <code>path.relative</code> 算相对路径、再 <code>path.resolve</code> join 回去，跨平台都能用了。</p><hr /><p>修完我把这脚本接进本地构建前的自检，顺手让它隔离时多打印一份缺失图清单，我能直接照着补图，而不是干删。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://x1anyu.cn/posts/10/</id>
      <title type="text">自建 Meting API 并让国内访问不再慢：Vercel 香港节点 + 歌词代理 + 边缘缓存</title>
      <published>2026-08-27T15:26:54.000Z</published>
      <updated>2026-08-27T15:26:54.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">没服务器也想随手发博客，于是搭了个企业微信机器人：发条消息，几分钟后它自己变成一篇文章。这篇记整条链路——企微 5 秒回调怎么绕、EdgeOne 边缘函数解密验签、Worker 写 GitHub。</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">看到有人用微信发博文觉得挺有意思，但原方案要一台服务器，而我没有。换个思路：企业微信的机器人不用服务器也能收消息，再借 EdgeOne 边缘函数转给 Cloudflare Worker，整条链路零成本跑通。</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">写 Firefly 博客最烦的不是正文，而是手工写 FrontMatter：字段一堆、引号冒号要转义，错一处整页就崩。于是做了 Firefly-Markdown，纯前端零依赖，双击 index.html 就能用。</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">站点的第一篇文章，也是这个博客的起点。内容最初是 WordPress 自带的 Hello World，换到 Astro 之后顺手改成了这段，一直留着当个纪念，顺便看看文章卡片和分类长什么样。</summary>
      <content type="html"><![CDATA[<p>欢迎使用WordPress。这是您的第一篇文章。编辑或删除它，然后开始写作吧！</p>
<p>欢迎使用 Astro ，这是您的第一篇文章。编辑或删除它，然后尽情Ciallo~</p>]]></content>
    </entry>
</feed>
