<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>takomitkm</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://takomitkm.github.io/</id>
  <link href="https://takomitkm.github.io/" rel="alternate"/>
  <link href="https://takomitkm.github.io/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, takomitkm</rights>
  <subtitle>自留地</subtitle>
  <title>takomitkm</title>
  <updated>2026-10-17T19:36:51.420Z</updated>
  <entry>
    <author>
      <name>takomitkm</name>
    </author>
    <category term="测试" scheme="https://takomitkm.github.io/categories/%E6%B5%8B%E8%AF%95/"/>
    <category term="Markdown" scheme="https://takomitkm.github.io/tags/Markdown/"/>
    <content>
      <![CDATA[<p> 留在这里当排版回归用：改主题或换渲染器之后重看这一页就知道有没有坏。</p><h2 id="列表与嵌套"> 列表与嵌套 </h2><ul><li> 一级项<ul><li> 二级项 </li><li> 二级项（带 <code> 行内代码 </code>）</li></ul></li><li> 有序：</li></ul><ol><li> 第一步 </li><li> 第二步 </li></ol><h2 id="引用与代码块"> 引用与代码块 </h2><blockquote><p> 这是一段引用。<br>第二行。</p></blockquote><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">fib</span>(<span class="params">n: <span class="built_in">int</span></span>) -&gt; <span class="built_in">int</span>:</span><br><span class="line">    a, b = <span class="number">0</span>, <span class="number">1</span></span><br><span class="line">    <span class="keyword">for</span> _ <span class="keyword">in</span> <span class="built_in">range</span>(n):</span><br><span class="line">        a, b = b, a + b</span><br><span class="line">    <span class="keyword">return</span> a</span><br></pre></td></tr></table></figure><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">alias</span> hexo=<span class="string">&#x27;deno run -A npm:hexo&#x27;</span></span><br><span class="line">hexo server</span><br></pre></td></tr></table></figure><h2 id="表格"> 表格 </h2><table><thead><tr><th> 项目 </th><th> 值 </th><th> 说明 </th></tr></thead><tbody><tr><td> 断点 </td><td>1023px</td><td> 与主题的 <code>$app_mobile_width</code> 对齐，手机版布局（含左上角抽屉菜单）在这个宽度以下生效 </td></tr><tr><td> 看板娘尺寸 </td><td>300x300</td><td><code>#live2d</code> 的 CSS 显示尺寸，改 <code>waifu.css</code> 即可 </td></tr><tr><td> 模型 </td><td>umaru</td><td>Live2D Cubism 2，来自 HesperusVenus/live2d</td></tr></tbody></table><h2 id="其它"> 其它 </h2><p> 分割线、删除线 <s> 划掉 </s>、脚注式的行内链接 <a href="https://github.com">https://github.com</a>、以及一个长段落用于检查正文宽度与换行：中文与 English 混排时候字距会不会突变，等宽数字 0123456789 在对齐上是否表现正常，以及标点挤压在移动端小屏上的观感。</p><span id="more"></span>]]>
    </content>
    <id>https://takomitkm.github.io/posts/682070878/</id>
    <link href="https://takomitkm.github.io/posts/682070878/"/>
    <published>2026-09-27T01:20:00.000Z</published>
    <summary>
      <![CDATA[<p> 留在这里当排版回归用：改主题或换渲染器之后重看这一页就知道有没有坏。</p>
<h2 id="列表与嵌套"> 列表与嵌套 </h2>
<ul>
<li> 一级项
<ul>
<li> 二级项 </li>
<li> 二级项（带 <code> 行内代码 </code>）</li>
</ul>
</li>
<li> 有序：</li>
</ul>
<ol>
<li> 第一步 </li>
<li> 第二步 </li>
</ol>
<h2 id="引用与代码块"> 引用与代码块 </h2>
<blockquote>
<p> 这是一段引用。<br>
第二行。</p>
</blockquote>
<figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">fib</span>(<span class="params">n: <span class="built_in">int</span></span>) -&gt; <span class="built_in">int</span>:</span><br><span class="line">    a, b = <span class="number">0</span>, <span class="number">1</span></span><br><span class="line">    <span class="keyword">for</span> _ <span class="keyword">in</span> <span class="built_in">range</span>(n):</span><br><span class="line">        a, b = b, a + b</span><br><span class="line">    <span class="keyword">return</span> a</span><br></pre></td></tr></table></figure>
<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">alias</span> hexo=<span class="string">&#x27;deno run -A npm:hexo&#x27;</span></span><br><span class="line">hexo server</span><br></pre></td></tr></table></figure>
<h2 id="表格"> 表格 </h2>
<table>
<thead>
<tr>
<th> 项目 </th>
<th> 值 </th>
<th> 说明 </th>
</tr>
</thead>
<tbody>
<tr>
<td> 断点 </td>
<td>1023px</td>
<td> 与主题的 <code>$app_mobile_width</code> 对齐，手机版布局（含左上角抽屉菜单）在这个宽度以下生效 </td>
</tr>
<tr>
<td> 看板娘尺寸 </td>
<td>300x300</td>
<td><code>#live2d</code> 的 CSS 显示尺寸，改 <code>waifu.css</code> 即可 </td>
</tr>
<tr>
<td> 模型 </td>
<td>umaru</td>
<td>Live2D Cubism 2，来自 HesperusVenus/live2d</td>
</tr>
</tbody>
</table>
<h2 id="其它"> 其它 </h2>
<p> 分割线、删除线 <s> 划掉 </s>、脚注式的行内链接 <a href="https://github.com">https://github.com</a>、以及一个长段落用于检查正文宽度与换行：中文与 English 混排时候字距会不会突变，等宽数字 0123456789 在对齐上是否表现正常，以及标点挤压在移动端小屏上的观感。</p>]]>
    </summary>
    <title>Markdown 渲染自检</title>
    <updated>2026-10-17T19:36:51.420Z</updated>
  </entry>
  <entry>
    <author>
      <name>takomitkm</name>
    </author>
    <category term="建站" scheme="https://takomitkm.github.io/categories/%E5%BB%BA%E7%AB%99/"/>
    <category term="备忘" scheme="https://takomitkm.github.io/tags/%E5%A4%87%E5%BF%98/"/>
    <content>
      <![CDATA[<p>这台机器上没有 Node，构建工具是 Deno（<code>C:\Users\takomi\.deno\bin</code>，已在用户 PATH 里）。Hexo 本身是个 npm 包，Deno 能直接跑 npm 包的命令行，所以不需要 Node 也能生成和预览。</p><p>在 Git Bash 里，于仓库根目录执行：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">cd</span> /s/apps/llm/agentwork/blog</span><br><span class="line"><span class="built_in">alias</span> hexo=<span class="string">&#x27;deno run -A npm:hexo&#x27;</span>   <span class="comment"># 每次开一个新终端先敲一次</span></span><br><span class="line"></span><br><span class="line">hexo new <span class="string">&quot; 文章标题 &quot;</span>        <span class="comment"># 生成 source/_posts/ 文章标题.md</span></span><br><span class="line">hexo server              <span class="comment"># 本地预览，http://localhost:4000</span></span><br><span class="line">hexo clean &amp;&amp; hexo generate   <span class="comment"># 清缓存并静态生成到 public/</span></span><br></pre></td></tr></table></figure><span id="more"></span><h2 id="为什么不用 -deno-install">为什么不用 deno install</h2><p><code>node_modules/</code> 是当初用 npm 装的扁平结构，Deno 直接读它跑得很好，构建产物和 Node 构建逐字节一致（只差模板里的 <code>?t=</code> 缓存时间戳）。</p><p>但 <strong> 别在这个目录里跑 <code>deno install</code></strong>。实测两种链路都会把依赖装坏：</p><table><thead><tr><th>命令</th><th>结果</th></tr></thead><tbody><tr><td><code>deno install</code>（默认 isolated 链路）</td><td>包全落在 <code>node_modules/.deno/&lt;pkg&gt;/node_modules/…</code>，顶层没有条目，hexo 的 <code>require('hexo-cli')</code>、<code>require('bluebird')</code> 全部 Cannot find module</td></tr><tr><td><code>deno install --node-modules-linker=hoisted</code></td><td>顶层 179 个包齐了，但 Deno 会按自己的可达性分析裁剪包内文件：<code>prismjs</code> 只剩 <code>CHANGELOG.md</code> 和 <code>components/</code>，根目录的 <code>components.js</code> 被裁掉，<code>hexo-util/dist/prism.js</code> 一 require 就炸</td></tr></tbody></table><p>真要重装依赖，解压仓库同级的 <code>_bak/blog_node_modules_npm.tar.gz</code>（npm 装的完整备份），或者干脆不管——线上构建是 GitHub Actions 上用真 Node + <code>npm ci</code> 跑的，本地依赖坏了也不影响上线。</p><h2 id="改了东西怎么上线">改了东西怎么上线</h2><p><code>git add -A &amp;&amp; git commit &amp;&amp; git push</code> 之后由 GitHub Actions 构建并部署到 Pages，不需要本地 <code>hexo generate</code> 的结果上传。本地 <code>hexo server</code> 只为看效果。</p><h2 id="依赖清单">依赖清单</h2><table><thead><tr><th>包</th><th>版本</th><th>作用</th></tr></thead><tbody><tr><td>hexo</td><td>8.x</td><td>静态站点生成器</td></tr><tr><td>hexo-renderer-ejs</td><td>2.0.0</td><td>渲染主题的 <code>.ejs</code> 模板</td></tr><tr><td>hexo-renderer-stylus</td><td>3.0.1</td><td>编译主题的 <code>.styl</code> 样式</td></tr><tr><td>hexo-renderer-markdown-it</td><td>7.1.1</td><td>把 Markdown 文章转成 HTML</td></tr><tr><td>hexo-generator-index / archive / category / tag</td><td>4 / 2 / 2 / 2</td><td>首页、归档、分类、标签四类列表页</td></tr><tr><td>hexo-generator-feed</td><td>4.0.0</td><td>RSS</td></tr><tr><td>hexo-server</td><td>3.0.0</td><td><code>hexo server</code> 的本地服务</td></tr></tbody></table><h2 id="目录约定">目录约定</h2><ul><li><code>source/_posts/</code> 一篇文章一个 md 文件，文件名就是标题</li><li><code>source/about/index.md</code> 是「关于」页，front-matter 里 <code>layout: page</code> 且 <code>name</code> 要和文件夹名一致，主题的 page 部件才认它</li><li><code>source/</code> 下任何非 md 文件（<code>live2d/</code>）会被原样拷进站点根目录，这就是自托管资源的放法</li><li><code>scaffolds/</code> 是新文章的模板</li></ul>]]>
    </content>
    <id>https://takomitkm.github.io/posts/1501581470/</id>
    <link href="https://takomitkm.github.io/posts/1501581470/"/>
    <published>2026-09-27T01:10:00.000Z</published>
    <summary>
      <![CDATA[<p> 这台机器上没有 Node，构建工具是 Deno（<code>C:\Users\takomi\.deno\bin</code>，已在用户 PATH 里）。Hexo 本身是个 npm 包，Deno 能直接跑 npm 包的命令行，所以不需要 Node 也能生成和预览。</p>
<p> 在 Git Bash 里，于仓库根目录执行：</p>
<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">cd</span> /s/apps/llm/agentwork/blog</span><br><span class="line"><span class="built_in">alias</span> hexo=<span class="string">&#x27;deno run -A npm:hexo&#x27;</span>   <span class="comment"># 每次开一个新终端先敲一次 </span></span><br><span class="line"></span><br><span class="line">hexo new <span class="string">&quot; 文章标题 &quot;</span>        <span class="comment"># 生成 source/_posts/ 文章标题.md</span></span><br><span class="line">hexo server              <span class="comment"># 本地预览，http://localhost:4000</span></span><br><span class="line">hexo clean &amp;&amp; hexo generate   <span class="comment"># 清缓存并静态生成到 public/</span></span><br></pre></td></tr></table></figure>]]>
    </summary>
    <title>写作与本地预览命令</title>
    <updated>2026-10-17T19:36:51.420Z</updated>
  </entry>
  <entry>
    <author>
      <name>takomitkm</name>
    </author>
    <category term="建站" scheme="https://takomitkm.github.io/categories/%E5%BB%BA%E7%AB%99/"/>
    <category term="Hexo" scheme="https://takomitkm.github.io/tags/Hexo/"/>
    <category term="Live2D" scheme="https://takomitkm.github.io/tags/Live2D/"/>
    <category term="B站直播" scheme="https://takomitkm.github.io/tags/B%E7%AB%99%E7%9B%B4%E6%92%AD/"/>
    <category term="GitHub Pages" scheme="https://takomitkm.github.io/tags/GitHub-Pages/"/>
    <content>
      <![CDATA[<p>技术栈：Hexo 8 + 主题 Mustom（fork 进仓库，不是 submodule）+ 自托管的 live2d-widget 看板娘 + 一个常驻轮播页 <code>/live/</code>。抽屉菜单用的是主题自带的那个（<code>main.js</code> 的 mobile 分支 + <code>.m-drawer</code>），历史上接过一个 WordPress 的 ShiftNav 插件、现已删除，见下面 ShiftNav 一节。口径（2026-10-03 定，取代原来那条 &quot; 一切资源都要搬进仓库 &quot;）：<strong>能不存仓库就不存仓库 </strong>。凡是有个外部原站可以直接引的东西——目标站点自己的 favicon、字体托管方给的那份 <code>result.css</code>——直接外链，仓库不留副本，显示尺寸由浏览器运行时的 CSS 决定。反过来，站内自己产生、别处取不到的东西（壁纸、看板娘模型、<code>/asset/part/*.html</code>、Font Awesome 那套界面字形）照旧在仓库里；这类图标没有 &quot; 目标站自带的 &quot; 可换，就保持现状。走外链的代价都单独记在对应小节里，见 &quot; 侧栏：图标一律外链目标站点自带的 &quot; 和 &quot; 字体：外链回退链，思源黑体出库 &quot;。主题的编译产物仍然走本地（<code>CDN_ENABLE: false</code>）——这个开关不是 &quot; 只换个图标 &quot; 级别的，它把整份 <code>style.css</code> 和 <code>main.js</code> 换成上游 jinyaoMa 那一份，仓库里对主题做的所有定制都会被绕掉。构建命令和依赖清单在<a href="/posts/1501581470/"> 写作与本地预览命令 </a> 那篇，这里只记改了什么、为什么这么改、以及踩到的坑。</p><h2 id="相对原版改了什么">相对原版改了什么</h2><table><thead><tr><th>位置</th><th>改动</th></tr></thead><tbody><tr><td><code>themes/mustom/_config.yml</code></td><td>菜单留 首页 / 归档 / 关于 / 直播间；联系方式原来只留 GitHub，后来又加了 B 站按钮（见 &quot; 侧栏：slogan 改四行、B 站进联系方式 &quot; 一节）；作者的网易云歌单先换成一首单曲、后来整块换成 <code>biliplayer</code>（<code>playlist</code>/<code>index</code>/<code>countdown</code> 三个字段，见 &quot; 播放器 &quot; 一节）；改 manifest 名称</td></tr><tr><td><code>themes/mustom/layout/_partial/frame.ejs</code></td><td>在 <code>&lt;/body&gt;</code> 前注入看板娘的资源引用（ShiftNav 那 6 行后来删了，见 ShiftNav 一节）；右侧栏的 <code>audioplayer</code> partial 换成 <code>biliplayer</code>；汇文明朝体的外链 <code>&lt;link rel=&quot;stylesheet&quot;&gt;</code> 改成 <code>media=&quot;print&quot; onload=&quot;this.media='all'&quot;</code> + <code>&lt;noscript&gt;</code> 兜底（否则该域名不通会整页白屏，见播放器一节）</td></tr><tr><td><code>themes/mustom/source/asset/js/main.js</code></td><td>删掉主题自带的 <code>L2Dwidget</code> 初始化，函数体改成直接回调，避免两套看板娘抢同一个 canvas；两处不蒜子回调多写一个 <code>page_pv</code> 到本文阅读量；给页脚计数绑触屏气泡；桌面 checklist 里 <code>audioplayer</code> 换成 <code>biliplayer</code>，<code>applyConfig()</code> 回填 <code>autoplay</code>，<code>final_load()</code> 完成块里调 <code>biliplayer.ready()</code></td></tr><tr><td><code>themes/mustom/source/asset/css/_part/biliplayer.styl</code></td><td>新面板样式：舞台 <code>padding-bottom: 56.25%</code> 装 iframe，遮罩按 <code>counting/manual/waiting</code> 三种模式切换显示哪块，按钮 <code>min-width: 3.5em; min-height: 1.9em</code></td></tr><tr><td><code>themes/mustom/source/asset/css/_common/color.styl</code></td><td>新增 <code>--color-post-title</code> / <code>--color-post-categories</code>（四套皮肤各一份，夜间标题给 <code>#cccccc</code> 免得黑字压在黑底上）和 <code>--color-translater-back</code>（划词条与结果框专用，不透明）；列表标题和分类行不再直接用 <code>--color-link</code></td></tr><tr><td><code>themes/mustom/{layout/_partial/_part,source/asset/part}/sitename.ejs</code> + <code>_part/sitename.styl</code></td><td>顶栏站名整条包进 <code>&lt;a href=&quot;/&quot;&gt;</code>，两份模板字节相同；见 &quot; 顶栏站名 &quot; 一节</td></tr><tr><td><code>themes/mustom/_config.yml</code> + <code>asset/{js/common/config.js,js/main.js,js/part/biliplayer.js}</code> + <code>_part/{footer,hitokoto}.styl</code></td><td>设置项 <code>lyride</code>（空壳）改名重做成 <code>hideplayer</code> 隐藏播放器；<code>config.js</code> 改成只认默认表里的键，顺手清掉老访客存下来的废键；删掉残留的 <code>:root.lyride .p-footer</code> 内边距</td></tr><tr><td><code>themes/mustom/{js/part/panels.js,css/_part/panels.styl}</code></td><td>抽屉里多了一份 <code>.p-panels</code>，改成逐个副本填，并用断点各显示一份；见 ShiftNav 一节</td></tr><tr><td><code>tools/bili_fav_dump.mjs</code> + <code>source/data/bili-playlist.json</code></td><td>收藏夹翻页导出脚本和它生成的 993 条歌单快照（面板的 <code>data-playlist</code> 指向 <code>/data/bili-playlist.json</code>）</td></tr><tr><td><code>themes/mustom/source/asset/js/plugin/</code></td><td>删掉不再被引用的 <code>L2Dwidget.min.js</code> / <code>L2Dwidget.0.min.js</code></td></tr><tr><td><code>themes/mustom/source/asset/css/_common/layout.styl</code></td><td>启动遮罩层去掉整屏红底和整屏白纱，只留转环和顶部进度条；<code>.markdown-body table</code> 的 <code>word-break: keep-all</code> 改成只在 ≥1024px 生效，≤1023px 给 <code>code/a/td/th/p/li/h1–h6</code> 加 <code>overflow-wrap: anywhere</code></td></tr><tr><td><code>themes/mustom/source/asset/css/_common/dimension.styl</code></td><td><code>$picurl</code> 从作者的 unsplash 随机壁纸换成本地 <code>/asset/img/bg.jpg</code>；新增 <code>$divider</code>（列表页两篇文章之间那串符号，见 &quot; 列表页的分隔符 &quot; 一节）</td></tr><tr><td><code>themes/mustom/source/asset/img/qf3cu.huaji.png</code></td><td>一言面板头部那张图，换成 pixiv 作品 51125671（文件名 <code>7bg51125671_p0.jpg</code>，1169×622），等比缩到 700 宽存成 PNG；画师和再发布授权没核，和壁纸那张一样只是把出处记在仓库里</td></tr><tr><td><code>themes/mustom/source/asset/css/_common/color.styl</code></td><td>默认皮肤：body 背景换成那张图；白底背景族（默认 10 处 + colorful 7 处）的 alpha 现在是 <code>80</code>（50% 不透明），试过 <code>4d</code>→<code>b3</code>→<code>80</code> 三档，见 &quot; 换壁纸和半透明面板 &quot; 一节</td></tr><tr><td><code>themes/mustom/{layout/_partial/_part,source/asset/part}/{footer,post}.ejs</code></td><td>页脚三个计数各包一层带注释气泡的 <code>.p-footer-count-item</code>；本文阅读量的容器从 <code>leancloud_visitors</code> 换成 <code>.p-post-pv-num</code></td></tr><tr><td><code>themes/mustom/{layout/_partial/_part,source/asset/part}/biliplayer.ejs</code></td><td>新的 B 站 BGM 面板模板，两份字节相同；旧的 <code>audioplayer.ejs</code>（两份）连同 <code>audioplayer.js</code>、<code>audioplayer.styl</code>、<code>APlayer.min.js/.css</code>、<code>Meting.min.js</code> 一起删除</td></tr><tr><td><code>themes/mustom/source/asset/js/part/biliplayer.js</code></td><td>面板组件：构建期清单 + <code>sessionStorage</code> 选歌、3 秒倒计时、<code>deadline</code> 每 2 秒对表自动切歌；静音挂载时挂一次性 <code>pointerdown</code>/<code>keydown</code> 监听，页面第一次被点就用 <code>&amp;t=</code> 原位重挂接上声音（不再有 &quot; 开启声音 &quot; 按钮）</td></tr><tr><td><code>tools/bili_playlist_fix.mjs</code></td><td>构建期歌单核验：逐条问 <code>x/web-interface/view</code>，非 0 内容状态码（<code>-404</code>/<code>62002</code>/<code>-403</code>，实测这三种都在播播放器自带的 17 秒 <code>error.mp4</code>）直接剔除，其余按 <code>pages[]</code> 把多 P 合集的总时长校正成单 P 时长；风控码计入中止阈值，避免大面积误删</td></tr><tr><td><code>themes/mustom/source/asset/js/part/skin.js</code></td><td><code>set(key, byUser)</code> 区分点击和程序设置；<code>colorIcons</code> 加排除表，<code>i.fa-github</code> 不参与 colorful 轮转</td></tr><tr><td><code>themes/mustom/source/asset/js/part/settings.js</code></td><td>同样给 <code>set(key, flag, byUser)</code> 加来源标记，避免开屏把默认值写成 &quot; 访客的选择 &quot;</td></tr><tr><td><code>themes/mustom/source/asset/js/common/config.js</code></td><td>新增 <code>configChosen</code> 键和 <code>has()</code>，用来区分 &quot; 访客真选过 &quot; 和 &quot; 跟着别的项一起被存进去的默认值 &quot;；默认项多一个 <code>translater: 'zh'</code></td></tr><tr><td><code>themes/mustom/source/asset/js/common/util.js</code></td><td>删掉 <code>baiduTranslate</code> 连同 md5 / JSONP 依赖，换成免密钥的 <code>googleTranslate</code></td></tr><tr><td><code>themes/mustom/source/asset/js/part/translater.js</code></td><td>三个语言分支合并成一个，改调谷歌；记住上次选的目标语言并标成默认</td></tr><tr><td><code>themes/mustom/source/asset/css/_part/translater.styl</code></td><td>被记住的那个语言项加 <code>.default</code> 下划线</td></tr><tr><td><code>themes/mustom/source/asset/css/_part/hitokoto.styl</code></td><td>句子改成只在标点处换行；作者行的 <code>-「 名字 」-</code> 收进一个 <code>inline-block</code> 盒子铺面板同款底色（不分三段，见 &quot; 一言面板 &quot; 一节）；<code>.p-hitokoto-from</code> 补 <code>z-index: 1</code>，修掉夜间作者行被图片整条盖住；≤1023px 挂 <code>.p-hitokoto-bgm</code> 的几条（图片让位给播放器、隐藏播放器时图片回来、作者行回常规流、播放器自带标题栏隐藏），见 &quot; 播放器 &quot; 一节</td></tr><tr><td><code>themes/mustom/_config.yml</code></td><td>serviceWorker 的 networkOnly 白名单加了 `bilibili.com</td></tr><tr><td><code>themes/mustom/source/asset/css/_part/footer.styl</code></td><td>注释气泡的样式</td></tr><tr><td><code>themes/mustom/{layout/_partial/_part,source/asset/part}/page.ejs</code></td><td>独立页面的标题条右侧加 <code>.p-page-pv</code>（本页访问量，沿用 <code>.p-post-pv-num</code> 这个 class，所以不用改 JS）</td></tr><tr><td><code>themes/mustom/source/asset/css/_part/page.styl</code></td><td>标题条改成 flex，访问量顶到右边；移动端图标隐藏规则里给眼睛开了个口子</td></tr><tr><td><code>themes/mustom/source/asset/js/part/comment.js</code></td><td>删掉给 <code>.leancloud_visitors</code> 设 id 和 <code>data-flag-title</code> 的那个分支</td></tr><tr><td><code>themes/mustom/source/asset/live2d/umaru/</code></td><td>看板娘模型，haruto 那套已删</td></tr><tr><td><code>themes/mustom/source/asset/lang/{zh-cn,en}.yml</code></td><td>新菜单项的显示名和面包屑名；页脚气泡文案 <code>footer.tipPv/tipUv/tipWd</code>、<code>page.viewcount</code>，<code>post.viewcount</code> 改成说明是不蒜子按本页地址统计；<code>translater.to</code> 标明翻译走谷歌；<code>biliplayer.*</code> 一组面板文案（标题 / 即将播放 / 取消自动播放 / 播放 / 上下首）；<code>sitename</code> 改成 &quot; 自留地 &quot;</td></tr><tr><td><code>source/about/index.md</code></td><td>正文里加过 B 站空间链接 <a href="https://space.bilibili.com/675978454">https://space.bilibili.com/675978454</a>（页面正文走运行时 <code>/api/pages/about.json</code>，不是 HTML 里），后来这行删了、改成侧栏的 B 站按钮，见下面两节</td></tr><tr><td><code>deno.lock</code></td><td>纳入版本控制：本地 <code>deno run -A npm:hexo</code> 的 npm 依赖解析锁，只记 <code>packageJson.dependencies</code> 那十条；线上 Actions 走 <code>npm install</code>，不读这个文件</td></tr><tr><td><code>source/live2d/</code></td><td>live2d-widgets 1.0.1 的 <code>dist</code>，换装按钮已关掉</td></tr><tr><td><code>source/live2d/dist/waifu-tips.js</code></td><td>vendor bundle 的两处改动：取时段问候那段拆掉 <code>&quot;/&quot; === location.pathname</code>（现在每页都出，根路径仍只有时段问候、别的页面在后面接欢迎行），<code>export</code> 前加一行 <code>window.waifuShowTip=i;</code> 把气泡显示函数交出去</td></tr><tr><td><code>source/live2d/loader.js</code></td><td><code>initWidget()</code> 之后自己再拉一次 <code>waifu-tips.json</code>，30 秒轮询做整点报时（换小时才报、气泡不在就不报且不记账，避免复活被 quit 掉的看板娘）</td></tr><tr><td><code>source/live2d/waifu-tips.json</code></td><td>新增 <code>message.hourly</code> 两条报时文案，<code>{hour}</code> 由 loader 替换</td></tr><tr><td><code>source/live2d/site.css</code></td><td>≤1023px：canvas 150×150、气泡 180 宽 13 号字、<code>translateY</code> 收到 12px、<code>#waifu-tool</code> 常显（触屏没 hover）</td></tr><tr><td><code>themes/mustom/scripts/plugin/hexo-generator-restful/lib/generator.js</code></td><td>新增 <code>preview(str, limit)</code>：剥标签→解实体→压空白→按字数切→补 <code>......</code>→重新转义；<code>postMap</code>（<code>/api/posts.json</code>）和 <code>postlist</code>（分类／标签）两个调用点用它，单篇那条不动</td></tr><tr><td><code>themes/mustom/_config.yml</code></td><td>新增 <code>excerpt_chars: 500</code> 给上面那个截断用</td></tr><tr><td><code>themes/mustom/source/asset/css/_plugin/highlight.styl</code></td><td>≤1023px：<code>pre/.highlight/.gist</code> 的负 <code>margin</code> 出血归零、左右 padding 40→8px、<code>.highlight pre</code> 改 <code>pre-wrap</code>、<code>.gutter</code> 行号列隐藏、<code>.highlight table</code> 收 <code>width: 100%</code></td></tr><tr><td><code>themes/mustom/source/asset/css/_part/recentposts.styl</code></td><td>首页列表条目之间加 <code>$divider</code> 分隔符，走 <code>::before</code> + <code>display: table-caption</code>（这类条目本身是 <code>display: table</code>），见 &quot; 列表页的分隔符 &quot; 一节</td></tr><tr><td><code>themes/mustom/source/asset/css/_part/timeline.styl</code></td><td>归档／分类／标签列表同样加 <code>$divider</code>，走 <code>.p-timeline-row::after</code> + <code>clear: both</code>，末篇用两层 <code>:last-child</code> 免掉</td></tr><tr><td><code>source/about/index.md</code></td><td>正文末尾一段页内 <code>&lt;style&gt;</code> + 一个 <code>.about-stamp</code> 空 div：关于页背景右上角一枚红 <code>#ff3300</code>、70% 透明、圆圈套住 &quot; 偷 &quot; 的内联 SVG，直径为文章宽度一半，见同一节</td></tr><tr><td><code>themes/mustom/scripts/plugin/heading-numbers.js</code></td><td>新增：构建期给正文每个标题写 <code>data-num=&quot;2.1&quot;</code> 这样的序号，算法和 <code>toc.js</code> 同源，见 &quot; 正文标题序号为什么全是 0.x&quot; 一节</td></tr><tr><td><code>themes/mustom/scripts/plugin/hexo-generator-restful/lib/generator.js</code></td><td>单篇 <code>/api/posts/&lt;abbrlink&gt;.json</code> 生成前先 <code>headingNumbers(post.content)</code>，<code>content</code> 和 <code>toc</code> 都用这份带 <code>data-num</code> 的 HTML，两者不会各算各的</td></tr><tr><td><code>themes/mustom/source/asset/css/_part/post.styl</code></td><td>删掉 <code>headingContent()</code> 和整套 <code>counter-reset</code>/<code>counter-increment</code>，标题号改成 <code>h{$num}[data-num]:before{content: attr(data-num) &quot;. &quot;}</code></td></tr><tr><td><code>themes/mustom/source/asset/css/_common/layout.styl</code></td><td><code>.markdown-body{counter-reset: h1}</code> 这条没用的删了（正文里根本没有 h1，它只是让 <code>counter(h1)</code> 从 0 开始）</td></tr><tr><td><code>themes/mustom/source/asset/css/_common/dimension.styl</code></td><td><code>$divider</code> 下面加一行 <code>$divider_value = var(--divider, $divider)</code>，两个消费点改用这个变量，样式里仍留一份静态兜底</td></tr><tr><td><code>themes/mustom/source/asset/css/_part/recentposts.styl</code>、<code>_part/timeline.styl</code></td><td>分隔符的 <code>content</code> 换成 <code>$divider_value</code>，各自补 <code>white-space: nowrap</code>（⫘ 的个数变多后不能让它在行中间断行）</td></tr><tr><td><code>themes/mustom/source/asset/js/main.js</code></td><td>新增 <code>fitDividers()</code> 和 <code>watchDividers()</code>：按列表行实际可用宽度重算 ⫘ 的个数，写成 <code>.m-content</code> 的行内 <code>--divider</code>；用 <code>ResizeObserver</code> 盯 <code>.m-content</code>，在 <code>final_load()</code> 完成块里紧跟 <code>biliplayer.ready()</code> 调一次</td></tr><tr><td><code>themes/mustom/source/asset/lang/{zh-cn,en}.yml</code></td><td><code>brand.slogan</code> 换成 &quot; 至虛極也守情裻也萬物旁作吾以觀其復也 &quot; 和 &quot;y emprendo mi andar a una ruta real&quot;（yml 里两头用 <code>&amp;quot;</code>，<code>lang.js</code> 是 <code>innerHTML</code> 注入，实体会解成真引号）</td></tr><tr><td><code>themes/mustom/source/asset/css/_part/brand.styl</code></td><td><code>.p-brand-slogan</code> 去掉 <code>white-space: nowrap</code>：中文 slogan 16 字在 16px 下要 300px，而 <code>.p-brand</code> 只有 256px 且 <code>overflow: hidden</code>，不换行就直接裁掉尾巴</td></tr><tr><td><code>source/about/index.md</code></td><td>括号里那三条写给我的说明换成运行时算出来的内容：<code>#abBorn</code>（访客本地日期往前 17 年）、<code>#abHeight</code>（160 + 访客本地小时）、两条装饰线走 <code>data-fill</code> 平铺截断，见 &quot; 关于页那三处算出来的东西 &quot; 一节</td></tr><tr><td><code>themes/mustom/_config.yml</code></td><td><code>contact</code> 里加 <code>bilibili</code> 一条（<code>url</code> + <code>&lt;img&gt;</code> 图标），侧栏联系方式多一个 B 站按钮，和 GitHub 那条走同一套机制；后来两条的图标都换成直链目标站 favicon 的 <code>&lt;img&gt;</code>（GitHub 原来是 <code>&lt;i class=&quot;fab fa-github&quot;&gt;</code>），见 &quot; 侧栏：slogan 改四行、B 站进联系方式 &quot; 一节</td></tr><tr><td><code>themes/mustom/source/asset/lang/{zh-cn,en}.yml</code></td><td>中文 <code>brand.slogan</code> 换成四行（值里直接写 <code>&lt;br&gt;</code>），新增 <code>brand.bilibili</code>（中「通过 B 站关注我」／英「Follow me on bilibili」）；英文 <code>brand.slogan</code> 去掉两头那对 <code>&amp;quot;</code></td></tr><tr><td><code>themes/mustom/source/asset/img/bilibili.ico</code></td><td><strong>已删除</strong>。它确实是 B 站 <code>https://www.bilibili.com/favicon.ico</code> 的原文件（4286 字节、md5 <code>f2d2896c488493e18c1b112cdd9bb1d9</code>，一字节没改），但存进仓库这一步本身是多余的：外链实测 200、<code>image/x-icon</code>、带跨站 <code>Referer</code> 也给，<code>&lt;img&gt;</code> 直接解码，仓库里那份和源站的逐字节相同（删除前 md5 对过）。删掉后 <code>&lt;img src&gt;</code> 直接指向源站</td></tr><tr><td><code>themes/mustom/source/asset/css/_part/brand.styl</code></td><td><code>.p-brand-slogan</code> 行高 <code>3</code>→<code>1.6</code> 并补上下 <code>$gap</code> 内边距（四行必需）；<code>.p-brand-contacts a</code> 换 <code>inline-flex</code> 并写死 <code>2em×2em</code> + <code>vertical-align: middle</code>，否则字体图标和图片图标会错开 4px；新增 <code>.p-brand-contact-img</code> 尺寸（<code>1.2em</code>，图标尺寸只在这里给，浏览器运行时缩放）；新增 <code>:root.night .p-brand-contact-dark{filter: invert(1)}</code>——GitHub 自带的 favicon 是近黑的 <code>#171515</code>，夜间不翻就是看不见</td></tr><tr><td><code>themes/mustom/source/asset/css/_common/layout.styl</code></td><td>抽屉里那份 <code>.p-brand-slogan</code> 行高 <code>3.35</code>→<code>1.6</code>；<code>.m-drawer</code> 桌面块加 <code>overflow-y: auto</code>——它是定高 <code>fixed</code> 且原来 <code>overflow: hidden</code>，卡片变高会把皮肤／设置顶到够不着的地方</td></tr><tr><td><code>source/about/index.md</code></td><td>正文里「B 站空间：<a href="https://space.bilibili.com/675978454">https://space.bilibili.com/675978454</a>」那行删掉，空间链接收到侧栏按钮，与「通过 Github 关注我」同步</td></tr><tr><td><code>themes/mustom/layout/_partial/frame.ejs</code></td><td>字体外链补成两束：原有那条汇文明朝体之后再加一条上图东观体 - 粗体，同样的 <code>media=&quot;print&quot; onload=&quot;this.media='all'&quot;</code> 延迟加载写法 + <code>&lt;noscript&gt;</code> 里两份兜底；再加一行 <code>&lt;link rel=&quot;preconnect&quot;&gt;</code> 提前握域名（见 &quot; 字体：外链回退链 &quot; 一节）。两条都在 <code>CDN_ENABLE</code> 的 <code>else</code> 分支里</td></tr><tr><td><code>themes/mustom/source/asset/css/_common/layout.styl</code></td><td>字体链换成 <code>'Huiwen-mincho', 'STDongGuanTi Bld', Arial, Helvetica, sans-serif</code>（<code>body</code> 和 <code>.markdown-body</code> 两处），删掉 <code>@font-face{Source Han Sans CN}</code> 和底下那行注释掉的重复 <code>src</code>；<code>:root.night</code> 那条 <code>img{filter: brightness(0.8)}</code> 拆成 <code>#live2d-widget</code> 和 <code>img:not([src^=&quot;http&quot;])</code> 两条，外链图片夜间不再被压暗</td></tr><tr><td><code>themes/mustom/source/asset/font/SourceHanSansCN.otf</code></td><td><strong>已删除 </strong>，8,800,680 字节，是这仓库里最大的单个文件；删完全站在主题和站点源码里 <code>grep</code> <code>Source Han</code>／<code>SourceHan</code>／<code> 思源</code> 是 0 命中</td></tr><tr><td><code>source/live/index.md</code></td><td>直播间页，线路表和播放器都在页内</td></tr><tr><td><code>source/shiftnav/</code></td><td><strong>已删除</strong>（见下面 ShiftNav 一节）：整个目录连同 <code>frame.ejs</code> 里 6 行引用一起清掉，只把两条还在生效的 CSS 规则搬进主题</td></tr><tr><td><code>_config.yml</code></td><td>写死一段显式 <code>markdown:</code> 配置；<code>description</code> 从 <code>personal blog</code> 改成 <code>自留地</code>（见 &quot; 站名 &quot; 一节）</td></tr></tbody></table><span id="more"></span><h2 id="看板娘">看板娘</h2><p>用的是 <a href="https://github.com/stevenjoezhang/live2d-widget">https://github.com/stevenjoezhang/live2d-widget</a>（npm 包名是复数 <code>live2d-widgets</code>）。它自 1.0 起不再需要后端 API，模型清单写在 <code>source/live2d/waifu-tips.json</code> 的 <code>models</code> 字段里，我指到 <code>/asset/live2d/umaru/model.json</code>。渲染核心是仓库自带的 <code>dist/live2d.min.js</code>，即 Live2D Cubism 2 Web SDK，所以 umaru 这种 <code>.moc</code> 老模型可以直接用。模型本身来自 <a href="https://github.com/HesperusVenus/live2d">https://github.com/HesperusVenus/live2d</a>，那是个仓库根没有 LICENSE 的个人合集，用的是 pio 那套 <code>live2d_model.json</code> 字段（<code>hit_areas</code>、<code>expressions</code>、<code>sound</code>），widget 一样能读；这个授权缺口记在模型目录的 <code>SOURCE.txt</code> 里。</p><p>想换模型：往 <code>themes/mustom/source/asset/live2d/</code> 放一个新模型目录，再在 <code>waifu-tips.json</code> 的 <code>models</code> 里加一条，<code>paths</code> 指向它的 model json；<code>loader.js</code> 的 <code>tools</code> 数组控制右下角按钮（<code>hitokoto</code> 一言、<code>photo</code> 拍照、<code>info</code> 介绍、<code>quit</code> 关闭）。</p><h3 id="它说 - 晚安 - 是看谁的表">它说 &quot; 晚安 &quot; 是看谁的表</h3><p>widget 里跟时间有关的取值只有一处：<code>new Date()</code> 拿 <code>getHours()</code>，去 <code>waifu-tips.json</code> 的 <code>time</code> 数组里找 <code>hour</code> 区间命中的那条。整个 bundle 里没有 <code>timeZone</code>、没有 <code>Asia/Shanghai</code>、也没有 <code>28800</code> 这类写死的偏移量，<code>loader.js</code> 里同样没传时区参数，所以用的就是访客操作系统给浏览器的本地时间，不存在 &quot; 默认东八区 &quot; 这一说；<code>seasons</code>（节日文案）用的也是本地的 <code>getMonth()+1</code> 和 <code>getDate()</code>，同一个道理。</p><p>要区分两件事：<strong>取值是访客的 </strong>，<strong> 分档是国内作息写的</strong>。<code>time</code> 现在是 <code>6-7</code> 早上好、<code>8-11</code> 上午好、<code>12-13</code> 午餐、<code>14-17</code> 犯困、<code>18-19</code> 傍晚、<code>20-21</code> 晚上好、<code>22-23</code> 晚安＋爱护眼睛、<code>0-5</code> 夜猫子。一个纽约的访客在当地时间 22 点同样会收到 &quot; 早点休息吧，晚安～&quot;，这在语义上没错（他那边的确是夜里），要不要照着访客的白天黑夜重写这套措辞是另一回事，跟时区无关。</p><p>时段问候现在每个页面都出。原来那段取文案的代码外面套了 <code>&quot;/&quot; === location.pathname</code>，只有首页根路径给时段问候、文章页只给 &quot; 欢迎阅读「页面标题」&quot;，这个判断已经拆掉：根路径仍是只出时段问候（和以前一样），非根路径出 &quot; 时段问候 + 欢迎 &quot; 两行，跨站来的再加一行来源问候，顺序是问候在上、标题在下。改动落在 <code>source/live2d/dist/waifu-tips.js</code> 那个 vendor bundle 里：改动前的原文备份在 <code>_tmp/waifu-tips.orig.js</code>，打补丁的是 <code>_tmp/waifu_patch.mjs</code>（做完替换会自查标记串在不在，重跑无害），同一个 bundle 另外导出了 <code>window.waifuShowTip</code> 供 <code>loader.js</code> 复用，其余逻辑没动。</p><h3 id="整点报时">整点报时</h3><p><code>loader.js</code> 在 <code>initWidget()</code> 之后自己再拉一遍 <code>waifu-tips.json</code>，每 30 秒比较 <code>new Date().getHours()</code> 和上一次开口的小时数，换了小时就调 <code>waifuShowTip(报时文案 + 该时段的问候, 6000, 11)</code>。报时文案是 <code>waifu-tips.json</code> 里新增的 <code>message.hourly</code> 数组（<code>现在 {hour} 点了，</code> / <code>到点啦，{hour} 点了，</code>，<code>{hour}</code> 由 loader 替换成数字，不写进 vendor 文件），拼在该小时命中的那条时段问候前面。</p><p>两个刻意的写法：</p><ul><li>用 30 秒轮询而不是 <code>setTimeout</code> 到下一个整点。后台标签页的定时器会被浏览器节流（throttling，浏览器为了省电把后台页面的定时器最低降到每分钟一次甚至更狠），一个长 timeout 在切走的标签页里可能大幅晚点，短轮询被节流到 60 秒也不影响 &quot; 这一小时内报过一次 &quot;。</li><li>气泡节点 <code>#waifu-tips</code> 不存在时直接跳过、且不更新记账的小时数。访客点过 <code>quit</code> 按钮（写 <code>localStorage['waifu-disabled']</code>）后看板娘不再出现，报时也不能把它复活；而 &quot; 气泡比 loader 晚出现 &quot; 的情况（首次挂载慢）留到下一个 tick 照样能报。</li></ul><p>优先级用 11，和首屏那条时段问候同级，所以报时会顶掉 idle／一言那类 9 级的碎话。<code>sessionStorage['waifu-message-priority']</code> 是 widget 自己的优先级账本，同值可以互相覆盖。</p><h3 id="手机端：看板娘缩一半、挪到右下角、横向不再撑破、列表 -500- 字">手机端：看板娘缩一半、挪到右下角、横向不再撑破、列表 500 字</h3><p><code>source/live2d/site.css</code>（我们自己的覆盖表，在 <code>dist/waifu.css</code> 之后加载）加了 <code>@media (max-width: 1023px)</code> 一段：canvas 从 300×300 改成 150×150，气泡宽度 250→180、字号 14→13、<code>min-height</code> 70→44，<code>#waifu</code> 的上移量 <code>translateY</code> 25px→12px。改 CSS 尺寸不影响模型渲染：canvas 的绘制缓冲是 bundle 里写死的 <code>&lt;canvas id=&quot;live2d&quot; width=&quot;800&quot; height=&quot;800&quot;&gt;</code>（800×800），CSS 只决定它在屏幕上占多大，所以缩小是等比降采样、不会糊也不会错位；widget 里唯一读鼠标坐标的是拖拽（<code>offsetX/offsetY</code> 和 <code>clientX</code> 做差算位移，和 canvas 内部分辨率无关），点击摸头那套逻辑这个 bundle 里根本没有（<code>tools</code> 只有一言／拍照／介绍／关闭）。顺带在同一断点把 <code>#waifu-tool</code> 的 <code>opacity</code> 设成 1——原样式靠 <code>#waifu:hover</code> 显形，触屏没有 hover，手机访客原本永远点不到那四个按钮，包括 <code>quit</code>。</p><p>看板娘的位置也改了：<code>dist/waifu.css</code> 里 <code>#waifu</code> 是 <code>position: fixed; left: 0</code>，桌面靠 <code>site.css</code> 的 <code>@media (min-width: 1024px)</code> 覆盖成 <code>left: auto; right: 76px</code>；手机这条覆盖从来没有对应的一段，所以手机一直停在 <strong> 左下角</strong>。现在 ≤1023px 补上 <code>left: auto; right: 0</code>，跟着把 <code>#waifu-tool</code> 从 <code>left: -8px</code> 翻到 <code>right: -8px</code>（tool 仍在看板娘左侧，只是不再贴着屏幕左边）。</p><p>气泡要多加一条 <code>right: 0</code>。<code>#waifu-tips</code> 是 <code>position: absolute</code> 挂在 <code>#waifu</code> 里，原样式只给 <code>margin</code> 不给 <code>left/right</code>，靠静态位置定位——看板娘在左下角时它向右溢出 30px 没人注意，挪到右下角后同样的静态位置就会把 180px 宽的气泡推出屏幕右沿。钉 <code>right: 0</code> 让它贴住看板娘的右边缘（也就是视口右边缘）向左展开。</p><p>实测（1000×700 视口、UA 钉成 iPhone）：<code>#waifu</code> 的 rect 从 <code>left: 835 → right: 985</code>，<code>#waifu-tips</code> 的右沿也是 985、<code>documentElement.scrollWidth 985</code> 等于视口宽，没有溢出；改之前 <code>#waifu</code> 是 <code>left: 0</code>。</p><p>移动端 390×844 实测到的撑破来自代码块：<code>_plugin/highlight.styl</code> 的 <code>$code-block</code> 写了 <code>margin block-gap article-padding * -1</code>（<code>article-padding = $gap * 6</code> = 40px，即左右各负 40px 让深色块出血铺满），<code>.markdown-body pre</code> 和 <code>.highlight</code> 都继承它。父级 <code>.p-post</code> 是 <code>overflow-x: hidden</code>，所以负 margin 的净效果不是 &quot; 铺满 &quot; 而是整块比视口宽 40px、右侧被裁掉，读者看不见也滚不到——<code>body{overflow-x:hidden}</code> 加 viewport 的 <code>user-scalable=no</code> 把这 40px 藏起来了。修法是 ≤1023px 把 <code>margin-left/right</code> 归零、左右内边距从 40px 收到 8px、<code>.highlight pre</code> 改 <code>white-space: pre-wrap</code>、<code>.gutter</code>（行号列）隐藏、表格 <code>width: 100%</code>。文字侧则是 <code>_common/layout.styl</code> 里 <code>.markdown-body table{word-break: keep-all}</code>（“不在词内断行”，中文表格不断词是为了不难看）只在 ≥1024px 保留，≤1023px 给 <code>code/a/td/th/p/li/h1–h6</code> 一律 <code>overflow-wrap: anywhere</code>（长 URL、长英文单词这种没有断点的串允许硬断）。</p><p>实测：<code>.p-post</code>／<code>.p-timeline</code> 的 <code>scrollWidth</code> 从 413 降到 373（<code>clientWidth</code> 373，视口 390，<code>documentElement.scrollWidth</code> 373），<code>figure.highlight</code> 那些 <code>right: 413</code>／<code>right: 495</code> 的条目全部消失；桌面 1366 宽下 <code>scrollWidth</code> 1349、看板娘仍是 300×300、<code>right: 76px</code>，负 margin 出血只在移动端取消，桌面观感不变。剩下唯一的越界元素是通知气泡 <code>.p-notification-message</code>（<code>position: absolute</code> 藏在屏幕右边缘外，靠 transform 滑入，不撑文档宽度），与本次改动无关。</p><p>列表摘要在构建期截断：<code>themes/mustom/scripts/plugin/hexo-generator-restful/lib/generator.js</code> 新加 <code>preview(str, limit)</code>，把 <code>post.excerpt || post.content</code> 剥成纯文本（去标签→解数字实体和命名实体→空白压成单空格），超过 <code>excerpt_chars</code> 就切片再加 <code>......</code>，最后重新转义 <code>&amp; &lt; &gt;</code>。之所以不能对 HTML 串直接切片：切在标签中间会留下不闭合的 <code>&lt;</code>，浏览器一解析就把后面整段吞掉。两个列表调用点（<code>postMap</code> 给 <code>/api/posts.json</code>、<code>postlist</code> 给分类／标签页）都换成它；单篇正文那条（<code>api/posts/&lt;id&gt;.json</code> 的 <code>excerpt</code>，仍是 <code>filterHTMLTags(post.excerpt)</code>）没动，文章页不会被砍。阈值是 <code>_config.yml</code> 里的 <code>excerpt_chars: 500</code>。</p><p>一个必须知道的连带效果：列表数据只有一份，<code>excerpt</code> 字段桌面和手机共用，所以 500 字截断在桌面列表页同样生效（以前桌面列表对没有 <code>&lt;!-- more --&gt;</code> 的文章会把全文塞进摘要，实测有一篇 6922 字符）。要只截手机就得改成运行时按视口截，现在没做。</p><p>实测：<code>/api/posts.json</code> 三条摘要 517／396／512 字符（含转义膨胀），首页渲染出的文本 <code>innerText</code> 去空白后 418／325／444，末条尾巴是 <code>......</code>；<code>/archives/</code> 同一篇也是 418。</p><p>实测记录（本地起 <code>public/</code>，headless Firefox 走 WebDriver BiDi，也就是 WebDriver 标准的双向协议、Firefox 通过 <code>--remote-debugging-port</code> 暴露的那套；脚本 <code>_tmp/live2d_tz.mjs</code>、<code>_tmp/live2d_mock.mjs</code>、<code>_tmp/live2d_nav.mjs</code>、<code>_tmp/waifu_chime.mjs</code>；改 bundle 用 <code>_tmp/waifu_patch.mjs</code>，页面尺寸与溢出扫描用 <code>_tmp/mob.mjs</code>，它按参数决定视口和要走的页面）：</p><ul><li><p>先读浏览器自己的表：<code>Intl.DateTimeFormat().resolvedOptions().timeZone</code> 是 <code>Asia/Taipei</code>，<code>getTimezoneOffset()</code> 是 <code>-480</code>，<code>getHours()</code> 是 22，按 JSON 命中的正是 <code>22-23</code> 那条晚安。也就是说这台机器上看到的 &quot; 晚安 &quot; 是本地 22 点的正常结果，不是被纠偏出来的。</p></li><li><p>想换个时区反证：Windows 上的 Firefox <strong>不读 <code>TZ</code> 环境变量</strong>，带着 <code>TZ=America/New_York</code> 启起来仍然是 <code>Asia/Taipei</code>。要动时区只有改系统时区（不能碰）或者改 <code>getHours()</code> 本身。</p></li><li><p>于是用 BiDi 的 <code>script.addPreloadScript</code>（在每个新 document 创建之前就注入的钩子，<code>global: true</code> 对之后所有导航生效）把 <code>Date.prototype.getHours</code> 钉成定值，其余一概让 widget 自己跑：钉到 9 点，气泡出 &quot; 上午好！工作顺利嘛，不要久坐……“；钉到 3 点，出 &quot; 你是夜猫子呀？这么晚还不睡觉”；同一个 9 点，机器实际时间是 22 点多。选择完全由访客本地小时数决定，代码里没有任何往东八区纠偏的分支。</p></li><li><p>仍然钉 9 点，在页面里 <code>location.href</code> 跳到 <code>/posts/2659281223/</code>：气泡先空几秒，随后出 &quot; 欢迎阅读「建站流程 - takomitkm」&quot;，没有时段问候——那是改动前的记录，当时 <code>&quot;/&quot; === location.pathname</code> 那个根路径判断还在。放开之后重测（<code>_tmp/waifu_chime.mjs</code>，同一个 preload 钉 <code>getHours</code>，跑起来第 12 秒再把钉住的小时数从 10 改成 11）：钉 10 点、路径 <code>/posts/2659281223/</code>，气泡是 &quot; 上午好！工作顺利嘛，不要久坐，多起来走动走动哦！&quot;＋换行＋&quot; 欢迎阅读「建站流程 - takomitkm」&quot; 两行；钉 10 点、路径 <code>/</code>，只有第一行，和以前一致。</p></li><li><p>整点报时同一次跑里验到：12.7 秒把小时数从 10 改成 11，21 秒后（下一个 30 秒轮询 tick）气泡变成 &quot; 现在 11 点了，上午好！工作顺利嘛，不要久坐，多起来走动走动哦！“；首页 <code>/</code> 上是同一句，只是后面不接欢迎行。中间穿插的 &quot; 嗨～快来逗我玩吧！”&quot; 拿小拳拳锤你胸口！&quot; 是 9 级的 idle 碎话，被 11 级的报时顶掉，符合 widget 自己的优先级规则。</p></li><li><p>顺手记一条测试方法：这类 &quot; 改了 <code>Date.prototype</code> 再验 &quot; 的注入，必须等 document 换过来之后才装得上，直接 <code>navigate</code> 完立刻 evaluate 有概率装到旧 document 上、看着像没生效。另外 BiDi 的 <code>browsingContext.navigate</code> 在这个 Firefox 构建里对带路径的 URL（<code>/posts/xxx/</code>）回 <code>invalid argument</code>，改成页面内 <code>location.href</code> 赋值即可绕过，跟站点无关。</p></li></ul><h2 id="列表页的分隔符，和关于页那枚红色 - 偷">列表页的分隔符，和关于页那枚红色 &quot; 偷 &quot;</h2><p>分隔符的字符串写在 <code>_common/dimension.styl</code> 的 <code>$divider</code>：<code>✟⫘⫘⫘⫘⫘⫘⫘⫘✟</code>，两头是 U+271F 粗希腊十字，中间八个 U+2AD8 双匕首运算符。以后要换符号只改这一行，两个消费点自动跟上；中间这八个是默认值，运行时 <code>main.js</code> 会按行宽把它重算成任意个数，见下面 &quot; 分隔符的 ⫘ 个数按行宽算 &quot;。</p><p>首页那条走 <code>_part/recentposts.styl</code>，给 <code>.p-recentpost:not(:first-child)::before</code>，<code>display: table-caption; caption-side: top</code>。用 caption 而不是普通伪元素块，是因为 <code>.p-recentpost</code> 本身就是 <code>display: table</code>，往表里塞一个 <code>display: block</code> 的子盒子会被浏览器套进匿名 table-row/table-cell 里、排版不可控，而 <code>table-caption</code> 是规范允许直接挂在 table 上的盒子，位置由 <code>caption-side</code> 说了算（默认就是 top，写出来是为了以后想挪 bottom 一眼找得到）。这些条目之间原本已经有一条 <code>border-top: 1px solid var(--color-clear)</code>，现在是 &quot; 细线 + 分隔符 &quot; 两层；只想要一层的，删掉那条 border 就行。</p><p>归档／分类／标签走 <code>_part/timeline.styl</code>，给 <code>.p-timeline-row::after</code>，<code>display: block; clear: both</code>——这一行左边是 <code>float: left</code> 的日期列，不 clear 分隔符就会浮到日期旁边去。这里多一层结构：<code>.p-timeline-item</code> 是按年分组的容器，一年里的每篇是一个 <code>.p-timeline-row</code>，所以 &quot; 整列表最后一篇不画 &quot; 要写成 <code>.p-timeline-item:last-child .p-timeline-row:last-child::after{content:none}</code>。跨年那一篇照旧有分隔符：桌面那里下面本来就压着年份大标题，移动端 <code>.p-timeline-head{display:none}</code> 把年份收掉了，不留分隔符两篇会直接粘连。</p><p>两边的颜色都是 <code>color: var(--color-text); opacity: .45</code>。先用的是主题自带的 &quot; 文章结尾 &quot; 色 <code>var(--color-post-ending)</code>，实测发现它在默认和 gray 皮肤里是 <code>#dddddd</code>，压在白底上基本看不见，只有 colorful 那套是 <code>#009999</code>，于是改成前景色加固定透明度，四套皮肤自己适配。</p><p>字形覆盖（这台机器上）：<code>✟</code> 和 <code>⫘</code> 都是真字形，不是豆腐块。判据是拿必定无字形的私有区码点 <code>\uE000</code> 做参照，比较 canvas 上的涂墨像素和前进宽度——<code>✟</code> 384 墨／28.4 宽，<code>⫘</code> 369 墨／51.1 宽，参照 <code>\uE000</code> 是 310 墨／38.9 宽，三个宽度互不相等、墨数也各不相同（对照正值：<code>偷 </code> 648 墨／40 宽，<code>+</code> 135 墨／21.3 宽）。站点字体栈 <code>&quot;Huiwen-mincho&quot;,&quot;Source Han Sans CN&quot;,Arial,Helvetica,sans-serif</code> 本身不含这两个符号，实际由系统符号字体兜底（Windows 上一般是 Segoe UI Symbol）。<strong> 这只核到本机</strong>：macOS／iOS／Android 的 fallback 各不相同，可能有访客看到方框，真出现就把 <code>$divider</code> 换成图片，或者给这两处单独指定 <code>font-family</code>。</p><p>关于页那枚章：<code>source/about/index.md</code> 正文末尾一段页内 <code>&lt;style&gt;</code> 加一个空的 <code>&lt;div class=&quot;about-stamp&quot;&gt;</code>。用页内 <code>&lt;style&gt;</code> 是沿用 <code>/live/</code> 那页已有的做法——页面正文是运行时从 <code>/api/pages/about.json</code> 取回来、由 <code>js/part/page.js</code> 用 <code>innerHTML</code> 注入的，<code>&lt;style&gt;</code> 注进 body 一样生效（只有 <code>&lt;script&gt;</code> 需要那页里那段手动搬运重跑），好处是这个节点只存在于关于页，不用给主题的 <code>page.styl</code> 塞一条只服务一页的选择器。</p><ul><li>图是一个内联 SVG（<code>data:image/svg+xml</code>，<code>&lt;</code>、<code>&gt;</code>、<code>#</code>、空格全部百分号编码，汉字 &quot; 偷 &quot; 写成 <code>%E5%81%B7</code>）：<code>viewBox='0 0 100 100'</code>，外圈是 <code>circle cx=50 cy=50 r=46 stroke=#ff3300 stroke-width=3 fill=none</code>，字用 <code>text</code> 加 <code>text-anchor='middle'</code> 和 <code>dominant-baseline='central'</code> 钉在圆心，<code>font-size=60</code>。</li><li>尺寸挂在 div 上：<code>width: 50%; aspect-ratio: 1/1; background: ... right top/100% 100%</code>。SVG 的 viewBox 是正方形，圈和字一起等比缩放，所以圆的直径恒等于文章宽度的一半，与视口宽窄无关。这也是为什么用背景图而不是 &quot; 字符 + CSS <code>border-radius: 50%</code> 圆圈 &quot; 两件事拼：CSS 的 <code>font-size</code> 没法写成容器宽度的百分比，字就不跟着宽度走。</li><li><code>position: absolute; z-index: -1</code> 要配 <code>.p-page{position: relative; z-index: 0}</code>。负 z-index 的后代画在 &quot; 形成层叠上下文那个元素自己的背景之上、正常内容之下 &quot;，所以章压在正文后面、面板底色前面；父级只给 <code>position: relative</code> 而 <code>z-index: auto</code> 是不构成层叠上下文的，那时负 z-index 会穿到面板底色底下去，什么都看不见。</li><li>&quot;70% 透明 &quot; 写成 <code>opacity: .3</code>，是对整个 div（圈加字）生效，不是给颜色带 alpha；<code>pointer-events: none</code> 保证不挡正文选字。</li></ul><p>实测（本地起 <code>public/</code>，headless Firefox 走 BiDi，脚本 <code>_tmp/divider.mjs</code>，它顺带做字形覆盖检测和越界元素扫描）：桌面 1366×900 下面板 <code>613×1341</code>，章 <code>307×307</code>（50.1%），距面板右沿 24px、顶 40px（正好在 32px 高的 <code>PAGE</code> 标题条下面），<code>bottom - panelBottom = -994</code>，也就是圆的下沿离面板底还差 994px，没被 <code>overflow: hidden</code> 切掉；手机 390×844 下面板 373 宽、章 <code>187×187</code>、仍是 50.1%。分隔符确实占了版面：首页第二个 <code>.p-recentpost</code> 的标题距该条目顶边 63px（没有分隔符时就是 32px 内边距），<code>.p-timeline-row</code> 的底比它内容列的底多出 30px。移动端两个列表页 <code>scrollWidth</code> 373 等于 <code>clientWidth</code>，没有新增横向溢出；桌面 1349，越界的仍然只有 <code>.p-notification-message</code>（通知气泡常驻屏幕右沿外，与本次无关）。截图这个构建依旧拿不到（<code>browsingContext.captureScreenshot</code> 恒回 <code>invalid argument</code>），上面全是计算样式和几何量，最终观感得在线上自己看。</p><h3 id="分隔符的 -⫘- 个数按行宽算">分隔符的 ⫘ 个数按行宽算</h3><p>CSS 的 <code>content</code> 是个静态串，自己不会按行宽增减，所以把串交给 JS：<code>fitDividers()</code> 算出该铺几个 ⫘，写成 <code>.m-content</code> 的行内自定义属性 <code>--divider</code>。自定义属性沿 DOM 继承，所以列表行自己的 <code>::before</code>/<code>::after</code> 都拿得到，而样式里仍留一份静态兜底：<code>_common/dimension.styl</code> 加 <code>$divider_value = var(--divider, $divider)</code>，两个消费点的 <code>content</code> 都换成这个变量。JS 没跑、跑不出宽度（比如访客禁脚本）就退回样式里那 8 个。</p><ul><li>变量写法只能是 <code>var(--divider, $divider)</code>。写成 <code>convert('var(--divider,' + $divider + ')')</code> 编译出来是 <code>content: var(--divider, ✟⫘…✟)</code>，字符串参数的引号被 <code>convert</code> 吞了，fallback 不合法、整条声明失效，页面上一个分隔符都不剩。Stylus 的函数调用形式会原样保留参数里的引号。</li><li>两边的 <code>content</code> 都补了 <code>white-space: nowrap</code>。个数变多以后不让它在行中间断行，断一次就上下两排符号，比原来的静态串难看。</li><li>可用宽度 <code>room</code> = 父盒子的 <code>clientWidth</code> − 父左右 padding − 行自身左右 margin 和 padding。不能拿行自己的宽度当可用宽度：<code>.p-recentpost</code> 是 <code>display: table</code>，分隔符是它的 <code>table-caption</code>，caption 的宽度会参与表格的 shrink-to-fit 计算，用 <code>row</code> 的宽度反推就成了 &quot; 加的越多行越宽、行越宽就越能加 &quot; 的自激循环。</li><li>量文本用一次性离屏 <code>span</code>，把伪元素那份 computed 的 <code>font-family/size/weight/style/letter-spacing</code> 全抄过来再量。分隔符带 <code>letter-spacing: .15em</code>，不抄这一项量出来偏窄，算出的个数会多一两个、直接溢出。</li><li>取哪一个行来量是个坑：<code>.m-content</code> 里常驻一份没填内容的 <code>.p-recentpost</code> 模板（它父容器 <code>clientWidth</code> 是 0，算出来 room 是 −64），而第一条 <code>.p-recentpost</code> 命中不了 <code>:not(:first-child)</code>，从它身上读 <code>::before</code> 拿到的是没套样式的默认值（16px、letter-spacing normal）。所以遍历 <code>.p-recentpost:not(:first-child), .p-timeline-row</code>，取第一个 room &gt; 0 的行；一个都取不到就原样不动。</li><li>个数：<code>n = floor(room / 单个⫘宽) − 2</code>（两头各减一个 ✟ 的位），再两头各微调一次到不能再多、不能再少。写之前和现值比一次，一样就不写，否则 &quot; 写属性→宽度没变→observer 再触发 &quot; 会来回刷。</li><li>监听挂在 <code>.m-content</code> 上的 <code>ResizeObserver</code>，不是 <code>window.resize</code>：pjax 换页时 <code>.m-content</code> 这个壳不重建、只换 <code>innerHTML</code>，抽屉和侧栏改变正文列宽度时也不会触发 <code>window.resize</code>。调用点在 <code>final_load()</code> 的完成块里，紧跟 <code>biliplayer.ready()</code>。</li></ul><p>实测（本地 <code>public/</code> + headless Firefox BiDi，脚本 <code>_tmp/roundE.mjs</code>）：桌面 1366×900 首页 room 549、铺到 27 个 ⫘、文本实测 548、余量 1px，单个 ⫘ 含字距 19px；手机 375×780 首页 room 342、16 个、余 8px，归档页 room 358、17 个、余 5px；两端 <code>document.documentElement.scrollWidth</code> 358 等于视口宽，没有新增横向溢出。</p><h2 id="正文标题序号为什么全是 -0-x">正文标题序号为什么全是 0.x</h2><p>症状：目录一侧的号是对的（1、2、2.1），正文里每个标题前面的号却是 <code>0.1</code>、<code>0.0.1</code>。</p><p>原因是原来那套编号整个写在 CSS 里。<code>_part/post.styl</code> 用一个 <code>headingContent($num)</code> 把 <code>h2</code> 的 <code>content</code> 拼成 <code>counter(h1) &quot;.&quot; counter(h2) &quot;. &quot;</code>、<code>h3</code> 拼成 <code>counter(h1) &quot;.&quot; counter(h2) &quot;.&quot; counter(h3) &quot;. &quot;</code>，再靠 <code>h{$num}{counter-reset: h(num+1)}</code> 逐级归零，<code>.markdown-body{counter-reset: h1}</code> 起头。而正文里从来没有 <code>&lt;h1&gt;</code>（Markdown 的 <code>##</code> 起头，页面标题另有 <code>.p-post-title</code>），<code>counter(h1)</code> 就永远是 0，每一位前面都多了个 0。</p><p>但把 h1 那一位去掉也只是看起来对：CSS 计数器只能按标签层级累加，没法按 &quot; 这篇文档自己的第一层 &quot; 归一化。目录走的是 <code>scripts/plugin/toc.js</code>，它里面有个 <code>firstLevel</code>——第一个遇到的标题是 h2，就把 h2 当第一层，编号从 1 起，下一级写成 2.1。这个 <code>firstLevel</code> 是解析整篇之后才知道的值，CSS 拿不到，所以正文和目录是两套编号，怎么调样式都会在某些文章上错。</p><p>改法是编号搬到构建期，且只复用目录那一套逻辑：新增 <code>themes/mustom/scripts/plugin/heading-numbers.js</code>，先调 <code>hexo-util</code> 的 <code>tocObj(html, {min_depth, max_depth})</code> 拿到和目录 <strong> 完全同一条 </strong> 标题序列（同一个解析器、同一份层级定义），用 <code>toc.js</code> 里同款的 <code>lastNumber[]</code> + <code>firstLevel</code> 推进算出每条的串，再按文档顺序用正则 <code>&lt;h([1-6])(?=[\s&gt;])</code> 逐个贴 <code>data-num=&quot;2.1&quot;</code>。<code>tocObj</code> 和这个正则都按文档顺序遍历 h1–h6，所以两者一一对照。</p><ul><li>落点在 <code>hexo-generator-restful/lib/generator.js</code> 里单篇那条：<code>var numbered = headingNumbers(post.content)</code>，<code>content</code> 和 <code>toc</code> 都改用 <code>numbered</code>。之所以改 JSON 就够，是因为正文和目录只存在于 <code>/api/posts/&lt;abbrlink&gt;.json</code>（主题配置 <code>posts_props.content: false</code>，<code>layout/post.ejs</code> 里的 <code>page.content</code> 只用来拼 meta description），静态 HTML 里没有可编号的正文标题。一个文件里同生，不可能再各算各的。</li><li>自保护：贴标签的次数和目录条数不相等就 <code>console.warn</code> 并把原始 HTML 原样返回。宁可整篇没号，也不要号错。</li><li>样式端 <code>h{$num}[data-num]:before{content: attr(data-num) &quot;. &quot;}</code>——选择器带 <code>[data-num]</code>，退回未编号时不会留下只有 &quot;. &quot; 的空号。原来给 <code>.markdown-body</code> 的 <code>counter-reset: h1</code> 一起删了。</li></ul><p>实测：《建站流程》这篇 37 个标题全部带号、0 个漏，正文前五个是 <code>1</code>、<code>2</code>、<code>2.1</code>、<code>2.2</code>、<code>2.3</code>，同一页 <code>.toc-number</code> 前五个是 <code>1.</code>、<code>2.</code>、<code>2.1.</code>、<code>2.2.</code>、<code>2.3.</code>（目录多带一个尾点，去掉尾点后逐字相同）。</p><h2 id="关于页那三处算出来的东西">关于页那三处算出来的东西</h2><p>她留的三条括号说明换成了运行时计算，全在 <code>source/about/index.md</code> 正文末尾那段页内 <code>&lt;script&gt;</code> 里（这脚本靠 <code>js/part/page.js</code> 那段 &quot; 把 <code>innerHTML</code> 里的 <code>&lt;script&gt;</code> 节点重建再 append&quot; 才会执行，上一轮记过）。</p><ul><li><code>🐣 &lt;span id=&quot;abBorn&quot;&gt;</code>：<code>new Date()</code> 取访客设备本地时间，<code>setFullYear(y − 17)</code>，输出 <code>2009 年 10 月 3 日</code>。取的是访客本地表，和看板娘时段问候同一口径，别改时区。</li><li><code>身高：&lt;span id=&quot;abHeight&quot;&gt;cm</code>：<code>160 + now.getHours()</code>。</li><li>两条装饰线（<code>‧*˚̩͙*‧₊̊…</code> 和 <code>°. ☪︎* 。.:* ・° ✰…</code>）：纹样存在元素的 <code>data-fill</code> 属性里，JS 用离屏探针量出纹样一组的宽度 <code>unit</code>，按段落所在列的 <code>clientWidth</code> 平铺 <code>repeat(ceil(room/unit) + 1)</code> 组，CSS <code>.about-rule{display:inline-block;max-width:100%;white-space:nowrap;overflow:hidden}</code> 负责 &quot; 铺满之后正好截断 &quot;。<ul><li>纹样 <strong> 不能 </strong> 直接写在正文里：<code>*</code> 成对出现会被 Markdown 当成强调，构建出来的 JSON 里变成 <code>‧&lt;em&gt;˚̩͙&lt;/em&gt;…</code>，星号消失、还白送一个斜体。属性值不走 Markdown 解析，所以只能待在 <code>data-fill=&quot;…&quot;</code> 里（实测构建产物 <code>data-fill</code> 内 <code>&lt;em&gt;</code> 出现 0 次）。</li><li>探针的字体从元素自己的 computed 样式抄，否则纹样里那些组合音标字符（<code>˚̩͙</code>、<code>₊̊</code>）宽度差得多。</li><li>重算靠 <code>ResizeObserver</code> 挂在这两条所在段落的父节点上，<code>ResizeObserver</code> 不可用才退回 <code>window.addEventListener('resize')</code>。</li></ul></li></ul><p>实测：1366 下两条纹样盒宽 517、正好等于所在列宽 517，<code>scrollWidth</code> 1072 / 997（超出部分被 <code>overflow:hidden</code> 切掉，这就是 &quot; 按需截断 &quot;）；375 下盒宽 342、<code>scrollWidth</code> 1072 / 748。<code>#abBorn</code> 和 <code>#abHeight</code> 与浏览器自己算的期望值一致（跑探针那轮：<code>2009 年 10 月 3 日</code>、<code>167</code>＝160+7）。</p><h2 id="slogan- 换成 -16- 字长句之后">slogan 换成 16 字长句之后</h2><p><code>brand.slogan</code> 现在是道德经那句（中文 16 字）和西语那句（35 字符），两头各一个引号。<code>.p-brand</code> 的内容宽只有 256px（<code>$drawer_width − $gap*8</code>），而 16px 下中文那句单行要 300px、西语那句 251px；<code>.p-brand</code> 带 <code>overflow: hidden</code>、<code>.p-brand-slogan</code> 原来带 <code>white-space: nowrap</code>，中文那句会被裁掉最后两三个字，西语那句只剩 5px 余量，换个字体就一起裁。抽屉里那份更窄：0.7em（11.2px）下可用宽只有 175px（<code>.p-brand-detail{padding-left: 5em}</code> 再叠 <code>padding-left: 1.75em</code>），两句都要 200px 出头。</p><p>改法是去掉 <code>.p-brand-slogan</code> 的 <code>white-space: nowrap</code> 放行内换行，<code>line-height: 3</code> 没动。没有选缩字号：桌面 <code>.p-brand-name</code> 是 1.5em，slogan 再往小缩就没法读；也没有做 JS 自适应字数：换行是 CSS 能白拿的，多出来的那点高度在可以滚动的侧栏里无害。</p><p>实测（同上面的 <code>roundE.mjs</code>，探针里直接把两种语言的串都塞进去量）：1366 可用宽 256，西语 1 行不裁、中文 2 行不裁；375 抽屉可用宽 175，西语 2 行、中文 2 行，<code>scrollWidth</code> 都等于 <code>clientWidth</code>，即 <code>realClip: false</code>。夜间／彩色皮肤只换颜色，版式不受影响。</p><h2 id="侧栏：slogan- 改四行、B- 站进联系方式">侧栏：slogan 改四行、B 站进联系方式</h2><p>上一节那个 &quot; 靠内联换行自动折 &quot; 的版本被推翻了：中文 slogan 现在按四行断——</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">至虛極也 </span><br><span class="line"> 守情裻也 </span><br><span class="line"> 萬物旁作 </span><br><span class="line"> 吾以觀其復也</span><br></pre></td></tr></table></figure><p>断行的方式是在 <code>zh-cn.yml</code> 的 <code>brand.slogan</code> 值里直接写 <code>&lt;br&gt;</code>。这不用改模板也不用改 <code>lang.js</code>：<code>lang.js</code> 对 <code>[data-lang]</code> 的写法是一条优先级链——带 <code>data-lang-titled</code> 的写 <code>title</code> 属性、带 <code>data-lang-placeholdered</code> 的写 <code>placeholder</code>，<strong>其余才走 <code>innerHTML = '&lt;span&gt;' + text + '&lt;/span&gt;'</code></strong>。slogan 那个节点走的是 innerHTML 那支，所以值里的 HTML 标签会被当标签处理（上一轮的 <code>&amp;quot;</code> 能解成真引号是同一件事的另一面）；联系方式那两个 <code>&lt;a&gt;</code> 带 <code>data-lang-titled</code>，走的是 <code>title</code> 那支，所以文案只会进鼠标提示，不会把里面的图标覆盖掉。代价是 innerHTML 这支不转义，所以 <code>lang/*.yml</code> 里的文案只能来自构建期源，不能掺任何访客可控的文本——本站不存在这种掺法。</p><p>英文那句 <code>y emprendo mi andar a una ruta real</code> 保持单行，不断句，但两头那对引号去掉了（值里不再有 <code>&amp;quot;</code>）。</p><h3 id="行高必须一起改，不然卡片会顶爆侧栏">行高必须一起改，不然卡片会顶爆侧栏</h3><p><code>.p-brand-slogan</code> 原来 <code>line-height: 3</code>。那是单行时代的留白做法——不是排版需求，是给一行字上下撑出呼吸空间。四行之后它变成 4×48=192px，实测把整张 <code>.p-brand</code> 从 438 顶到 515。改成 <code>line-height: 1.6</code> 再加 <code>padding: $gap 0</code>（上下各 8px）把原来那点留白补回来：单行仍占 25.6px，四行是 4×25.6+16=118px，和实测的 118 对得上。抽屉里那份同步从 <code>line-height: 3.35</code> 改 1.6。</p><h3 id="桌面侧栏原来是不许滚的定高栏，这是这一轮真正的坑">桌面侧栏原来是不许滚的定高栏，这是这一轮真正的坑</h3><p><code>.m-drawer</code> 在 ≥1024px 是 <code>position: fixed</code> + 高度 <code>calc(100vh - 3em - $progress_height)</code> + <code>overflow: hidden</code>，也就是一个高度锁死、且明令不滚动的栏。卡片一高，排在它后面的 <code>.p-skin</code>（实测 904–1003）和 <code>.p-settings</code>（1035–1275）就整体落到 847px 的栏外。注意这不是 &quot; 要往下滚一点才能看到 &quot;，而是 <strong> 没有任何滚动入口，永远点不到</strong>——换肤和设置两个按钮直接消失。所以给它加了 <code>overflow-y: auto</code>（横向仍 <code>hidden</code>，不破坏原来的截断行为）。实测加完之后 <code>clientHeight 847 / scrollHeight 1254</code>，把 <code>scrollTop</code> 写成 300 能读回 300。</p><p>判断这类问题不能只比 <code>getBoundingClientRect().top</code> 和视口高，得看这个祖先的 <code>overflow</code> 是什么、<code>scrollHeight</code> 是否大于 <code>clientHeight</code>，再把 <code>scrollTop</code> 真写一遍读回来。</p><h3 id="B- 站按钮：走主题现成的 -contact- 机制，不新写模板">B 站按钮：走主题现成的 contact 机制，不新写模板</h3><p>主题里有 <code>contact: {&lt;key&gt;: {url, icon} }</code>，渲染成 <code>&lt;a href target=&quot;_blank&quot; data-lang=&quot;brand.&lt;key&gt;&quot; data-lang-titled&gt;icon&lt;/a&gt;</code>。GitHub 那条就是这么来的，所以 B 站只需要在 <code>_config.yml</code> 加一个 <code>bilibili</code> 键、在两份 lang 里加 <code>brand.bilibili</code> 标题，模板一行不用碰，将来加第三个联系方式同理。</p><p>图标用目标网页自带的。仓库里 bundled 的 Font Awesome 5.12 没有 bilibili 品牌图标（<code>grep -c bilibili</code> 在它的 css 里是 0），<code>fab</code> 那一套里指不出一个真图标，所以取 B 站自己的 <code>https://www.bilibili.com/favicon.ico</code>。空间页 head 里的 <code>apple-touch-icon</code> 也指向同一个图案，所以这就是站点自己的标识。另外下过它的 512×512 apple-touch 磁贴（粉渐变加 &quot;WEB&quot; 缎带那版），和侧栏另外两个单色小图标放一起很跳，看过之后删掉了。</p><p><strong>这个 ico 是原文件，一字节没改</strong>（4286 字节，md5 <code>f2d2896c488493e18c1b112cdd9bb1d9</code>）。中间一版我把它转成了 PNG（477 字节）再放进来，那是多余的一步：<code>&lt;img&gt;</code> 能直接解码 ICO（实测 headless Firefox 里 <code>naturalWidth=32</code>、<code>complete=true</code>），转码没有换来任何好处——逐像素比过，ico 和那张 PNG 一模一样（1024 个像素里 444 个不透明，颜色全是同一个 <code>#00A1D6</code>，亮度 131／255），ICO 里也只有一帧 32×32／32bpp，不存在 &quot; 多尺寸可选 &quot; 的差别。</p><p>上面这段 &quot; 把原文件存一份进仓库 &quot; 的做法在同一天就被推翻了：现在两条联系方式的图标都 <strong> 直接外链目标站点自己的 favicon</strong>，仓库里不留副本。理由不是我认为热链有什么先验优势，而是 &quot; 必须把每一份第三方资源都搬进仓库 &quot; 这条口径本身不要了——图标只是一个指向外部站点的装饰，跟着那个链接走才说得通。推翻之后重做的部分（GitHub 图标、夜间可见性、Service Worker 下的表现、离线后果）在下一节。</p><p><code>&lt;img&gt;</code> 图标顺带避开一个换肤问题：<code>skin.js</code> 的 <code>colorIcons</code> 只挑 <code>i.fas</code> / <code>.fab</code> / <code>.far</code> 去轮转颜色，图片不在选择器里，所以 colorful 皮肤下它自动保持原色，不需要像 GitHub 那样专门写排除项。它自带的这个蓝色（亮度 131／255）在四套皮肤上都看得见，既不会在夜间底色上糊成一片，也不会在白底上变浅——它是自带颜色的图案，不吃 <code>color</code>。</p><p>两个图标要真正在同一行，得写死盒子的宽高。这里踩了两个坑，第一个是自己造的：新声明插进原有 <code>a</code> 块后，下面那条 <code>display: inline-block</code> 又赋值了一次，把 <code>inline-flex</code> 覆盖掉，B 站图标偏 5px；合并成一条声明后解决。第二个是 CSS 的规则本身：<code>inline-flex</code> 是 inline-level 盒子，默认按 <strong> 基线 </strong> 对齐，而字体图标的基线是字形基线、图片是替换元素、基线取底边；两个盒子又没有显式高度，一个被内容撑到 20px、一个 24px，于是中心差 4px（实测 GitHub 盒 481–501、B 站盒 475–499）。这看起来就是 &quot; 两个图标不在同一行 &quot;。改成 <code>width: 2em; height: 2em; vertical-align: middle</code>，两个盒子都成了同一行的 40×40，各自的图标再靠 <code>align-items: center</code> 居中，中心差归 0。hover 的那块圆角底色也因此两个都是 40×40，不再一个大一个小。</p><h3 id="图标一律外链目标站点自带的，GitHub- 那条也一起换了">图标一律外链目标站点自带的，GitHub 那条也一起换了</h3><p><code>_config.yml</code> 里两条联系方式的 <code>&lt;img src&gt;</code> 现在是 <code>https://github.com/favicon.ico</code> 和 <code>https://www.bilibili.com/favicon.ico</code>。显示尺寸只在 <code>.p-brand-contact-img</code> 里写 <code>1.2em</code>，由浏览器运行时缩放，不再预先转码或裁成固定像素；仓库里那份 <code>bilibili.ico</code> 删了。</p><p>换成图片之后有个副作用必须处理：GitHub 原来是 <code>&lt;i class=&quot;fab fa-github&quot;&gt;</code>，吃 <code>color: var(--color-text)</code>，四套皮肤下自动就是 &quot; 当前文字色 &quot;；图片没有这回事，而 GitHub 自己的 favicon 是 <strong> 近黑的</strong>——32×32 那帧里不透明像素全是同一个 <code>#171515</code>（简单亮度 21／255）。夜间皮肤那块底色实测 <code>rgba(0,0,0,0.8)</code>，直接摆上去看不见。这个不是推测：<code>:root.night</code> 下不加工时，图标那 48×48 个设备像素里与背景差得开（ΔL&gt;0.02）的有 <strong>0 个</strong>，对比度 1.15∶1。主题本来那条 <code>:root.night img{filter: brightness(0.8)}</code>（<code>layout.styl</code>）对近黑图案只会更黑。于是单独加 <code>:root.night .p-brand-contact-dark{filter: invert(1)}</code>——<code>invert</code> 只改 RGB 不动 alpha，实心近黑翻成接近白，实测对比度 <strong>17.39∶1</strong>、差得开的像素 1157／2304（对照组是同一次探针里用 <code>!important</code> 把这条规则关掉）。B 站是自带颜色的图案，不进这条规则，当时它夜间实测 4.73∶1（<code>brightness(0.8)</code> 压完的结果），比 GitHub 弱——这一串数字是 2026-10-03 重算过的，第一次跑的时候相对亮度公式的系数写错了，见 &quot; 夜间模式不再压暗任何外链图片 &quot; 一节末尾的更正；同一条全局规则在下一节里被改成了豁免外链。</p><p>外链的两条后果，都实测过：</p><ul><li>跨站取这两个文件不需要源站配合：带 <code>Referer: https://takomitkm.github.io/</code> 和 <code>Origin</code> 头，直连与 7897 代理都是 <code>200 image/x-icon</code>，字节数 6518（GitHub）和 4286（B 站），且同一路径两种走法字节一致；B 站那份与刚删掉的仓库文件 md5 相同（<code>f2d2896c488493e18c1b112cdd9bb1d9</code>）——没有防盗链。</li><li>这两个外链现在进 PWA 缓存了。<strong>先更正 </strong>：上一版这里写的是 &quot;sw-toolbox 的 cache 里 <code>favicon.ico</code> 一条也没有，所以它们走网络 &quot;，那是当时路由表里没有能命中它们的条目所致，不是 Service Worker 存不了跨站资源——这轮给 <code>_config.yml</code> 加了缓存路由之后重测，同一个 profile 连开 10 次，每次快照里 <code>favicon.ico</code> 都是 <strong>2 条</strong>（<code>https://github.com/favicon.ico</code>、<code>https://www.bilibili.com/favicon.ico</code>），<code>github.com</code> 这个 host 下 2 条（favicon + 头像 <code>takomitkm.png?size=256</code>），<code>www.bilibili.com</code> 1 条。<strong> 这里还要再更正一次我自己的归因 </strong>：上一段写成 &quot; 命中它们的是兜底的按扩展名那条，因为名单里带 <code>ico</code>&quot;，那条路由在 2026-10-04 的重新分类里已经整体删掉了，favicon 的 2 条实测在换表之后仍然成立（<code>_tmp/roundY.mjs</code>）。真正接住这两个外链的是第一条路由——它的 pattern 是 <code>\/favicon\.ico(\?.*)?$</code>，<strong> 结尾锚定、开头不锚域名</strong>，所以任何站点的 <code>/favicon.ico</code> 都落进来，站内那个和两个外链都是；把它排在 <code>bilibili\.com</code> 那条 <code>networkOnly</code> 前面，外链图标才不会被后者按域名抢走。策略是 <code>fastest</code>，所以每次开页仍会发一次网络检查更新，离线可见性因此变成 &quot; 缓存优先 &quot;，不再只靠浏览器自己的 HTTP 缓存。</li></ul><p>两条不顺手但留下的记录：<code>skin.js:61</code> 的 <code>exclude: 'i.fa-github'</code>（防止 GitHub 图标被 colorful 换肤轮转颜色）从此选不到任何节点，因为全站 <code>&lt;i class=&quot;fab fa-github&quot;&gt;</code> 只有这一处——这轮把它连同 <code>colorIcons.run</code> 里那句 <code>if (i.matches(that.exclude)) return;</code> 一起删了，留着是一段永远不会成立的判断。另外 ICO 是多帧容器，GitHub 那个有 16×16 和 32×32 两帧，<code>&lt;img&gt;</code> 实测拿到的是 32 那帧，而 dpr 2 的手机上 24 CSS px 要 48 设备像素，是放大出来的，边缘比字体图标糊——源站只给到 32，这是外链路线本身的代价。</p><h3 id="关于页那行空间链接删了">关于页那行空间链接删了</h3><p><code>source/about/index.md</code> 里「B 站空间：<a href="https://space.bilibili.com/675978454">https://space.bilibili.com/675978454</a>」整行去掉，入口换成侧栏这个按钮，和 GitHub 那条同级同机制。链接本身现在只在 <code>_config.yml</code> 的 <code>contact.bilibili.url</code> 一处。</p><h3 id="实测">实测</h3><p>探针（<code>_tmp/roundG.mjs</code> 量 slogan 和卡片，<code>_tmp/roundH.mjs</code> 专量联系方式那一行；两个都会把中英文两种 slogan 分别塞进卡片量，因为 headless Firefox 的界面语言是 en-US，线上默认读到的是英文那条）：</p><ul><li>1366：中文四行行宽 64／64／64／96，可用宽 256，<code>clip: false</code>，<code>.p-brand</code> 高 515（西语单行时 438）；</li><li>375 抽屉：四行行宽 45／45／45／67，字号 11.2px、行高 17.93px，<code>clip: false</code>，卡片高 233（西语 197）；</li><li>联系方式那一行（修基线之前）：GitHub 盒 <code>y 481 h 20</code>、B 站盒 <code>y 475 h 24</code>，中心差 4px；修完之后两个盒都是 <code>y 474 h 40</code>，图标分别是 <code>25×20</code> 和 <code>24×24</code>（原图 32×32 缩到 1.2em），各自中心差 0；<code>.ico</code> 在 <code>&lt;img&gt;</code> 里 <code>complete=true</code>、<code>naturalWidth=32</code>；375／dpr2 下同样两个盒都是 <code>y 148 h 40</code>；</li><li>桌面抽屉 <code>overflow-y: auto</code> 生效，<code>scrollMove: 300</code>。</li></ul><p>换成外链之后重跑（<code>_tmp/roundH.mjs</code> 量布局，<code>_tmp/roundI.mjs</code> 截 <code>:root.night</code> 的图按 WCAG 相对亮度逐像素算对比度，<code>_tmp/roundJ.mjs</code> 连开两次验 Service Worker 接管下的加载与缓存）：</p><ul><li>1366／dpr1 与 375／dpr2 两档，两个联系方式都是 <code>icon=img</code>、<code>24×24</code>、盒内中心差 <code>0</code>、<code>imgOk=true</code>、<code>natural=32x32</code>；盒子仍是同一行的 <code>40×40</code>（<code>y 474 h 40</code> 和 <code>y 148 h 40</code>），行没有因为换图标而错位；</li><li><code>:root.night</code> 下 GitHub 计算样式 <code>filter: invert(1)</code>，最亮 5% 像素相对亮度 L=0.819、对底色 17.39∶1；把这条规则用 <code>!important</code> 关掉作对照，同一位置 L=0.008、1.15∶1、可分辨像素 0 个；B 站当时夜间是 <code>brightness(0.8)</code>（主题那条全局规则），L=0.186、4.73∶1；这三个数是重算值，第一次那版系数写错了（见 &quot; 夜间模式不再压暗任何外链图片 &quot; 一节末尾）；</li><li>SW 接管的那一次加载里，两个外链 <code>&lt;img&gt;</code> 依然 <code>complete=true</code>，而 sw-toolbox 的 cache 中只有 <code>takomitkm.png?size=256</code>，没有任何 <code>favicon.ico</code>。</li></ul><p>数行数有个坑：<code>Range.getClientRects()</code> 会被 <code>&lt;br&gt;</code> 切成多段，四行返回 8 个 rect，所以不能数 rect 个数，要按 <code>top</code> 去重或者用 <code>height / lineHeight</code> 反推。</p><h2 id="字体：改回逐字回退，侧栏另指定一款">字体：改回逐字回退，侧栏另指定一款</h2><p>上一轮立的规矩是 &quot; 整页只停在一档，缺一个字整页换档 &quot;（<code>js/part/fonttier.js</code> + <code>:root.font-tier-N</code> + <code>--body-font</code>）。这轮把整套删了，回到 CSS Fonts 默认的 <strong> 逐字回退 </strong>（per-character fallback：<code>font-family</code> 写一列候选，浏览器对每个字符取第一个覆盖它码位的字体，一款缺某个字时只有那个字落到别款）。删的理由不是嫌麻烦，是这条规矩在本站<strong> 满足不了 </strong>：slogan 里的 <code> 裻</code> U+88FB、列表分隔符里的 <code>⫘</code> U+2AD8 和 <code>✟</code> U+271F，四款外链字体声明的 <code>unicode-range</code> 里一个都没有（下表逐款核过）。&quot; 只要有一个字没有就整页降档 &quot; 因此每趟都一路降到末档，全站永远用不上汇文——判法没写错，是这个站点的字集不让它成立。</p><p>现在的链就两条：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">body, .markdown-body &#123; font-family: &#x27;Huiwen-mincho&#x27;, &#x27;STDongGuanTi Bld&#x27;, &#x27;Noto Sans SC&#x27;, Arial, Helvetica, sans-serif &#125;</span><br><span class="line">.m-drawer &#123; font-family: &#x27;Source Han Serif CN VF&#x27;, &#x27;Huiwen-mincho&#x27;, &#x27;STDongGuanTi Bld&#x27;, &#x27;Noto Sans SC&#x27;, Arial, Helvetica, sans-serif &#125;</span><br></pre></td></tr></table></figure><p>Stylus 侧只剩 <code>dimension.styl</code> 里 <code>$font_body</code> / <code>$font_drawer</code> 两个变量；<code>layout.styl</code> 的 <code>:root</code> 四条 <code>--body-font</code>、<code>frame.ejs</code> 里读 <code>localStorage['mustom-fonttier']</code> 的那段同步内联脚本、<code>main.js</code> 的 <code>fonttier.watch()</code> 一起删净，<code>js/part/fonttier.js</code> 移出仓库（归档在 <code>_tmp/fonttier.v3.archived.js</code>，不再进构建）。链尾的 <code>Arial, Helvetica, sans-serif</code> 本站不注册任何东西，<code>sans-serif</code> 是 CSS 的通用族关键字，等于 &quot; 这台设备说了算 &quot;。</p><p>侧栏另起一款是因为 <code>裻</code>：它不在汇文、上图、思源黑任何一款的码位表里，逐字回退会一路掉到访客系统字体，那一块 slogan 的字面就和整站不是一套。挑法是把托管方 <code>https://cn-font.claude-code-best.win/index.json</code>（26,510 字节，80 个包的目录）里 CJK 覆盖宽的几个逐个拉 <code>result.css</code>、解析每条 <code>@font-face</code> 的 <code>unicode-range</code>，拿本站文字去撞：</p><table><thead><tr><th>候选</th><th>注册的 family</th><th>切片</th><th>声明码位</th><th>侧栏固定文案（含 slogan 十六字）</th><th>站点生僻字集仍缺</th></tr></thead><tbody><tr><td>思源宋体 <code>syst</code></td><td><code>Source Han Serif CN VF</code></td><td>382</td><td>30,898</td><td>全覆盖，含 <code>裻</code></td><td><code>₇ ₊ ☪ ⚖ ✟ ✰ ❎ ⫘</code></td></tr><tr><td>小赖体 <code>xiaolai</code></td><td><code>Xiaolai SC</code></td><td>365</td><td>44,246</td><td>全覆盖，含 <code>裻</code></td><td><code>⚖ ❎ ⫘</code></td></tr><tr><td>霞鹜文楷 <code>lxgwwenkai</code></td><td><code>LXGW WenKai</code></td><td>269</td><td>26,445</td><td>全覆盖，含 <code>裻</code></td><td><code>₇ ₊ ☪ ⚖ ✟ ✰ ❎ ⫘</code></td></tr></tbody></table><p>选思源宋体：字面是宋体，和正文的汇文明朝体同一路（另两款一个手写体、一个楷意，压在侧栏会和正文打架）；托管方和汇文／上图同一个域，那条 <code>preconnect</code> 已经在了；代价是它比小赖少 5 个生僻字，而那 5 个都不在侧栏、都在正文，正文本来就走整条链。侧栏的下载量实测是首页 8 片、167,556 字节；单片最贵的是 <code>裻</code> 那片 <code>a653112064520a45104a506d465aee7c.woff2</code>，63,724 字节、<code>font/woff2</code>。</p><p>判 &quot; 这款字体有没有这个字 &quot; 用的是静态 <code>unicode-range</code> 分析：<code>@font-face</code> 里声明的码位段就是这道字体能被浏览器选中的唯一范围，不在段里的码位浏览器根本不会问它——这是 CSS Fonts 的规范行为，不依赖网络、不依赖浏览器实测，结论确定。</p><h2 id="换档机制作废前的那版实测，一并留着">换档机制作废前的那版实测，一并留着</h2><p><code>js/part/fonttier.js</code> 写到 v3 时跑过的三场景，作为这套判法为什么不可用的证据留下，机制本身已经不在线上。这三段和上面那节 &quot; 作废前的那版实测 &quot; 是 <strong> 她 2026-10-04 明确要留的</strong>（原话：本项目的代码全保留，留着整页换档的证据），以后别当冗余清掉；<code>_tmp/fonttier.v3.archived.js</code> 那份归档同样不动。三场景如下：</p><ul><li><strong>整页换档在本站点不住末档 </strong>：<code> 裻</code> 在侧栏，侧栏每页都渲染，所以 &quot; 整页 &quot; 口径下每一页都判不出比末档更好的档。</li><li><strong>v2 的 <code>faceCount()</code> 只数 <code>status === 'loaded'</code> 的切片</strong>，外链样式表还没解析完就说 &quot; 这一档没有 &quot;，于是 7 ms 内降到 Arial、<code>/archives/</code> 上 5 ms，判定实际上在跟 CDN 延迟赛跑；v3 改成先看 <code>@font-face</code> 注没注册（<code>unloaded</code> 也算注册）、再给 8 秒 <code>window.load</code> 等待，跑通了，但跑通的也只是 &quot; 慢 &quot;，前面那条 &quot; 本站点不住 &quot; 没变。</li><li><strong>v2 还把结论写进 <code>localStorage</code></strong>，判到末档之后 <code>if (tier.family)</code> 直接跳过无 family 的档，下次开页短路所有检查，永久锁在 Arial。v3 改成只持久化有把握的判定＋按路径记档，但这套复杂度本身就是该删的东西。</li></ul><h2 id="四份外链 -CSS- 的实测事实">四份外链 CSS 的实测事实</h2><table><thead><tr><th>项</th><th>汇文明朝体 <code>hwmct</code></th><th>上图东观体 - 粗体 <code>stdgt</code></th><th>思源黑体 <code>noto-sans-sc</code></th><th>思源宋体 <code>syst</code></th></tr></thead><tbody><tr><td>入口</td><td><code>https://cn-font.claude-code-best.win/packages/hwmct/dist/ 汇文明朝体 /result.css</code></td><td><code>…/packages/stdgt/dist/ 上图东观体 - 粗体 /result.css</code></td><td><code>https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-sc@5/index.css</code></td><td><code>…/packages/syst/dist/SourceHanSerifCN/result.css</code></td></tr><tr><td>字节</td><td>146,857</td><td>100,464</td><td>105,274（走线 30,957，brotli）</td><td>201,023</td></tr><tr><td><code>@font-face</code> 条数（切片）</td><td>294</td><td>154</td><td>101</td><td>382</td></tr><tr><td>注册的 family</td><td><code>&quot;Huiwen-mincho&quot;</code></td><td><code>&quot;STDongGuanTi Bld&quot;</code></td><td><code>&quot;Noto Sans SC&quot;</code></td><td><code>&quot;Source Han Serif CN VF&quot;</code></td></tr><tr><td><code>src</code></td><td><code>local(&quot;Huiwen-mincho&quot;), url(&quot;./&lt;md5&gt;.woff2&quot;)</code></td><td>同形状</td><td><code>url(./files/noto-sans-sc-&lt;n&gt;-400-normal.woff2)</code>，无 <code>local()</code></td><td><code>local(&quot;Source Han Serif CN VF&quot;), url(&quot;./&lt;md5&gt;.woff2&quot;)</code></td></tr><tr><td>字重</td><td>400</td><td>400</td><td>只有 400（<code>index.css</code> 与 <code>400.css</code> 逐字节相同）</td><td>400（VF，<code>font-weight: 400</code>）</td></tr><tr><td><code>font-display</code></td><td><code>swap</code></td><td><code>swap</code></td><td><code>swap</code></td><td><code>swap</code></td></tr></tbody></table><p>托管方那三份 CSS 自带两层保险：每条 <code>@font-face</code> 的 <code>src</code> 都以 <code>local(&quot;&lt;family&gt;&quot;)</code> 开头，访客本机装过这款字就不发请求、直接用本机那份；<code>font-display: swap</code> 让字形下载期间先用设备默认字面画出来、下载完再换过去。jsDelivr 那份是 <code>@fontsource</code> 生成的，形状不同：只有 <code>url()</code>、没有 <code>local()</code>，<code>font-display: swap</code> 一样有。</p><p>思源黑体那份的取数事实单独记：<code>curl</code> 直连 <code>index.css</code> 是 200／105,274 字节，一片 <code>noto-sans-sc-4-400-normal.woff2</code> 是 200／2,300 字节、<code>font/woff2</code>、<code>access-control-allow-origin: *</code>、<code>Cache-Control: public, max-age=604800, s-maxage=43200</code>。101 片里 4 片按文字命名（<code>latin</code>／<code>latin-ext</code>／<code>cyrillic</code>／<code>vietnamese</code>），其余 97 片按编号命名，编号从 4 起、到 119 止但中间不连号，所以照 0 去拼编号一定 404（我踩过一次）。4 号那片覆盖的是 U+1F1E9–U+1F37E（区域指示符和 emoji 段），不是汉字段。<code>@fontsource</code> 另外还提供按子集切的 <code>chinese-simplified.css</code>，但那份没有 <code>unicode-range</code>、还把 100–900 九个字重全列出来，浏览器无法先按码位筛片，所以用 <code>index.css</code> 这份逐码位分片的。</p><p>切片按 <code>unicode-range</code> 划分，一片只覆盖一批码位，浏览器只下载本页文字碰到的那几片。这是 &quot; 分包 CJK 字体 &quot; 能用的前提：不分包的话一款中文字体是几 MB 起步。代价是链里挂几款，下载就按几款叠——具体数字见下面 &quot; 实测 &quot;，不是小数目。</p><p>取数路径上有三件事必须知道（<code>curl</code> 直连测的）：</p><ul><li><code>result.css</code> 不带 <code>Referer</code> 是 <strong>403／112 字节</strong>，带 <code>Referer: https://takomitkm.github.io/</code> 才 200。浏览器默认的 <code>Referrer-Policy</code>（引用来源策略）是 <code>strict-origin-when-cross-origin</code>，跨站取样式表时会带上站点根地址，本站也没有任何改过这个策略的 <code>&lt;meta&gt;</code> 或响应头，所以浏览器这一侧接得上。</li><li>那个 200 其实是 <strong>307 重定向 </strong> 过来的：<code>Location: https://ik.imagekit.io/&lt; 账号 &gt;/packages/…/result.css?t_3=&lt; 数字 &gt;</code>。账号段会变——同一轮里取到过 <code>valox72479</code>、<code>fonts122</code>、<code>fonts14</code> 三种。结论是 <strong> 绝不能把 <code>ik.imagekit.io/…</code> 写进仓库</strong>，只能引 <code>cn-font.claude-code-best.win</code> 这个入口，让浏览器每次自己跟着重定向走。</li><li><code>@font-face</code> 里的 <code>url(&quot;./dea9cc….woff2&quot;)</code> 是相对地址，基准是 <strong> 重定向之后 </strong> 的最终 URL，所以那片 woff2 的完整地址是 <code>https://ik.imagekit.io/fonts14/packages/hwmct/dist/ 汇文明朝体 /dea9cc….woff2</code>。实测 200、29,676 字节、<code>font/woff2</code>，<strong>不带 <code>Referer</code> 也给</strong>。</li></ul><p>这里我自己错了一次，记下来：先手拼 woff2 地址试过 <code>404 0 字节 </code>，当时以为是签名或 token 拦的。那是<strong> 拼错了</strong>——路径是 <code>dist/ 汇文明朝体 /&lt; 文件 &gt;</code>，我把中间那层目录丢了。按正确基准重拼就是 200，什么 token 都不存在。教训是手拼相对 URL 前先确认基准，拼错了别归因到对方的访问控制上。</p><p><strong>思源黑体为什么能出库 </strong>：<code>themes/mustom/source/asset/font/SourceHanSansCN.otf</code> 8,800,680 字节，是这仓库里最大的单个文件；<code>layout.styl</code> 里那段 <code>@font-face{Source Han Sans CN}</code> 一起删（那底下还留着一行注释掉的重复 <code>src</code>）。删完全站在主题和站点源码里 <code>grep</code> <code>Source Han</code> / <code>SourceHan</code> / <code> 思源 </code> 是 0 命中，没留悬空引用。有一点要说清免得看错改动大小：<strong> 正文的主字面本来就是汇文明朝体</strong>——<code>frame.ejs</code> 里那条 hwmct <code>&lt;link&gt;</code> 在这轮之前就在，仓库那份思源只是接住汇文接不住的字。所以出库换的是 &quot; 接不住时谁来接 &quot;，日常首屏不会看到整站换字，差别出现在缺字和外链失效两种情况。这轮把那一层保险从 &quot; 仓库里的 8.8 MB&quot; 换成 &quot; 链里第三项的外链思源黑体 &quot;，按口径要求不存仓库，代价是思源也跟着托管方走（见下面 &quot; 代价 &quot;）。</p><p>代价，按口径要求如实写：一整块中文字面现在挂在第三方中转上，四份 CSS 里三份走 <code>cn-font.claude-code-best.win</code>（它不是汇文／上图／思源的官方发布渠道，是个白牌 ImageKit 桶），一份走 jsDelivr（<code>@fontsource</code> 的正规发布路径，但它对中国访客的可达性历史上波动过）。任何一束挂掉的后果是 &quot; 那一款的字换成链里下一款画 &quot;，不会出方框，但会出混排；四束全挂才整页落到访客系统字体。原来仓库里那 8.8 MB 思源是 &quot; 外链全断也还有一档完整中文字面 &quot; 的保险，上一轮已经出库，这轮没有把它加回来，仓库里不再有本地字体兜底。四条 <code>&lt;link&gt;</code> 都写在 <code>frame.ejs</code> 里 <code>CDN_ENABLE</code> 的 <code>else</code> 分支——把那个开关打开，字体会跟主题定制一起消失。</p><p>逐字回退本身的两条代价，是这轮改判换来的：一是 <strong> 混排回来了 </strong>，一行里缺的字会落到别款，字面不统一是可见的，这是 &quot; 逐字最优 &quot; 和 &quot; 整页统一 &quot; 之间选边的结果，上一版选后者、这版选前者；二是<strong> 首屏字体流量按四款叠加</strong>，具体数字见下面 &quot; 实测 &quot;。上一版写的 &quot; 没降到的档一片都不下载、日常首屏只有一束字体请求 &quot; 随整套换档机制一起作废。</p><p>Service Worker 的缓存口径这轮改了两次，最终形态是 <strong> 只列两类特例、其余全部由 <code>fastest</code> 兜底</strong>（<code>_config.yml</code> 的 <code>serviceWorker.routes</code>，按注册顺序，sw-toolbox 只取第一个命中的路由）：</p><table><thead><tr><th>顺序</th><th>pattern</th><th>策略</th><th>管到什么</th></tr></thead><tbody><tr><td>1</td><td><code>/\/favicon\.ico(\?.*)?$/</code></td><td><code>fastest</code></td><td>唯一一条 &quot; 从特例里捞回来 &quot; 的例外：B 站那个图标 URL 含 <code>bilibili.com</code>，不排在下一条前面就会被判成不缓存</td></tr><tr><td>2</td><td><code>/(hm\.baidu\.com|google\.com|bilibili\.com|bilivideo\.com|hdslb\.com|api\.github\.com|busuanzi\.ibruce\.info)/</code></td><td><code>networkOnly</code></td><td>特例一：绝不能留在访问者本地的——统计、划词翻译、B 站播放器和音频、GitHub API、不蒜子浏览量</td></tr><tr><td>3</td><td><code>/(\/|\/index\.html)(\?.*)?$/</code></td><td><code>networkFirst</code></td><td>特例二：正文页面本体，要当场尽量为真；带 <code>opts.networkTimeoutSeconds: 5</code></td></tr><tr><td>4</td><td><code>/\//</code></td><td><code>fastest</code></td><td>兜底：除以上两类之外的一切，样式、脚本、正文 <code>api/*.json</code>、图片、字体 CSS 和全部 woff2 切片、看板娘语音 <code>.mp3</code>、<code>atom.xml</code>、<code>asset/part/*.html</code></td></tr></tbody></table><p><strong>为什么不再按扩展名罗列。<strong>上一版第 4 条写的是 <code>/.+\.(css|js|json|jpg|…|woff2|mtn|moc)(\?.*)?$/</code> 这样一份名单，代价是 </strong> 没写进去的扩展名就漏了</strong>——<code>.mp3</code>、<code>.xml</code>、<code>.txt</code> 都不在名单里，于是看板娘语音落到兜底那条 <code>networkFirst</code> 上，每次朗读都得先等网络、最长 5 秒才可能出声。她的口径是：要单独对待的只有 <code>networkFirst</code>（页面本体）和 <code>networkOnly</code>（那批）两类，剩下的一律 <code>fastest</code>。所以兜底反过来写——第 4 条 <code>/\/</code> 匹配任何含斜杠的 URL，也就是 &quot; 全接 &quot;，新出现的资源类型不需要回来加条目，天然就是 &quot; 缓存＋每次开页核对更新 &quot;。</p><p>第 3 条的 pattern 是按本站产物的 URL 形状选的：<code>find public -name '*.html'</code> 数是 47 个文件，其中 15 个是页面、全部以 <code>/index.html</code> 结尾或以 <code>/</code> 结尾（<code>/</code>、<code>/posts/2659281223/</code>、<code>/about/index.html</code>、<code>/archives/</code>），剩下 32 个都在 <code>asset/part/</code> 下、既不以 <code>/</code> 结尾也不是 <code>index.html</code>，所以这条只会命中页面本体，部件 html 归兜底。</p><p>新表实测（<code>_tmp/roundY.mjs</code>，冷 profile 连开四趟，每趟再主动 <code>fetch</code> 一遍语音／<code>atom.xml</code>／部件／样式）：第一趟不受控，缓存只有 precache 那 56 条（页面 6、部件 31），<code>mp3</code>/<code>xml</code>/<code>woff2</code>/<code>favicon</code> 全 0；受控之后 136 → 183 → 187 条，<code>mp3</code> <strong>1 条 </strong>、<code>xml</code> <strong>1 条</strong>、<code>css</code> 7、<code>js</code> 49、<code>woff2</code> 4→50→53、<code>favicon</code> 2、<code>part</code> 31、<code>busuanzi</code> <strong> 恒 0</strong>。也就是说换掉名单之后，不带那些扩展名的语音和订阅文件确实被兜底接住了，而不蒜子照样一片不留。</p><p>两条策略的差别用标记法分辨（<code>_tmp/roundZ.mjs</code>，复用同一个已受控 profile）：往磁盘 <code>public/index.html</code> 的 <code>&lt;/body&gt;</code> 前插 <code>&lt;!--MK-P-ALPHA--&gt;</code>，<strong>同一趟打开 </strong> 页面 <code>outerHTML</code> 里就已经有标记，缓存里那份也从 26,408 字节变成 26,425；对照组是样式表——同一份构建里 <code>style.css?t=</code> 恒定，往磁盘那份末尾追加 <code>/*MK-R*/</code> 之后，<code>fastest</code> 的表现是 &quot; 当趟缓存变新、当趟画面还是旧的、下一趟画面才新 &quot;。所以页面拿到的是当场为真、资源拿到的是零等待但晚一趟。</p><p>这里也更正我自己一个判据选择：本来想用导航条目的 <code>transferSize</code> 来分辨页面走哪条策略，实测 SW 一旦接管，<code>performance.getEntriesByType('navigation')[0].transferSize</code> 在 Firefox 里恒为 <strong>0</strong>（V2/V3/V4 全是 0），这个数在两条策略下都一样，不能用来分辨——分辨要靠 &quot; 改版当趟是否生效 &quot;，不是靠字节数。</p><p>上一轮顺带做的两件事仍然有效：<code>cdn.jsdelivr.net</code> 从 networkOnly 名单里拿出来（思源黑体要走它）、<code>clustrmaps</code> 和 <code>icon-lib\|font-mustom\|MPlayer</code> 那两条 networkOnly 删掉；不蒜子 <code>busuanzi\.ibruce\.info</code> 加进 networkOnly（她的口径是浏览量数字不留本地，见下面实测那节末尾）。旧规则里字体切片写成 <code>cacheFirst</code> 那个坑也一并记着：<code>cacheFirst</code> 命中就永不回源，改了字体访问者不会生效，现在全站没有一条 <code>cacheFirst</code>。</p><p>三种策略分别在做什么（读的是 <code>sw-toolbox@3.4.3</code> 编译进 <code>sw.js</code> 的那三段源码，不是文档转述）：</p><ul><li>**<code>networkOnly</code>**＝<code>fetch(request)</code>，一行，碰都不碰缓存。计费和统计类必须用它，否则离线时把旧响应存下来、下次开页拿旧数当新数用。</li><li><strong><code>fastest</code><strong>＝<code>new Promise(race)</code>，同时放两路：<code>fetchAndCache(request)</code>（发网络请求，成功后 <code>cache.put()</code> 把新版写回、再返回）和 <code>cacheOnly(request)</code>（只读缓存，命中就立刻返回，没命中返回 <code>undefined</code>、这一路算失败）。谁先出结果用谁。因为读缓存是本地操作、几乎瞬时，所以 </strong> 画面等的是缓存，网络那一趟照样发出去、只是不挡画面</strong>——这就是 &quot; 每次打开都核对更新，但不多等一秒 &quot; 的实现。代价是新版永远晚一趟生效，实测就是这个样子（下面那个 <code>/*MK-R*/</code> 标记测试）。</li><li>**<code>networkFirst</code>**＝先 <code>fetchAndCache</code>，网络成功就直接用网络那份；只有网络报错或状态码不合格才回退去读缓存。加上 <code>opts.networkTimeoutSeconds: 5</code> 之后 sw-toolbox 会另起一个 5 秒定时器，和那条 fetch 一起 <code>Promise.race</code>——<strong>5 秒到点才去读缓存</strong>，所以等待上限是 5 秒，不是无限等。它同样会在网络成功时写缓存，所以 &quot; 只有 <code>fastest</code> 才存东西 &quot; 是不成立的（这点是本轮实测纠正的）。</li></ul><p>选谁的分界：只有两处需要点名——&quot; 必须当场为真的数字 &quot;→<code>networkOnly</code>（统计、翻译、播放器、GitHub API、不蒜子），“页面本体 &quot;→<code>networkFirst</code>（最多为新版等 5 秒）。<strong>其余一概不列名单</strong>，由最后那条 <code>/\/</code> <code>fastest</code> 全接：界面、字形、正文 JSON、语音、订阅 XML、部件 html 全都 &quot; 先发更新核对、立刻用本地”，新出现的资源类型不用再回来加条目。</p><p><code>cacheFirst</code> 本站已经不用了：它 <code>cacheOnly</code> 命中就返回、永不回源，上一版把字体切片写成它，才出现 &quot; 改了字体但访问者看不到 &quot; 的死档。</p><p>Service Worker 到底存不存这些，这轮把同一份构建产物连着开了四趟（<code>_tmp/roundR.mjs</code>，冷 profile，本地 <code>127.0.0.1:4162</code>）：</p><ul><li><strong>第一趟</strong>：<code>navigator.serviceWorker.controller</code> 还是 <code>false</code>（SW 刚注册，这一页还不归它管），toolbox 缓存里只有 precache 那 56 条（页面 37、<code>/api/*.json</code> 10、其余 9），字体相关 0 条；这一趟 111 条 woff2 网络条目、<code>transferSize</code> 合计 3,024,260 字节，全走网络。</li><li><strong>第二趟</strong>（已受控）：缓存 56 → 129 → 133 条，其中字体 CSS 3 条、woff2 3→4 条（域名 <code>ik.imagekit.io</code>）。这一趟页面只发出 3 条 woff2 请求，且 <code>transferSize</code> 全是 0，四份字体 CSS、<code>style.css</code>、5 条 <code>/api/*.json</code> 同样是 0——都由缓存给出。为什么只发 3 条：其余切片这时还躺在浏览器自己的 HTTP 缓存里，没走到 SW 这一层（归因未核，标 B）。</li><li><strong>文章页那一趟</strong>：文字多了，55 条 woff2 请求全部经过 SW、<code>transferSize</code> 全 0，缓存里 woff2 累计到 59 条（<code>ik.imagekit.io</code> 52＋<code>cdn.jsdelivr.net</code> 5＋本地 2）。</li><li>于是上一轮那句 &quot;<strong>woff2 一条都没进 PWA 缓存</strong>&quot; 作废。那是 <code>cacheFirst</code> 那版规则下、SW 又恰好没拦到请求造成的观测缺口，不是 sw-toolbox 存不下跨站切片。</li></ul><p>“每一次打开都核对更新 &quot; 这条单独验过：在第一趟和第二趟之间，往磁盘那份 <code>public/asset/css/style.css</code> 末尾追加一行 <code>/*MK-R*/</code>（183,232 → 183,242 字节），<strong>URL 一个字没改</strong>（<code>?t=</code> 是构建时间戳，同一份构建里恒定，所以不是靠换地址刷新的）。第二趟打开后，缓存里那份就已经带 marker（长度 183,242）；页面这一趟画的还是旧版——这一条只能从 <code>fastest</code> 的语义推（<code>cacheOnly</code> 瞬间命中，<code>fetchAndCache</code> 稍后才把新版写回），直接证据只有 &quot; 缓存在这趟就变成了新的”，标 B。第三趟页面吃到的才是新版。这就是 <code>fastest</code> 和 <code>networkFirst</code> 的分工：两者都发网络请求，<code>networkFirst</code> 在请求回来之前一直等（本站 <code>networkTimeoutSeconds: 5</code> 是硬等），<code>fastest</code> 立刻用缓存画、新版下一次生效。</p><p>缓存条数在往上走（四趟之后 191 条），sw-toolbox 的 <code>maxEntries</code>／<code>maxAgeSeconds</code> 本站没设。不会跨部署堆：换新 SW 时它会把上一版那份额外的 <code>$$$inactive$$$</code> 缓存清掉（第一趟的快照里能看到这条，10 秒后再读就没了），这也正是 precache 必须存在的原因——见文末 PWA 那段。</p><p>存了什么，这轮把整份缓存逐条倒出来数过（<code>_tmp/roundT.mjs</code> 五类页面各连开两轮、末次快照 262 条；<code>_tmp/roundV.mjs</code> 复用同一个 profile 再开两趟、322 条，按扩展名）：woff2 182、js 49、html 36、json 16、png 12、css 7、ico 2、woff 1、svg 1、jpg 1、<code>.moc</code>/<code>.mtn</code> 各 1、无扩展名 13（页面 URL 本身）。按 host：本地 127、<code>ik.imagekit.io</code> 159、<code>cdn.jsdelivr.net</code> 21、<code>cn-font.claude-code-best.win</code> 3、<code>github.com</code> 2、<code>www.bilibili.com</code> 1、<code>busuanzi.ibruce.info</code> 7、<code>v1.hitokoto.cn</code> 1、<code>api.ip.sb</code> 1。三点值得记：</p><ul><li>四份外链字体 CSS <strong>全部 </strong> 进了缓存（<code>css</code> 7 条＝站内 <code>style.css</code>＋<code>waifu.css</code>＋<code>site.css</code>＋那四份）。但 jsdelivr 那份 <code>index.css</code> 是滞后出现的：roundT 那十次快照里它一直是 0（当时 21→20 条 jsdelivr 全是被它引用的 woff2 切片），到 roundV 才数到它。为什么慢一趟没查，观测本身是 A、机制标 C。</li><li>三个第三方 JSONP（不蒜子、一言、<code>api.ip.sb</code> 取访客 IP）在改之前也被兜底那条 <code>/\//</code> <code>networkFirst</code> 收进了缓存（末次快照里不蒜子 7 条、一言 1 条、IP 1 条）——它们的 URL 没有扩展名，命中不了按扩展名那条，就归最后一条。<code>networkFirst</code> 同样会写缓存，所以 &quot; 只有 <code>fastest</code> 才存 &quot; 是不成立的。<strong>据此定的两条口径</strong>：不蒜子那个计数接口现在钉成 <code>networkOnly</code>、永不留在访问者本地（浏览量数字不能拿旧的充当新的）；一言不动，仍然进缓存——离线时侧栏那句是上一次开页存的，这是 knowingly 接受的，见下一段。</li><li><code>.mp3</code>（看板娘语音）、<code>.xml</code>（<code>atom.xml</code>）在旧的按扩展名列名单那版路由下是 <strong>0 条</strong>——它们不在名单里，那版把它们推给了兜底 <code>networkFirst</code>。兜底换成 <code>fastest</code> 之后（roundY）两个都是 <strong>1 条</strong>，实际朗读过的语音和订阅文件都留在访问者本地了。<code>.txt</code> 仍然 0 条，因为站点里没有页面去请求任何 <code>.txt</code>（不是路由拦的）。</li></ul><h3 id="实测：逐字链跑通，0- 方框，但首屏字体流量是四款叠加">实测：逐字链跑通，0 方框，但首屏字体流量是四款叠加</h3><p>探针 <code>_tmp/roundQ.mjs</code>：headless Firefox（<code>--headless --no-remote --new-instance --profile</code>，每次先删 profile 冷启动），站点跑在本地 <code>127.0.0.1:4162</code> 服务构建产物，视口 1366×900。判据分两层，各管各的：</p><ul><li><strong>覆盖判据用静态 <code>unicode-range</code></strong>：解析四份 CSS 每条 <code>@font-face</code> 的 <code>unicode-range</code> 并成码位集，和站点文字集求差。上面所有覆盖表都出自这里。这是规范级的确定性判断——码位不在某款声明的任何段里，浏览器就不会拿这款去画那个字，不需要实测。</li><li><strong>&quot; 画出来是不是方框 &quot; 用位图比对，但只做正向判定 </strong>：把字画进这一档实际生效的 <code>font-family</code> 链，和同一条链画 <code>U+10FFFD</code>（未分配码位，任何字体只能画成 <code>.notdef</code> 框）的位图比 FNV-1 哈希，一致＝方框，不一致＝链里确实有款把字画出来了。<strong>&quot; 和 <code>monospace</code> 那张一样 &quot; 不能判成缺字</strong>——这是上一版判式的错：缺字时浏览器用隐式的 last-resort 系统字体去画，那张不保证等于 <code>monospace</code>，所以 &quot; 一致 &quot; 推不出 &quot; 缺字 &quot;。<code> 裻</code>／<code>⫘</code> 当年就是这么被藏过去的，也是那套换档判法当时看起来 &quot; 能跑 &quot; 的原因。字宽也不能判（中日韩等宽，<code>至 </code>／<code> 裻</code>／<code>虛</code> 每款都是同一个 1em）。<code>document.fonts.check()</code> 同样不能判覆盖，它报的是 &quot; 此刻有没有已下载好的字形 &quot;。</li></ul><p>四款齐挂时，首页在跑位图扫描之前（也就是纯页面加载）的字体流量：</p><table><thead><tr><th>家族</th><th>切片总数</th><th>首页已下载</th><th>唯一片数（网络条目）</th></tr></thead><tbody><tr><td>汇文明朝体</td><td>294</td><td>46</td><td>46</td></tr><tr><td>上图东观体 - 粗体</td><td>154</td><td>41</td><td>41</td></tr><tr><td>思源黑体</td><td>101</td><td>18</td><td>18</td></tr><tr><td>思源宋体（侧栏）</td><td>382</td><td>8</td><td>8</td></tr><tr><td>合计</td><td>931</td><td>113</td><td>114 条含 1 条 Font Awesome</td></tr></tbody></table><p>113 片 woff2、<code>transferSize</code> 合计 <strong>3,691,160 字节（约 3.5 MB）</strong>；逐片 <code>curl</code> 实测各家族下载量：汇文 47 片 1,710,420 字节、上图 43 片 1,343,024 字节、思源宋体 8 片 171,556 字节（思源黑那 18 片没逐片量）。</p><p>同一份首页把另外三份 <code>&lt;link&gt;</code> 删掉、只留汇文（<code>public/_1fam.html</code>，测完即删）：<strong>35 片、1,132,004 字节（约 1.1 MB）</strong>。也就是说链里多挂三款，首屏字体流量从 1.1 MB 涨到 3.5 MB。</p><p>这多出来的量不是 &quot; 补缺 &quot; 补出来的：上图那 41 片一共声明 2,312 个码位，其中 <strong>2,271 个（98%）汇文也声明 </strong>，<code> 的</code> U+7684 在汇文片 <code>5f771bfa….woff2</code> 和上图片 <code>d023a214….woff2</code> 里都有，两片 <strong> 都下载了</strong>。所以 Firefox 是照 <code>unicode-range</code> 把链里每一款的对应切片都拉，而不是只在首款画不出时才拉下一款（为什么会这样我没查，机制归因标 C 未核；数字本身是 A，直接读网络条目）。上一版 &quot; 没降到的档一片都不下载 &quot; 那句就是被这个观测否掉的。</p><p>字形表现（位图扫描，方框＝和 <code>.notdef</code> 对照同哈希；<code>_tmp/roundS.mjs</code> 扫七类页面，<code>_tmp/roundW.mjs</code> 把直播页和关于页按 &quot; 落位后再扫 &quot; 重跑了一遍）：</p><table><thead><tr><th>页面</th><th>部位</th><th>生效链首项</th><th>去重字符</th><th>方框</th><th>零墨迹</th></tr></thead><tbody><tr><td>首页 <code>/index.html</code></td><td><code>body</code></td><td>Huiwen-mincho</td><td>365</td><td>0</td><td>0</td></tr><tr><td>首页</td><td><code>.m-drawer</code></td><td>Source Han Serif CN VF</td><td>39</td><td>0</td><td>0</td></tr><tr><td>首页</td><td><code>.p-brand-slogan</code>／<code>.p-menus</code></td><td>Source Han Serif CN VF</td><td>13／17</td><td>0</td><td>0</td></tr><tr><td>首页</td><td><code>.m-aside</code>／<code>.p-timeline</code>／<code>.p-hitokoto</code>／<code>.p-footer</code></td><td>Huiwen-mincho</td><td>49／24／27／34</td><td>0</td><td>0</td></tr><tr><td>文章页 <code>/posts/2659281223/</code></td><td><code>body</code></td><td>Huiwen-mincho</td><td>1207</td><td>0</td><td>1（U+FE0E）</td></tr><tr><td>文章页</td><td><code>.p-post-content</code>（正文本体）</td><td>Huiwen-mincho</td><td>1204</td><td>0</td><td>1（U+FE0E）</td></tr><tr><td>文章页</td><td><code>.m-aside</code>（目录）</td><td>Huiwen-mincho</td><td>327</td><td>0</td><td>0</td></tr><tr><td>归档页 <code>/archives/</code></td><td><code>body</code>／<code>.p-timeline</code></td><td>Huiwen-mincho</td><td>365／325</td><td>0</td><td>0</td></tr><tr><td>分类页 <code>/categories/ 建站 /</code></td><td><code>body</code>／<code>.p-timeline</code></td><td>Huiwen-mincho</td><td>307／259</td><td>0</td><td>0</td></tr><tr><td>标签页 <code>/tags/Hexo/</code></td><td><code>body</code>／<code>.p-timeline</code></td><td>Huiwen-mincho</td><td>260／207</td><td>0</td><td>0</td></tr><tr><td>关于页 <code>/about/</code></td><td><code>body</code></td><td>Huiwen-mincho</td><td>463</td><td>0</td><td>4（U+FE0F、U+A0、U+329、U+FE0E）</td></tr><tr><td>直播页 <code>/live/</code></td><td><code>body</code>／<code>.m-drawer</code></td><td>Huiwen-mincho／Source Han Serif CN VF</td><td>217／39</td><td>0</td><td>0</td></tr><tr><td>每一页</td><td>分隔符探针 <code>✟⫘</code>（临时插入再删）</td><td>Huiwen-mincho</td><td>2</td><td>0</td><td>0</td></tr></tbody></table><p>七类页面全部 <strong>0 方框</strong>。字符数在不同趟之间会差十几个，因为侧栏的一言和时段问候是每次随机／按钟点取的，不是判定漂移。</p><p><code>getComputedStyle</code> 回读：<code>body</code> 和 <code>.markdown-body</code> 是 <code>&quot;Huiwen-mincho&quot;, &quot;STDongGuanTi Bld&quot;, &quot;Noto Sans SC&quot;, Arial, Helvetica, sans-serif</code>，<code>.m-drawer</code>／<code>.p-brand-slogan</code>／<code>.p-menus</code> 前面多一项 <code>&quot;Source Han Serif CN VF&quot;</code>——Stylus 那两条链在编译产物里名字和顺序都在，<code>.m-drawer</code> 的继承覆盖到了侧栏全部子块。</p><p>零墨迹那几例不是缺字：U+FE0E／U+FE0F 是变体选择符、U+329 是合字用 &quot;（一）“、U+A0 是不换行空格，这几个码位单独画在空画布上本来就没有墨。判定只用一个方向——位图和 <code>.notdef</code>（拿 U+10FFFD 做对照）同哈希才算方框，所以 &quot; 零墨迹 &quot; 永远不等于 &quot; 缺字形”。</p><p>两处观测缺口顺手记一下。<code>.markdown-body</code> 在文章页扫出来 n=0，是选择器顺序的假象：页面上第一个 <code>.markdown-body</code> 是列表摘要那个空占位，正文在 <code>.p-post-content</code> 里，所以单列了一行。直播页第一趟（roundS）只有 63 字符、侧栏 3 个字符，看着像侧栏没渲染，<code>_tmp/roundU.mjs</code> 对着它和关于页逐项比了 innerText／textContent、计算样式 <code>display</code>／<code>visibility</code>、<code>[data-lang]</code> 填充数（92 个里填了 78）、<code>meta[name=layout-parts]</code>（两页都是 <code>[&quot;page&quot;]</code>）、<code>/asset/part/*.html</code> 的请求数与状态码——两边完全一致，差别只在直播页多挂了 3 个 iframe 和 1 个 video，部件落位更慢。改成轮询到 <code>.m-drawer</code> 文本稳定再扫，就是上表那行 217／39。<strong>这是探针采样太早，不是页面 bug</strong>，但这轮没动直播页的任何代码。</p><p>外链断掉的后果这轮没重测（上一版测过 &quot; 强制只留某一款 &quot; 的三场景，但那是给换档机制测的，链的口径已经变了）。按 CSS Fonts 的行为，某一束挂不住＝那一束的字全部落到链里下一款，<code>font-display: swap</code> 期间先拿设备默认字面画，不会留方框；这条是规范推论，标 B。</p><h2 id="夜间模式不再压暗任何外链图片">夜间模式不再压暗任何外链图片</h2><p><code>layout.styl</code> 原来是一条 <code>:root.night {#live2d-widget, img { filter: brightness(0.8) } }</code>，站内站外一起压。上一轮图标换成外链之后这条就成了麻烦：外链图片本来就是别家品牌的固定配色，压暗只会让它更糊。现在拆成两条，豁免条件写在规则本身里：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">:root.night</span><br><span class="line">  #live2d-widget</span><br><span class="line">    filter brightness(0.8)</span><br><span class="line">  img:not([src^=&quot;http&quot;])</span><br><span class="line">    filter brightness(0.8)</span><br></pre></td></tr></table></figure><p><code>img:not([src^=&quot;http&quot;])</code> 是按 &quot;<code>src</code> 属性是不是以 http 开头 &quot; 判的，所以以后新增的外链图片自动豁免，不用再回来改样式表。两个边界说清：</p><ul><li>它只看 <code>src</code> 开头，协议相对写法（<code>src=&quot;//example.com/x.png&quot;</code>）选不中、会被当成站内图片压暗。实测全站 <code>&lt;img&gt;</code> 没有这种写法（外链都是 <code>https://</code>，站内是 <code>/asset/…</code>），所以现状下豁免到的正是那 3 个外链：两个 favicon 和头像 <code>https://github.com/takomitkm.png</code>。要新增外链图片就照 <code>https://</code> 写，别用 <code>//</code>。</li><li>只管 <code>filter</code>。夜间底色和文字色那套变量没动；<code>gray</code>（灰阶）皮肤底色本来就是浅色，不需要豁免，也没给它加规则。</li><li>上一轮为 GitHub favicon 加的 <code>:root.night .p-brand-contact-dark{filter: invert(1)}</code> 保留。它跟 &quot; 压暗 &quot; 不冲突：<code>invert</code>（反色）只改 RGB 不动 alpha，那条近黑的 <code>#171515</code> 翻成接近白；而且它选择器更具体（<code>:root.night</code> + 一个 class），实测计算样式就是 <code>invert(1)</code>。</li></ul><p>实测（<code>_tmp/roundP.mjs</code>，1366／dpr2，把 <code>night</code> 挂到 <code>&lt;html&gt;</code> 上，逐像素按 WCAG 相对亮度算图标与其左右留白底色的对比度）：</p><ul><li>外链三张的计算样式：<code>takomitkm.png</code> → <code>none</code>、<code>github.com/favicon.ico</code> → <code>invert(1)</code>、<code>www.bilibili.com/favicon.ico</code> → <code>none</code>；站内三张（<code>/asset/img/icon.medium.png</code>、<code>qf3cu.huaji.png</code>、<code>qq.png</code>）仍然都是 <code>brightness(0.8)</code>，豁免没有漏到站内图片上。</li><li>B 站图标夜间对比度：豁免之后 <strong>7.07∶1</strong>（可分辨像素 1025／2304）；把这条豁免单独撤掉、恢复旧的 &quot; 外链也压暗 &quot;（给那个 <code>&lt;img&gt;</code> 写行内 <code>filter: brightness(0.8)</code> 模拟改动前那一版）是 <strong>4.73∶1</strong>（1021／2304）。也就是说豁免换来的是对比度上浮，&quot; 看得出来有几个像素 &quot; 那一列几乎没变。这一张只取 B 站那列：同一次会话里上一状态注入的 &quot; 关掉 GitHub 反色 &quot; 样式还在生效，所以那张图在 <code>nightdim</code> 里也显示成 <code>none</code>。</li><li>GitHub 图标那组顺带重算了一次，并且 <strong> 要更正上一轮写的数字</strong>：<code>_tmp/lumcheck.py</code> 里相对亮度的绿色系数当时写错成 <code>0.0722</code>（应为 <code>0.7152</code>，<code>0.0722</code> 是蓝的），B 站那个图标主体是全青 <code>#00A1D6</code>、亮度几乎全在绿通道上，所以被系统性低估。修正后：夜间有 <code>invert(1)</code> 是 <strong>17.39∶1</strong>、可分辨像素 1157／2304；用 <code>!important</code> 把这条规则关掉作对照，<strong>1.15∶1、可分辨像素 0 个</strong>——&quot; 不翻就完全看不见 &quot; 这个结论没变（0 像素那条不依赖系数），只有比值数变了。上一版正文里的 6.81∶1／1.06∶1 是错的，已就地改成修正值。</li></ul><h2 id="ShiftNav（已删除）">ShiftNav（已删除）</h2><p><strong>现在这个仓库里已经没有 ShiftNav 了</strong>，这节留着记它是什么、为什么来、为什么走。</p><p>ShiftNav 原本是 WordPress 的一个抽屉菜单插件，前端本身只是「一份 CSS + 一个 jQuery 插件 + 一段 off-canvas DOM」，所以当年不装 WordPress 也能移植进来：</p><ol><li><code>jquery.min.js</code> → <code>shiftnav.min.js</code>（原样取自插件 SVN trunk）</li><li><code>shiftnav_data</code> 这个全局对象原本是 WP 用 <code>wp_localize_script</code> 注入的，我在胶水脚本里手工补齐了它读取的 16 个键</li><li>抽屉 DOM 照 vcb-s 线上渲染结果的结构搭：<code>.shiftnav[data-shiftnav-id] &gt; .shiftnav-inner &gt; nav.shiftnav-nav &gt; ul.shiftnav-menu</code>，开关按钮 <code>#shiftnav-toggle-main.shiftnav-toggle[data-shiftnav-target]</code></li><li>菜单项从主题渲染好的 <code>.p-menus .p-menu</code> 里读；主题是用 JS 异步填充这块 DOM 的，所以胶水脚本每 250ms 轮询一次，拿到内容就停</li><li><code>shift_body</code> 留空，即不做「推挤主体」的位移，抽屉直接盖在上面——主题的布局有自己的 fixed 头部，推 body 会错位</li><li>断点 991px：宽屏隐藏 ShiftNav 开关、用主题自带抽屉；窄屏反过来，并给 <code>.m-header</code> 让出左上角的位置</li></ol><h3 id="手机端抽屉改回主题自带的那个，然后把 -ShiftNav- 整个删掉">手机端抽屉改回主题自带的那个，然后把 ShiftNav 整个删掉</h3><p>原来窄屏走 ShiftNav、宽屏走主题抽屉。ShiftNav 那个抽屉里只有 <code>buildMenu()</code> 从 <code>.p-menus</code> 抄出来的一组链接，也就是「只有 main」——个人信息、皮肤、设置、分类、标签一块都没有，而且 <code>.p-menus</code> 只有一个分组（<code>groups.length === 1</code>），所以连二级菜单都没有。</p><p>根源是它 <strong> 只能抄菜单</strong>：<code>.p-menus</code> 是主题渲染的那一块，主题自己的抽屉里 brand / menus / skin / settings / panels 五块它一概搬不了。与其把这些再抄一遍，不如直接用主题那个抽屉——它本来就是给这两个断点写的，移动端样式（<code>position: fixed</code>、272px 宽、<code>overflow-y: auto</code>）都是现成的。</p><p>所以手机端改回 <code>.m-drawer</code>，并把分类 / 标签补进去，只做两件事：</p><ol><li><code>frame.ejs</code> 的 <code>.m-drawer</code> 里加一份 <code>_part/panels</code>。于是页面上现在有 <strong> 两个</strong> <code>.p-panels</code>（抽屉一份、<code>.m-aside</code> 一份），而 <code>common/part.js</code> 的 <code>part()</code> 只 <code>querySelector</code> 第一个、<code>panels.js</code> 也只往 <code>element</code> 里填——第二份会永远是空的。改成 <code>panels.js</code> 自己 <code>querySelectorAll('.p-panels')</code> 逐个填、逐个去 <code>HIDE</code>，并把原来的字号 / 透明度分配抽成 <code>scale(items, box)</code> 复用。</li><li>两份各管一个断点：<code>.m-drawer .p-panels</code> 在 ≥1024px 隐藏、<code>.m-aside .p-panels</code> 在 ≤1023px 隐藏。两块都在 DOM 里、都会被填，但任一宽度只有一块可见。</li></ol><p>确认抽屉比 ShiftNav 全之后，<code>source/shiftnav/</code> 整个删掉，<code>frame.ejs</code> 末尾那 6 行引用（3 个 <code>&lt;link&gt;</code> + 3 个 <code>&lt;script&gt;</code>）一并去掉。只删文件不改样式是不行的——<code>shiftnav-site.css</code> 里有两条规则是现在还在生效的，搬进主题里：</p><table><thead><tr><th>原位置</th><th>搬到</th></tr></thead><tbody><tr><td><code>@media (min-width: 1024px) .m-drawer .p-panels {display: none !important}</code> 和 <code>@media (max-width: 1023px) .m-aside .p-panels {display: none !important}</code></td><td><code>_part/panels.styl</code></td></tr><tr><td><code>@media (max-width: 1023px) .m-drawer .p-brand-contacts {display: block !important}</code></td><td><code>_part/brand.styl</code>，同时把 <code>panels</code> 之外原本的 <code>.p-brand-contacts {display: none}</code> 直接改掉，<code>!important</code> 就不必要了</td></tr></tbody></table><p><code>.m-app-inner, .m-frame, .m-main, .m-footer {width: 100% !important; margin: 0 !important}</code> 那几条 <strong> 没有 </strong> 搬。它们当初是给 ShiftNav 那个 fixed 抽屉让位的（主题自己在 ≤1023px 已经写了 <code>.m-drawer, .m-frame, .m-aside, .m-main {width: 100%}</code> 和 <code>.m-footer {width: 100%}</code>），现在用回主题抽屉就不需要了。</p><p>顺带解决的三件事：</p><ul><li><strong>skin 在手机版原本根本不初始化</strong>。<code>main.js</code> 的 <code>skin.init()</code> 只写在 <code>util.run</code> 的 <code>desktop(final)</code> 分支里，手机分支只有 <code>xdrawer</code> 和 <code>settings</code>；<code>skin.styl</code> 里还写着 <code>@media (max-width: $app_mobile_width) display none</code>。现在删掉那条 <code>display none</code>、给 <code>.p-skin</code> 补一份手机版布局（<code>width 100%</code>，和 <code>.p-menus</code>／<code>.p-settings</code> 一样——抽屉净宽 257px，桌面那条 <code>width 256px + margin 32px</code> 会横向溢出），并在手机分支里 <code>skin.init()</code>。<code>applyConfig()</code> 里的 <code>skin.set(config.get('skin'))</code> 也从 <code>util.runOnDesktop</code> 里提出来，现在两个宽度都回填访客存的皮肤。</li><li><strong><code>transfigure</code> / <code>lyride</code> / <code>autoplay</code> 三项在手机版是隐藏的 </strong>（<code>settings.styl</code>）。<code>transfigure</code> 对应桌面端 evanyou 那个画布，手机没有，<strong> 继续隐藏</strong>；<code>lyride</code> 是给早先删掉的 APlayer 歌词用的；<code>autoplay</code> 是 <code>biliplayer</code> 的开关——播放器现在手机上也有了，所以把它放出来，并给手机分支的 <code>settings.init</code> 补上 <code>key === 'autoplay'</code> 的处理（原来只有 desktop 分支有）。<code>lyride</code> 那项后来整个改名重做成 &quot; 隐藏播放器 &quot;，见下面 &quot; 隐藏播放器 &quot; 一节。</li><li><strong><code>.p-brand-contacts</code>（GitHub 图标）手机上是 <code>display: none</code></strong>，抽屉够宽，改成显示，和桌面一致。</li></ul><p>实测（把 UA 钉成 iPhone 再看）：手机 UA 下进度条到 100%、<code>.m-spinner</code> 的 <code>.active</code> 摘掉、<code>html</code> 上 <code>closeDrawer</code> 正常；点开抽屉 <code>innerText</code> 依次是 <code>takomitkm / slogan / 3 2 7 / MAIN(Home, Archive, About, Live) / SKIN / SETTINGS(简体中文, Night Shift, Hide Lyric, Autoplay) / CATEGORIES / TAGS</code>；点灰圆点 <code>html</code> 上多出 <code>gray</code>、<code>localStorage['config'].skin === 'gray'</code>、<code>configChosen</code> 里记上 <code>skin</code>（说明确实走的是访客点击那条路径）；抽屉 <code>scrollWidth === clientWidth === 257</code>，逐个量 <code>.p-brand</code>／<code>.p-menu-items a</code>／<code>[data-skin-key]</code>／<code>[data-settings-key]</code>／<code>.p-panel-items a</code> 没有一个越过抽屉右沿；<code>documentElement.scrollWidth 985 &lt; 1000</code>，不撑文档宽度。两个 <code>.p-panels</code> 各 2 个分类、7 个标签，两份都填上了。</p><p>删掉 ShiftNav 之后 <code>public/</code> 里不再有 <code>shiftnav/</code> 目录，<code>document.querySelector('#shiftnav-toggle-main')</code> 和 <code>'.shiftnav'</code> 都是 <code>null</code>，<code>window.jQuery</code> 也不再被注入（主题自己的代码不依赖它），每页少加载约 120 KB。</p><h2 id="加载界面">加载界面</h2><p>主题启动时叠了三层：<code>.m-launch</code> 是整屏 <code>--color-link</code> 红底，<code>.m-spinner</code> 是整屏 <code>#ffffffee</code> 白纱加一个转环，<code>.m-progress</code> 是顶部进度条。前两层太刺眼，做法是：</p><ul><li><code>.m-launch</code> 直接 <code>display: none</code></li><li><code>.m-spinner</code> 只删 <code>background</code>，转环动画和 <code>.active</code> 期间的 <code>pointer-events: all</code> 都留着，进度条不动</li></ul><p>DOM 节点本身一个都不能删。<code>main.js</code> 的启动清单是靠这些节点计数决定 &quot; 加载完了没有 &quot;，少一个节点 <code>activateSpinner(false)</code> 就永远不执行，页面卡在加载态。</p><h2 id="Markdown- 配置">Markdown 配置</h2><p><code>_config.yml</code> 里写了一段显式 <code>markdown:</code>，值就是 hexo-renderer-markdown-it 7.1.1 当前的默认值，所以它对产物是逐字节的 no-op——这点是构建前后对 <code>public/api/posts/*.json</code> 取 md5 比出来的（比对时要排除 <code>api/site.json</code>，它内嵌 <code>swPath: /sw.js?t=&lt; 构建毫秒 &gt;</code>，每次必变）。写死它的唯一目的是钉住渲染行为，将来升级 renderer 时排版不会静默变化。</p><h2 id="直播间页">直播间页</h2><p>front-matter 是 <code>layout: page</code> + <code>name: live</code> + <code>parts: [page]</code>。三个约束值得记：</p><ol><li><code>parts</code> 里只能写主题认识的名字。<code>main.js</code> 只对 <code>page</code> 这个 part 做初始化，写一个不存在的 part 会让启动清单凑不齐，和删 DOM 节点同一个死法。</li><li>正文里的 <code>&lt;script&gt;</code> 由 <code>part/page.js</code> 重新执行——它照抄一个新建的 script 元素再插进文档，<strong>原来的 id 会丢</strong>。所以页内脚本的全局数据必须写在脚本第一行，不能靠 <code>getElementById</code> 去捞一个带 id 的 script 标签。</li><li>markdown-it 把 <code>&lt;style&gt;</code> / <code>&lt;script&gt;</code> 当 HTML block type 7，里面 <strong> 不能有空行</strong>，有空行就会被截断。</li></ol><p>线路表 <code>routes</code> 支持三种形态：字符串是直连媒体地址（m3u8 走 hls.js、mp4 走原生 <code>canPlayType</code> 判断），带 <code>embed</code> 的对象是第三方播放器的 iframe，带 <code>room</code> 的对象是 B 站直播间的跳板卡片。点赞数只写 <code>localStorage</code>，没有服务端计数。</p><h2 id="B- 站直播嵌不进来的部分，和唯一能嵌的那个">B 站直播嵌不进来的部分，和唯一能嵌的那个</h2><table><thead><tr><th>端点</th><th>实测结果</th></tr></thead><tbody><tr><td><code>www.bilibili.com/blackboard/live/live-activity-h5-player.html?roomid=…&amp;type=room</code></td><td>200，<strong>没有任何 X-Frame-Options / CSP</strong>，可嵌；自带 <code>bilibili-lmp-danmaku</code> 弹幕层；未开播时显示房间封面＋播放按钮</td></tr><tr><td><code>live.bilibili.com/&lt; 房间 &gt;</code></td><td><code>X-Frame-Options: SAMEORIGIN</code>，嵌不了</td></tr><tr><td><code>player.bilibili.com/player.html</code></td><td>参数白名单只有 <code>aid/bvid/cid/episodeId/seasonId/poster/autoplay/muted/t/p/page/danmaku/kind/refer</code>，<strong>没有直播字段</strong>，<code>getGroupKind()</code> 只会返回 Ugc/Pgc；传 <code>is_live=true&amp;live=…</code> 会被当没给 aid 的视频，落到 &quot; 视频链接失效 &quot; 那张错误短片</td></tr><tr><td><code>player.bilibili.com/live.html</code>、<code>live.player.bilibili.com</code></td><td>404 错误页</td></tr><tr><td><code>api.live.bilibili.com</code> 全系</td><td>请求只要带跨域 <code>Origin</code> 一律 403，响应也没有 <code>Access-Control-Allow-Origin</code>；不带 Origin 时 <code>get_info</code>、<code>Danmu/getConf</code>、<code>Room/playUrl</code> 匿名 200，playUrl 给的是 https FLV；<code>callback=</code> JSONP 参数被忽略</td></tr><tr><td><code>xlive/web-room/v1/index/getDanmuInfo</code></td><td><code>-352</code> 风控，要 WBI 签名；拿 <code>x/frontend/finger/spi</code> 的 buvid3 当 cookie 喂进去仍然 <code>-352</code></td></tr><tr><td>弹幕 socket</td><td><code>broadcastlv</code> 的 wss:443 / ws:2244 / ws:2243 和 <code>zj-cn-live-comet:443</code>，六种握手 body 变体（protover 0/1/2/3、带不带 getConf 的 token、<code>cmd:Auth</code> 包装、<code>type:danmu</code>），浏览器和 Deno 两边一致：连上后零字节断开</td></tr></tbody></table><p>所以：弹幕是 &quot; 有 &quot; 的，但只有借 B 站自己的播放器才有。想自己写代码读弹幕、自己取流，静态托管上做不了，得有个后端做中转（把带 Origin 的请求换成不带的服务端请求，再把 FLV 和弹幕转发出来）。</p><h2 id="加一个菜单项要改三处">加一个菜单项要改三处</h2><p><code>themes/mustom/_config.yml</code> 的 <code>menus.main.&lt;key&gt;</code>、<code>asset/lang/zh-cn.yml</code> 的 <code>menus.main.items.&lt;key&gt;</code>、以及同文件的 <code>pather.&lt;key&gt;</code>。少最后一项，页面顶部面包屑就打印 <code>undefined</code>。英文那份 <code>en.yml</code> 同理两份都要加。</p><h2 id="换壁纸和半透明面板">换壁纸和半透明面板</h2><p>颜色全在 <code>themes/mustom/source/asset/css/_common/color.styl</code>，是一份份 CSS 自定义属性，按皮肤分四块：<code>:root</code>（默认）、<code>:root.gray</code>、<code>:root.colorful</code>、<code>:root.night</code>。切皮肤只是给 <code>&lt;html&gt;</code> 加减 class（<code>asset/js/part/skin.js</code> 的 <code>set()</code>），所以改之前要先确定改的是哪一块——不带 class 的那块才是打开页面看到的。</p><p>背景图不是单独写 <code>background-image</code>，而是塞进 <code>--color-body-back</code>。<code>layout.styl</code> 里 body 已经带了 <code>background-repeat:no-repeat; background-size:cover; background-attachment:fixed</code>，所以只要这个变量的值是 <code>url(...)</code>，整站就是一张固定铺满的壁纸。图放在 <code>themes/mustom/source/asset/img/bg.jpg</code>，路径由 <code>dimension.styl</code> 的 <code>$picurl</code> 给出。原版只有 <code>:root.colorful</code> 的 body 背景用这个变量，它指向 <code>https://source.unsplash.com/1920x1080/?wallpaper</code>，实测现在请求只拿到 <code>503</code> 加一个 heroku 的 “Application Error” 页，取不到图，也就是说 colorful 那套皮肤的 &quot; 随机壁纸 &quot; 一直是加载失败的——换成本地图之后这块顺带修好了。</p><p>&quot; 透明 &quot; 就是在十六进制色后面拼两个字母的 alpha。改的背景类变量是这些：<code>--color-background</code>、<code>--color-clear</code>、<code>--color-header-background</code>、<code>--color-progress-back</code>、<code>--color-menus-hover</code>、<code>--color-extension-back</code>、<code>--color-extension-hover</code>、<code>--color-recentposts-more-back</code>、<code>--color-iconlib-iframe-back</code>、<code>--color-settings-switch-off</code>。没动的两类：<code>--color-menus-active-background</code> 和 <code>--color-toc-active-background</code> 是选中项的深底白字，透明了字就压在照片上；<code>gray</code> 和 <code>night</code> 两套整体保持原样（gray 的 <code>--color-body-back</code> 是纯色 <code>#f1f1f1</code>、night 是 <code>#000000</code>，都没有壁纸可透，透明了只会露白或露黑）。</p><p>这个后缀试过三档：<code>4d</code>（30% 不透明 = 70% 透明）正文区还能读，但引用块、灰色小字这类本来就浅的文字压在照片亮部会糊；<code>b3</code>（70% 不透明 = 30% 透明）是可读与通透的折中，先定在这一档；<strong>现在是 <code>80</code>（50%）</strong>，2026-09-30 按 &quot; 白底背景透明度调整成 50%&quot; 改的。要再调就改这一处后缀，想保住通透感也可以给面板加 <code>backdrop-filter: blur(...)</code>。</p><p>注意 stylus 会把带 alpha 的十六进制色编译成 <code>rgba()</code> 形式，所以源码里是 <code>#ffffff80</code>、产物里是 <code>rgba(255,255,255,0.502)</code>，别在 <code>public/</code> 里按 <code>#ffffff80</code> 搜不到就以为没生效。</p><p>改这一档时顺手把 <code>:root.colorful</code> 里那几个 <strong> 纯不透明 </strong> 的白底一起压到 50%（<code>--color-clear</code>、<code>--color-header-background</code>、<code>--color-settings-switch-off</code>、<code>--color-recentposts-more-back</code>，以及本来就是 <code>dd</code>／<code>99</code> 的 <code>--color-background</code>、<code>--color-progress-back</code>、<code>--color-menus-hover</code>）。原因是开屏 3 秒后会自动切到 colorful（见 &quot; 记忆规则 &quot; 一节），访客实际看到的大多是 colorful 这一套，只改 <code>:root</code> 那套等于改了看不见的地方。改完四套皮肤逐个读计算样式实测（<code>_tmp/bg_alpha.mjs</code>，headless Firefox 读 <code>getComputedStyle</code>）：默认皮肤与 colorful 的 <code>.m-header</code>／<code>.m-heaper</code>／<code>.p-menus</code>／<code>.p-hitokoto</code> 全是 <code>rgba(255, 255, 255, 0.5)</code>，gray 是 <code>rgb(255, 255, 255)</code>，night 是 <code>rgb(34, 34, 34)</code> 或 <code>rgba(0, 0, 0, 0.8)</code>——只有前两套变了。这个 Firefox 构建里 <code>browsingContext.captureScreenshot</code> 一直回 <code>invalid argument</code>，所以这轮没留截图，只有计算样式和编译产物两份证据。</p><p>图本身是 pixiv 作品 67481841（画师 lemontea），来源地址记在 <code>dimension.styl</code> 的那行注释里。pixiv 上的作品默认不含任何再发布授权，这条只是把出处留在仓库里，不构成授权。</p><h2 id="一言面板的换行，和作者行那块底色">一言面板的换行，和作者行那块底色</h2><p>句子容器是 <code>.p-hitokoto-word</code>（<code>asset/css/_part/hitokoto.styl</code>），原版没写 <code>word-break</code>，而中文排版允许在任意两个汉字之间断行，所以句子一超过一行就被腰斩——&quot; 离离原上草，一岁一枯／荣。&quot; 这种。改成 <code>word-break: keep-all</code> 之后，CJK 之间的断行机会全部取消，只剩下标点后那些，实测（420px 宽、24px 字）：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">君不见，黄河之水天上来，</span><br><span class="line">奔流到海不复回。君不见，</span><br><span class="line">高堂明镜悲白发，朝如青丝暮成雪。</span><br></pre></td></tr></table></figure><p>三行都收在 <code>，</code> 或 <code>。</code> 上，没有一行是从词中间断的。真实面板里把 <code>.p-hitokoto-word</code> 压到 300px 复测也是同样：<code>小荷才露尖尖角，</code> / <code>早有蜻蜓立上头。</code>。</p><p><code>keep-all</code> 有个必须配兜底的副作用：整句不带标点时就一个断点都没有，文字直接撑破容器横向溢出（实测 32 字无标点句 <code>scrollWidth</code> 744 &gt; <code>clientWidth</code> 420，而 <code>.p-hitokoto</code> 是 <code>overflow: hidden</code>，等于把后半句裁掉）。所以同一块补了 <code>overflow-wrap: break-word</code>——它只在 &quot; 这一行无论如何放不下 &quot; 时才强制断，同一句 32 字变成 17+14 两行，正常句子仍然只在标点处断。这套是纯 CSS，逐字打字的动画（<code>asset/js/part/hitokoto.js</code> 每 160ms 改一次 <code>innerText</code>）不用动，重排自然跟着走。</p><p>作者行 <code>.p-hitokoto-from</code> 是 <code>position: absolute</code>，压在面板底部那张 pixiv 图上，图没有底色，字直接糊在画上，所以要一块和面板同款的底色。这块底色 <strong> 不能 </strong> 加在 <code>.p-hitokoto-from</code> 这个块上：块上有 <code>line-height: 6</code>，整块铺底会在图上盖出一条 6em 高的横带。可行的形态是把 <code>-「 </code>、名字、<code> 」-</code> 三段收进一个盒子——伪元素挂在行内的 <code>.p-hitokoto-name</code> 上，再给它 <code>background: var(--color-background)</code> 加 <code>display: inline-block</code>，这样一个盒子的宽度天然等于 &quot; 装饰 + 名字 + 装饰 &quot; 的总长，底色是一整块。用的变量和面板、句子同一个，所以跟着皮肤走（默认与 colorful 是 <code>rgba(255,255,255,0.5)</code>、gray 是 <code>#ffffff</code>、night 是 <code>#000000cc</code>）。</p><p>为什么要收成单盒：先前那版把底色分别加在 <code>.p-hitokoto-from::before</code>、<code>.p-hitokoto-name</code>、<code>::after</code> 三个兄弟行内盒上，实测在名字和 <code>」-</code> 之间留了一道 <strong>6.667px 完全没被涂到 </strong> 的缝。症状是 <code>::after</code> 的 <code>content</code> 以空格开头（<code>'」-'</code>）时那个空格不被绘制，背景从 <code>」</code> 才开始画；<code>-「 </code> 和名字之间实测 0px，没缝（尾随空格被画了，前导没有——只复现到这一层，规范上为什么不对称没核，B 级）。常见的绕法是那个位置换成不换行空格 <code>\a0</code> 或改 <code>padding</code>，但那样装饰和名字之间的间距得重调，所以这里选了收单盒。改成单盒以后，量到的白框 rect 宽 114.2px，正好等于旧版三段的并集（27.1 + 60 + 6.667 + 20.43）；截图放大四倍逐列扫，框内没有一列掉回图片本身的亮度（框左外沿 123、右外沿 121，框内 236-238，只有字墨处才下陷到 188/109/51）。</p><p>顺带修了一个改之前就在的问题：<code>:root.night img {filter: brightness(0.8) }</code>（写在 <code>layout.styl</code> 末尾那一段夜间规则里）会让 <code>img</code> 变成层叠上下文，和 <code>.p-hitokoto-from</code> 落在同一层、DOM 序又在后面，于是夜间模式下作者行整条被图片盖住、根本看不见。给 <code>.p-hitokoto-from</code> 加 <code>z-index: 1</code> 后夜间才真正看得到那块底色。三种皮肤 + 480px 移动端各自截图核过。</p><h2 id="页脚那三个数字">页脚那三个数字</h2><p><code>&lt;div class=&quot;p-footer-count&quot;&gt;</code> 里三个数（<code>layout/_partial/_part/footer.ejs</code>）不是一回事：</p><table><thead><tr><th>图标</th><th>class</th><th>来源</th><th>是不是访客</th></tr></thead><tbody><tr><td>眼睛</td><td><code>p-footer-pv</code></td><td>运行时 JSONP 请求 <code>//busuanzi.ibruce.info/busuanzi</code> 的 <code>site_pv</code>（<code>main.js</code> 里两处，启动时和每次 <code>pushState</code> 之后各一次）</td><td>是，页面浏览量</td></tr><tr><td>人</td><td><code>p-footer-uv</code></td><td>同一份响应的 <code>site_uv</code></td><td>是，独立访客</td></tr><tr><td>文档</td><td><code>p-footer-wd</code></td><td><code>/api/site.json</code> 的 <code>word4site</code>，Hexo 构建时算出来的全站字数</td><td>不是</td></tr></tbody></table><p>不蒜子（busuanzi，接口域名 <code>busuanzi.ibruce.info</code>）是一个免费的极简访问计数器：不用注册、不用后端，页面里放一段脚本，它按这次请求的 Referer 域名返回几个数，走的是 JSONP（所以也不需要对方允许跨域）。它拿不到任何访客身份，UV 只能按访客侧特征粗去重（圈内一般说法是 IP 加 UA，官方没公开算法），换网络、换浏览器、运营商复用出口 IP 都会算错，所以这四个数的定位是 &quot; 图个乐的大致量级 &quot;，不是分析数据。</p><p>所以你看到的那个 <code>3.1k</code> 是 &quot; 这个站一共多少字 &quot;，不是人数——往这篇里加完这几节再构建，它就变成了 <code>4k</code>，正好当场验证了这个数的性质。GitHub Pages 是纯静态托管，没有服务器记账，但不蒜子是按请求 Referer 的域名统计的第三方服务，所以 PV/UV 在 Pages 上照常能跑——<code>curl &quot;https://busuanzi.ibruce.info/busuanzi?jsonpCallback=BusuanziCallback_999&quot;</code> 实测回来的是 <code>try{BusuanziCallback_999({&quot;site_uv&quot;:10,&quot;page_pv&quot;:10,&quot;version&quot;:2.4,&quot;site_pv&quot;:10});}catch(e){}</code>。参数名必须是 <code>jsonpCallback</code>，写成 <code>callback</code> 会回 <code>Bad Request</code>。这类第三方计数的两个固有问题：被刷没有防护，服务哪天关了就永远停在最后一个值上（初始占位是 <code>∞</code>）。</p><p>文章页头部眼睛旁边那个数字是 &quot; 本文阅读量 &quot;。原版它走 LeanCloud（<code>leancloud_visitors</code> 配 Valine 的 visitor 模式），而 <a href="https://docs.leancloud.cn/sdk/announcements/sunset-announcement">LeanCloud 官方公告</a> 写的是：2026 年 1 月 12 日起停止新用户注册，2027 年 1 月 12 日正式停止服务，之后数据按合规要求清除。这条依赖不值得接，所以换成了同一个不蒜子的 <code>page_pv</code>。</p><p>不蒜子的响应里除了全站两个数，还带一个按请求 Referer 的 URL 记账的 <code>page_pv</code>，实测：</p><table><thead><tr><th>Referer</th><th>返回</th></tr></thead><tbody><tr><td><code>https://takomitkm.github.io/</code></td><td><code>site_pv:11</code>、<code>page_pv:11</code></td></tr><tr><td><code>https://takomitkm.github.io/posts/2659281223/</code></td><td><code>site_pv:12</code>、<code>page_pv:1</code></td></tr><tr><td><code>https://takomitkm.github.io/posts/1406938971/</code></td><td><code>site_pv:13</code>、<code>page_pv:1</code></td></tr></tbody></table><p>也就是说 &quot; 这一页被打开过几次 &quot; 本来就是现成的，不用再引第二个服务。填充点在 <code>main.js</code> 的两处不蒜子回调里（启动时一次、每次 <code>pushState</code> 之后一次），写进 <code>.p-post-pv-num</code>；<code>comment.js</code> 里原来那段给 <code>.leancloud_visitors</code> 设 id 和 <code>data-flag-title</code> 的代码删掉了。代价是三个数现在全押在同一家第三方上，它哪天停了整站统计一起回到 <code>∞</code>；真要换，方向是可自托管、带导出接口的 GoatCounter 一类，但那要先有账号和自己的域名。<code>Valine.min.js</code> 和 LeanCloud 的 <code>av-min.js</code>（合计约 268 KB）现在还在每页加载，只是评论本来就没开，要清随时能清。</p><h3 id="site-pv- 和 -site-uv- 能清零吗：不能">site_pv 和 site_uv 能清零吗：不能</h3><p>这两个数是 <strong> 域名维度的累计</strong>，存在不蒜子自己的服务器上，统计键是你站点的 hostname，站方没有任何写入口。三条核过的证据：</p><table><thead><tr><th>检查</th><th>结果</th></tr></thead><tbody><tr><td>官方文档 FAQ 第 5 条（<code>ibruce.info/2015-04-04/busuanzi/</code>）</td><td>原文 &quot; 我的网站已经运行一段时间了，想初始化访问次数怎么办？——请先注册登录，自行修改阅读次数。&quot;</td></tr><tr><td>不蒜子首页 <code>busuanzi.ibruce.info</code> 的登录 / 注册弹窗</td><td>“不蒜子暂不开放注册，攻城狮们正在火热内测中，敬请期待。”（2026-09-28 实测）——FAQ 指的那条自服务入口需要账号，而注册从没开放过</td></tr><tr><td>客户端脚本 <code>busuanzi/2.3/busuanzi.pure.mini.js</code>（1939 字节，全文读过）</td><td>请求参数只有一个 <code>?jsonpCallback=</code>，没有 reset / clear / modify 端点</td></tr></tbody></table><p>网上 &quot; 不蒜子 UV/PV 初始化 / 清零 &quot; 的教程全部是 <strong> 显示层加偏移</strong>（在自己页面里给返回值再 + 一个基数，用来给新站补历史），反向做不出清零，只会让显示数字和真实数字永久错开。所以这两个数保留原样显示，含义写在页脚气泡里，不做任何加工。</p><p>这个站的 <code>site_uv</code> 一度等于 <code>site_pv</code>，是因为我为了测接口用 curl 反复直连打请求——curl 没有浏览器特征，每次都被当成一个新访客。要真想让计数从零开始，唯一的杠杆是换统计键（不蒜子按 hostname 记账，换自定义域名后新域名从 1 开始），那是换域名，不是清零。</p><h3 id="不蒜子一共能给什么">不蒜子一共能给什么</h3><p>接口一次请求返回四个字段：<code>site_pv</code>、<code>site_uv</code>、<code>page_pv</code>、<code>version</code>（最后一个是服务自己的版本号，不是统计量）。三个统计量现在全站都用上了：</p><table><thead><tr><th>字段</th><th>统计键</th><th>显示位置</th></tr></thead><tbody><tr><td><code>site_pv</code></td><td>站点 hostname</td><td>页脚眼睛</td></tr><tr><td><code>site_uv</code></td><td>站点 hostname</td><td>页脚人</td></tr><tr><td><code>page_pv</code></td><td>请求 Referer 的完整 URL</td><td>文章页头部眼睛；这一轮起独立页面（<code>/live/</code>、关于）的标题条右侧也显示</td></tr></tbody></table><p>官方文档另外两条能力：一是 <strong> 只计数不显示 </strong>（只引脚本不引标签）；二是明确写了<strong> 不做 </strong> 列表页逐篇阅读量——“不蒜子为保持极简，暂不支持在站点文章摘要列表中（如首页）逐个显示每篇文章的阅读次数”，所以归档页每篇后面那个数拿不到，除非引第二个服务。</p><p><code>page_pv</code> 是按 URL 记账的，所以同一个填充点（<code>main.js</code> 里那两处回调写 <code>.p-post-pv-num</code>）顺手也能喂独立页面——页面模板 <code>layout/_partial/_part/page.ejs</code> 的标题条右侧加了一个 <code>.p-page-pv</code>，元素沿用 <code>.p-post-pv-num</code> 这个 class，JS 一行没改。</p><h2 id="页脚注释气泡">页脚注释气泡</h2><p>三个图标各包了一层 <code>.p-footer-count-item</code>，里面挂一个 <code>.p-footer-tip</code>，文案走主题的 i18n：<code>data-lang=&quot;footer.tipPv|tipUv|tipWd&quot;</code>，中英两份在 <code>asset/lang/{zh-cn,en}.yml</code>。桌面靠 <code>:hover</code>/<code>:focus</code>，触屏靠 <code>main.js</code> 里给这些元素绑的 click 切 <code>.on</code>（点另一个会自动关掉前一个）。两个坑：<code>.p-footer</code> 自己是 <code>overflow:hidden</code>，所以气泡的定位上下文必须是整行 <code>.p-footer-count</code> 而不是单个图标，否则最左边那个会被裁掉一半；stylus 里 <code>&amp;:hover, &amp;:focus, &amp;.on</code> 这种并列父选择器嵌子元素时会编译出一个光秃秃的 <code>.p-footer-tip</code>，把默认隐藏样式覆盖掉，得把三条选择器整行写全。</p><h2 id="播放器">播放器</h2><p>右下角那块面板现在是一个 B 站官方 iframe，组件叫 <code>biliplayer</code>，见下面 &quot; 换成 B 站官方 iframe&quot; 一节。再下面两小节是替换之前的调查记录：meting/APlayer 的代码已经全部删掉了，但结论留着有用——它解释了 &quot; 公共 meting 端点为什么只有 30 秒 &quot;“音频流为什么不能自己转发”，这两条以后换任何方案都会再撞上。</p><h3 id="网易云 -meting- 面板（已删除）">网易云 meting 面板（已删除）</h3><p>主题自带 APlayer + MetingJS（<code>asset/js/plugin/APlayer.min.js</code>、<code>Meting.min.js</code>），面板在 <code>layout/_partial/frame.ejs</code> 里无条件渲染，开关只有一个字段：<code>themes/mustom/_config.yml</code> 的 <code>meting.id</code>。判定在 <code>main.js</code> 里——</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> mj = el &amp;&amp; el.<span class="title function_">querySelector</span>(<span class="string">&#x27;meting-js&#x27;</span>);</span><br><span class="line"><span class="keyword">if</span> (mj &amp;&amp; mj.<span class="title function_">getAttribute</span>(<span class="string">&#x27;id&#x27;</span>)) <span class="title function_">_run_Meting</span>();</span><br><span class="line"><span class="keyword">else</span> el &amp;&amp; el.<span class="title function_">remove</span>();</span><br></pre></td></tr></table></figure><p>也就是说 id 给空字符串时整个 <code>.p-audioplayer</code> 面板会被删掉（原版作者的歌单 id 之前就是这么清掉的），填上就重新出现。<code>&lt;meting-js&gt;</code> 的 <code>type</code> 支持 <code>song</code> / <code>playlist</code> / <code>artist</code> / <code>album</code>，单曲就写 <code>type: song</code>。</p><p>MetingJS 这个版本默认调用 <code>https://api.i-meto.com/meting/api?server=&lt; 服务商 &gt;&amp;type=&lt; 类型 &gt;&amp;id=&lt; 号 &gt;</code>，域名确实写在 <code>Meting.min.js</code> 里，但那只是 <strong> 默认值</strong>——同一行是 <code>this.api = this.meta.api || window.meting_api || &quot;https://api.i-meto.com/...&quot;</code>，而 <code>api</code> 正是 <code>&lt;meting-js&gt;</code> 认的属性之一，所以换端点不用碰插件源码。主题模板（build-time 和 runtime 两份 <code>audioplayer.ejs</code>）因此多输出一行 <code>api=&quot;&lt;%- theme.meting.api %&gt;&quot;</code>，值写在 <code>_config.yml</code>；这个字段留空时属性是 <code>api=&quot;&quot;</code>，空串 falsy，会回落到 i-meto 默认，等于没改。</p><p>先看 i-meto 那份实测：这个端点 <strong> 走本机代理会 403（Cloudflare 挡的，直连 200）</strong>；元数据（标题 / 作者 / 封面 / 歌词）拿得很顺，但 <strong> 音频地址是按曲目授权给的</strong>——</p><table><thead><tr><th>检查</th><th>结果</th></tr></thead><tbody><tr><td>网易云自己的 <code>music.163.com/api/song/detail/?id=448155</code></td><td><code>fee: 1</code>，即 VIP/ 付费曲</td></tr><tr><td><code>api.i-meto.com/...&amp;type=url&amp;id=448155</code></td><td><code>404</code>，空 body</td></tr><tr><td>同一端点取热歌榜 3778678 的前几首做对照</td><td>海屿你 / 明知故犯 / 于是 → <code>200 audio/mpeg</code>；甲乙丙丁 / 我不难过 → <code>404</code></td></tr><tr><td>headless Firefox 里 <code>meting-js.aplayer.audio</code></td><td><code>error</code> 是 <code>MediaError code 4: Failed to open media</code>，<code>networkState: 3</code>，面板时长显示 <code>00:00 / 00:00</code></td></tr></tbody></table><p>也就是说端点是活的、配置是对的，448155（あなたがいた森 (TV サイズ) / 樹海）在 i-meto 上放不出声。换成同协议的 <code>https://api.injahow.cn/meting/?server=:server&amp;type=:type&amp;id=:id&amp;r=:r</code> 之后能响——</p><table><thead><tr><th>检查</th><th>i-meto</th><th>injahow</th></tr></thead><tbody><tr><td>直连 / 走 7897 代理取元数据</td><td>200 / 403</td><td>200 / 200</td></tr><tr><td><code>type=url&amp;id=448155</code></td><td><code>404</code> 空 body</td><td><code>302</code> 到 <code>m702.music.126.net/...mp3</code>，带 Range 请求回 <code>206 audio/mpeg</code></td></tr><tr><td>CORS</td><td><code>Access-Control-Allow-Origin: *</code></td><td><code>*</code>，302 那一跳和最终音频都带</td></tr><tr><td>headless Firefox 实测</td><td><code>MediaError 4</code></td><td><code>error: null</code>，<code>currentTime</code> 走到 4.0，面板显示 <code>00:05 / 00:30</code></td></tr></tbody></table><p>但 <strong> 只有 30 秒</strong>：把那个 481115 字节的流存下来 <code>ffprobe</code> 是 <code>duration=30.000000</code>、<code>bit_rate=128297</code>，浏览器里 <code>aplayer.audio.duration</code> 同样是 30。<code>fee: 1</code> 的曲子网易云只发免费试听段，公共 meting 端点只是替客户端签了个 CDN 地址，绕不过授权——换端点改变的是 &quot; 响不响 &quot;，不是 &quot; 完整不完整 &quot;。要整首只有两条路：换任意 <code>fee: 0</code> 的曲目号（改 <code>meting.id</code> 一行），或者把音频文件自托管挂上去。</p><p>两个已知代价：injahow 是社区公益端点、没有可用性承诺，它哪天挂了面板就退回 &quot; 封面歌词在、播放不响 &quot;，回退动作是删掉 <code>meting.api</code> 那行；另外它响应时间方差大，headless Firefox 里等 6 秒时 <code>meting-js.aplayer</code> 还没实例化、14 秒才稳，写验证脚本时 <code>--wait</code> 要给够。</p><h3 id="换成 -B- 站收藏夹当歌单：数据拿得到，音频放不出">换成 B 站收藏夹当歌单：数据拿得到，音频放不出</h3><p>想把自己那个收藏夹（<code>fid=1190530554</code>，即 <code>https://www.bilibili.com/list/ml1190530554</code>）挂成歌单、只播音频。分三层，前两层没问题，第三层是死路：</p><table><thead><tr><th>层</th><th>实测</th></tr></thead><tbody><tr><td>收藏夹元数据</td><td>匿名可读。<code>api.bilibili.com/x/v3/fav/folder/info?media_id=1190530554</code> 与 <code>x/v3/fav/resource/list?media_id=&amp;pn=&amp;ps=</code> 都回 <code>code:0</code>，全站 996 条，每条给 <code>bvid</code>/ 标题 / 时长 / 封面 /UP 主。翻页就是 <code>pn</code></td></tr><tr><td>曲目直链</td><td><code>x/player/playurl?bvid=&amp;cid=&amp;fnval=16</code> 匿名可拿，DASH 里 <code>sound</code> 段的 <code>baseUrl</code> 就是纯音频流</td></tr><tr><td>让浏览器播这个流</td><td><strong>不行</strong>。同一个 <code>upos-sz-mirrorcos.bilivideo.com/...m4s</code> 地址：不带 Referer → 403；带 <code>Referer: https://takomitkm.github.io/</code> → 403；带 <code>Referer: https://www.bilibili.com/</code> → 206。UA 换桌面 / 安卓都一样。CDN 回 <code>Access-Control-Allow-Origin</code> 且跟着请求 Origin 走，所以卡的不是 CORS 是 Referer，而浏览器发媒体请求时的 Referer 由页面来源决定，网页脚本改不了（<code>&lt;meta name=&quot;referrer&quot;&gt;</code> 只能收窄、不能伪造成 bilibili）</td></tr></tbody></table><p>meting 端点这一侧也帮不上：<code>api.i-meto.com</code> 对 <code>server=bilibili</code> 直接回 &quot;server 参数不合法 &quot;，<code>api.injahow.cn</code> 回 <code>{&quot;error&quot;:&quot;unknown type&quot;}</code>，两家都没有 bilibili 服务商。<code>Meting.min.js</code> 里的 <code>server</code> 字段是原样拼进请求 URL 的，客户端没有白名单（源码里 <code>netease</code>/<code>tencent</code>/<code>xiami</code> 那几个字符串只出现在 <code>_parse_link</code>，也就是 &quot; 粘链接解析 &quot; 那条分支），所以插件这边改不出花来。</p><p>结论：静态站（GitHub Pages 只有静态托管）做不到，必须有个服务端去带 <code>Referer: https://www.bilibili.com/</code> 转发音频流——Workers / Deno Deploy / 自己那台 Debian 都行，代价是这条链路变成 &quot; 依赖我自己的服务器 &quot;，服务器挂了播放器就哑。唯一不需要中继的路子是官方 iframe：<code>player.bilibili.com/player.html?bvid=&amp;page=1</code> 实测 200、无 <code>X-Frame-Options</code>、<code>Access-Control-Allow-Origin: *</code>，能嵌，但那是整块视频播放器，出画面，APlayer 的进度条和歌词面板都用不上，等于把右下角那块面板换成一个 iframe。</p><h3 id="换成 -B- 站官方 -iframe：biliplayer- 组件">换成 B 站官方 iframe：biliplayer 组件</h3><p>上面那句 &quot; 唯一不需要中继的路子 &quot; 就是最终采用的：收藏夹 <code>ml1190530554</code> 当歌单，逐首用 <code>player.bilibili.com/player.html?bvid=&lt;BV&gt;&amp;page=&lt; 页 &gt;&amp;danmaku=0&amp;high_quality=1&amp;autoplay=1</code> 嵌进右侧栏面板。删掉的文件是 <code>plugin/APlayer.min.js</code>、<code>plugin/Meting.min.js</code>、<code>plugin/md5.min.js</code>、<code>css/_plugin/APlayer.min.css</code>、<code>css/_part/audioplayer.styl</code>、<code>layout/_partial/_part/audioplayer.ejs</code>、<code>source/asset/part/audioplayer.ejs</code>、<code>source/asset/js/part/audioplayer.js</code>，<code>main.js</code> 里两个插件的 import 和 <code>_run_APlayer()</code> 一并清掉，<code>color.styl</code> 的 16 行 <code>--color-audioplayer-*</code> 变量、<code>dimension.styl</code> 的 <code>$audioplayer_height</code>/<code>$audioplayer_list_height</code>、<code>layout.styl</code> 里 <code>:root.lyride .aplayer-lrc</code> 整块都跟着删。新面板的两份模板（<code>layout/_partial/_part/biliplayer.ejs</code> 与 <code>source/asset/part/biliplayer.ejs</code>）字节相同，这是主题 part 的固有要求。</p><p><strong>歌单是构建期快照，不是运行时拉的。</strong> <code>tools/bili_fav_dump.mjs</code> 翻页调 <code>api.bilibili.com/x/v3/fav/resource/list?media_id=1190530554&amp;pn=&amp;ps=20</code>，写成 <code>source/data/bili-playlist.json</code>（收藏夹原始 993 条，核验剔除 12 条后落盘 981 条、194460 字节，每条 <code>bvid/title/duration/page/upper/pubtime</code>，按 <code>pubtime</code> 倒序），面板用 <code>data-playlist</code> 指向它。为什么不让页面自己去拉——三条对照实测：</p><table><thead><tr><th>请求</th><th>结果</th></tr></thead><tbody><tr><td>不带 <code>Origin</code></td><td><code>200</code>，<code>code:0</code> 正常 JSON，响应里 <strong> 没有任何 <code>Access-Control-*</code> 头</strong></td></tr><tr><td><code>Origin: https://www.bilibili.com</code></td><td><code>200</code> + <code>Access-Control-Allow-Origin: https://www.bilibili.com</code>、<code>Access-Control-Allow-Credentials: true</code>，即接口有 Origin 白名单</td></tr><tr><td><code>Origin: https://example.com</code> / <code>Origin: https://takomitkm.github.io</code></td><td><strong><code>403 Forbidden</code></strong>，<code>Server: openresty</code>，回 HTML 错误页而不是 JSON</td></tr></tbody></table><p>浏览器发起的 <code>fetch</code> 一定带 <code>Origin</code>，而且 <code>Origin</code> 是 forbidden header，脚本改不了，所以本站域名去拉这个接口拿到的是 403，连 &quot; 有没有 CORS 头 &quot; 都轮不到判断。要运行时拉就得有自己的服务端，与 &quot; 不要中继 &quot; 这个选型前提冲突，因此固化成构建期文件。<strong>这个白名单不是收藏夹接口特有的 </strong>：后面为了做 &quot; 失效自动跳过 &quot; 而想改问逐条探活的 <code>x/web-interface/view</code> 和 <code>x/player/pagelist</code>，带 <code>Origin: https://takomitkm.github.io</code> 一律 <strong>403</strong>，带 <code>Origin: https://www.bilibili.com</code> 才回 <code>200</code> + <code>Access-Control-Allow-Origin</code> 回显——所以运行时逐条问也是问不到的，检测只能全部放在构建期（见下面 &quot; 失效条目 &quot; 一节）。<strong> 往收藏夹加歌后不会自动出现</strong>，刷新方法是 <code>deno run -A tools/bili_fav_dump.mjs</code>（走 7897 代理）→ <code>deno run -A tools/bili_playlist_fix.mjs</code>（剔失效 + 补时长，见下面两节）→ 重新构建。</p><p><strong>加载完成 3 秒后是一个带取消按钮的倒计时，然后自动开播：</strong></p><p>面板标题是 &quot; 我的歌单 &quot;（英文站 <code>MY PLAYLIST</code>），走 <code>biliplayer.caption</code> 这个语言键，不写死在模板里。</p><table><thead><tr><th>环节</th><th>行为</th></tr></thead><tbody><tr><td>倒计时</td><td>面板遮罩显示 &quot; 即将开始播放我的歌单 &quot;+ 3/2/1，同时给 &quot; 取消自动播放 &quot; 按钮</td></tr><tr><td>归零</td><td><code>play()</code>：挂 iframe，<code>autoplay=1</code>；<strong>此刻页面若没有用户激活，浏览器会让它静音起播</strong>，这是策略不是设置</td></tr><tr><td>页面第一次被点</td><td>静音挂载时 <code>play()</code> 顺手在 <code>window</code> 上挂一次性 <code>pointerdown</code>/<code>keydown</code>（capture）监听：页面被点下第一下的那一刻，用 <code>&amp;t=&lt; 本地算出的已播秒数 &gt;</code> 原位重挂同一个 <code>index</code>，位置不丢、声音自己接上。已经有激活时这个监听压根不挂，不会给本来有声的人多 reload 一次。上一版这里是一个 &quot; 开启声音 &quot; 按钮，等于把活儿推给访客，删了</td></tr><tr><td>取消自动播放</td><td><code>settings.set('autoplay', false, true)</code> → 落 <code>config.autoplay</code>，卸掉 iframe、遮罩切手动模式显示 &quot; 播放 &quot; 按钮；重载后仍是手动</td></tr><tr><td>上一首 / 下一首</td><td><code>index</code> 环形自增；已挂载就整块换 <code>src</code> 重新 <code>play()</code>，没挂载只更新标题</td></tr><tr><td><code>×</code></td><td><code>stop()</code> 后给面板本身加 <code>.HIDE</code>，整块关掉——这就是 &quot; 做不到时改成可点击关闭 &quot; 的兜底，两条都留了</td></tr></tbody></table><p><code>danmaku=0</code> 是关弹幕（headless 里弹幕 socket 六种握手全被零字节关闭，嵌进来的 iframe 也拿不到，留着只会转圈）。官方 iframe 没有 postMessage 控制接口，所以进度、音量、播放暂停全在那个 iframe 内部，外层只能整块换 <code>src</code>，上下首必须由自己做。</p><h3 id="手机端：B- 站播放器接进一言面板当底图">手机端：B 站播放器接进一言面板当底图</h3><p>面板本体在 <code>.m-aside</code> 里，位置在手机上其实是能看见的（<code>.m-aside</code> ≤1023px 是整幅宽、排在正文之后）；看不见的原因是 <code>biliplayer.init()</code> 只写在 <code>main.js</code> 的 desktop 分支，手机分支里从没跑过，<code>.p-biliplayer</code> 一直带着 <code>HIDE</code>。现在手机分支的 checklist 里加上 <code>biliplayer</code>，并补了 <code>settings.init</code> 的 <code>autoplay</code> 分支（原来也只有 desktop 有）。</p><p>但 &quot; 放到右栏底部 &quot; 不是要的。要的是 <strong> 拿播放器替掉一言面板里那张 <code>/asset/img/qf3cu.huaji.png</code>，让它当面板的背景</strong>——所以手机分支的 checklist 之后多搬一次 DOM：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> panel  = <span class="variable language_">document</span>.<span class="title function_">querySelector</span>(<span class="string">&#x27;.p-hitokoto&#x27;</span>);</span><br><span class="line"><span class="keyword">let</span> player = <span class="variable language_">document</span>.<span class="title function_">querySelector</span>(<span class="string">&#x27;.p-biliplayer&#x27;</span>);</span><br><span class="line"><span class="keyword">let</span> content = <span class="variable language_">document</span>.<span class="title function_">querySelector</span>(<span class="string">&#x27;.m-content&#x27;</span>);</span><br><span class="line">content.<span class="property">parentElement</span>.<span class="title function_">insertBefore</span>(panel, content);   <span class="comment">// 面板先挪出 .m-content</span></span><br><span class="line">panel.<span class="title function_">insertBefore</span>(player, panel.<span class="title function_">querySelector</span>(<span class="string">&#x27;img&#x27;</span>)); <span class="comment">// 播放器接到 &lt;img&gt; 原来的位置</span></span><br><span class="line">panel.<span class="property">classList</span>.<span class="title function_">add</span>(<span class="string">&#x27;p-hitokoto-bgm&#x27;</span>);</span><br></pre></td></tr></table></figure><p><strong>为什么要多挪一步面板、而不是直接把播放器塞进 <code>.p-hitokoto</code>：</strong> pjax 会整块换掉 <code>.m-content</code> 的 <code>innerHTML</code>（<code>main.js</code> 的 <code>pjax.run</code>）。播放器一旦待在里面，下一次站内跳转连同 iframe 一起被销毁，<code>biliplayer.js</code> 里缓存的 <code>element</code> 就指向一个脱节点的 detached 元素，播放器再也不会回来。所以先把面板本身搬到 <code>.m-content</code> <strong>外面</strong>（<code>.m-main</code> 里、<code>.m-content</code> 之前）——它在 pjax 的替换范围之外，面板和播放器一起活下来。视觉位置不变：<code>.p-translater</code> 在手机上本来就是 <code>display: none</code>，面板挪到 <code>.m-content</code> 前面仍然排在正文最上面。</p><p>代价是面板躲开替换之后，显示与否得自己按路径管。<code>final_load()</code> 开头加了一段 <code>util.runOnMobile</code>：按 <code>pathname()</code> 给面板 <code>toggle('HIDE')</code>（只有 <code>/</code> 和 <code>/index.html</code> 显示，和原来 &quot; 一言只在首页 &quot; 一致），并把 <code>.m-content</code> 里 pjax 带进来的那份没人初始化的旧面板 <code>remove()</code> 掉——不删的话 <code>part()</code> 的 <code>querySelector</code> 有可能挑到那一份。</p><p>配套的样式（<code>hitokoto.styl</code> 的 ≤1023px 一段）全部挂在 <code>.p-hitokoto.p-hitokoto-bgm</code> 底下，class 由上面那段 JS 加。这么写是因为 **“窄窗口 + 桌面 UA”** 这种组合：CSS 断点是按宽度的、JS 分支是按 UA 的，桌面用户把窗口拖到 1023px 以下时 JS 不会搬，CSS 却已经生效——不加 class 的话那块面板就变成图没了播放器也没来，空白一块。</p><p>面板内部三处跟着变：<code>&lt;img&gt;</code> 让位给播放器（<code>.p-hitokoto-bgm &gt; img {display: none}</code>）；<code>.p-hitokoto-from</code>（作者行）从 <code>position: absolute</code> 改回常规流——原来它靠绝对定位压在图的下缘，视频比图高得多，继续绝对定位会整个盖在画面上；<code>.p-biliplayer-caption</code>（&quot; 我的歌单 &quot;＋×那条）隐藏，面板自己已经有一言的标题了，两条标题栏叠着没意义。</p><p><strong>手机上没有关闭入口，这是有意的。</strong> <code>config.autoplay</code> 的默认值是 <code>false</code>，所以手机访客看到的是遮罩里的 &quot; 播放 &quot; 按钮、不会被倒计时自动开播（“倒计时 &quot; 那条只在 <code>autoplay: true</code> 时跑，见下）。既然不会自己响，就没有需要关掉的东西，最早做的那一栏 &quot; 关闭 bgm”（<code>.p-biliplayer-offbar</code>，连同 <code>biliplayer.closebgm</code> 文案）也就没必要了，删掉。代价是访客手动点 &quot; 播放 &quot; 之后，这一页里没法把播放器收起来——只能等自动切歌或者离开页面。桌面版不受影响，标题栏右边那个 <code>×</code> 照旧是整块 <code>HIDE</code>。</p><p>实测（UA 钉 iPhone、1000×700）：面板的 class 是 <code>p-hitokoto p-hitokoto-bgm</code>，父节点 <code>.m-main</code>，子节点顺序 <code>caption → word → from → .p-biliplayer → img</code>；<code>&lt;img&gt;</code> 计算样式 <code>display: none</code>，播放器 985×633，<code>.p-biliplayer-caption</code> <code>display: none</code>，页面上不存在 <code>.p-biliplayer-offbar</code>。把 <code>config.autoplay</code> 设成 <code>true</code> 再刷一次，遮罩里照旧是 &quot; 即将开始播放我的歌单 / 1 / 取消自动播放 &quot;，倒计时没受影响。</p><p>pjax 往返实测：首页 → 文章页，面板 <code>display: none</code>、<code>.p-hitokoto</code> 始终只有 1 个；再回首页，面板重新 <code>display: block</code>、播放器 985×633、图片仍然让位。<code>documentElement.scrollWidth 985 &lt; 1000</code>，不撑文档宽度。</p><p>反过来 &quot; 窄窗口 + 桌面 UA&quot; 实测：面板 class 只有 <code>p-hitokoto</code>（没有 <code>-bgm</code>），图片 <code>display: block</code> 985×523 照常显示，播放器留在右栏（256×208）、只有标题栏那个 <code>×</code>——和改动前一样。</p><p>这里没有顺手把 <code>hitokoto.init()</code> 也跳过：<code>final_load()</code> 的 checklist 是按 <code>meta[layout-parts]</code> 里列的 part 建的，首页的 parts 含 <code>hitokoto</code>，不调它 <code>checklist.hitokoto</code> 永远是 <code>false</code>，那个 600ms 的轮询就永远凑不齐，<code>activateSpinner(false)</code> 不会执行、页面卡在加载态（同一个坑在本篇 &quot; 加载界面 &quot; 一节记过）。代价是手机首页仍会打一次 <code>v1.hitokoto.cn</code> 的请求。</p><h2 id="标题和分类的颜色">标题和分类的颜色</h2><p><code>--color-post-title</code> / <code>--color-post-categories</code> 两个变量加在 <code>_common/color.styl</code> 的四块里（<code>:root</code> / <code>:root.gray</code> / <code>:root.colorful</code> / <code>:root.night</code>），值分别是 <code>#000000</code> 和 <code>#999999</code>——只有夜间那块标题给 <code>#cccccc</code>，因为夜间背景是纯黑，写死黑字会看不见（<code>.p-post-ending</code> 就是这么处理的，<code>night</code> 下给 <code>#444444</code>）。</p><p>用在三处：<code>.p-recentpost-title a</code>、<code>.p-timeline-title a</code>（列表里的文章标题，原来直接吃 <code>a {color: var(--color-link) }</code> 的橙红 <code>#ff3300</code>），以及 <code>.p-post-categories</code>（文章页头部那一栏，连里面的分类链接一起 <code>color: inherit</code>）。桌面端 hover 时标题底下那道下划线也跟着从 <code>--color-link</code> 换成 <code>--color-post-title</code>，免得黑字配橙线。</p><p><strong>文章页那个大标题 <code>.p-post-title</code> 没动</strong>——亮色皮肤下它本来就是 <code>--color-text</code> = <code>#000</code>，已经是黑的。用户确认过指的是列表里的标题。</p><p>顺带把 <code>.p-recentpost-categories</code>（首页列表每条下面的分类）也压成灰色，和文章页那一栏一致；<code>--color-post-categories</code> 在四套皮肤里全是 <code>#999999</code>，夜间背景纯黑上这个灰也是够的。</p><p>实测（彩色皮肤下）：<code>.p-recentpost-title a</code> 和 <code>.p-timeline-title a</code> 是 <code>rgb(0, 0, 0)</code>，<code>.p-recentpost-categories</code> 和 <code>.p-post-categories</code>（含里面的 <code>a</code>）是 <code>rgb(153, 153, 153)</code>，同行的 <code>.p-post-date span</code> 仍是 <code>rgb(0, 0, 0)</code>——只有分类那行被压下去了。</p><h2 id="顶栏站名是个回首页的链接">顶栏站名是个回首页的链接</h2><p><code>.p-sitename</code> 那一栏（图标 + <code>takomitkm 的自留地</code>）每个页面都有、每个断点都在（<code>.m-header</code> 是 fixed 的），原来纯展示。整条包进 <code>&lt;a class=&quot;p-sitename-link&quot; href=&quot;/&quot;&gt;</code>，两份模板（<code>layout/_partial/_part/sitename.ejs</code> 与 <code>source/asset/part/sitename.ejs</code>）字节相同，这是主题 part 的固有要求。</p><p>样式两处：<code>.p-sitename-link</code> 要 <code>color: inherit</code>，不然会吃到全局 <code>a {color: var(--color-link) }</code> 变成橙红；再加 <code>display: inline-block; max-width: 100%</code> 配 <code>overflow-x/white-space/text-overflow</code> 那一套，让父级 <code>.p-sitename</code> 的省略号在长标题时仍然生效（换了一层包裹就断在这层了）。桌面 hover 时 &quot; 文字换成图标 &quot; 那套靠的是 <code>.p-sitename:hover</code> 下的后代选择器，图标和文字都在 <code>&lt;a&gt;</code> 里，不受影响。</p><p>不用自己写跳转：<code>listen2Links()</code> 会给所有 <code>a:not([target=&quot;_blank&quot;])...</code> 绑 <code>linksStore.setClick</code>，走 pjax。已经在首页时 <code>url === window.location.href</code>，点它不触发导航（直接 return）。</p><p>实测（1000×700）：<code>.p-sitename-link</code> 是 <code>A</code>，<code>href=&quot;/&quot;</code> 解析成 <code>http://127.0.0.1:4321/</code>，<code>getComputedStyle</code> 的颜色是 <code>rgb(51, 51, 51)</code>，和 <code>.m-header</code> 一致（不是 <code>var(--color-link)</code>），图标和文字都在链接内，pjax 已绑上（<code>data-listened=&quot;true&quot;</code>）。在 <code>/posts/2659281223/</code> 点它，2.5 秒后 <code>location.pathname</code> 变成 <code>/</code>。构建产物里 16 个完整页面全部带这个 <code>&lt;a&gt;</code>；<code>asset/part/*.html</code> 那 31 个片段不带是应该的，它们本来就没有头部。</p><h2 id="隐藏播放器">隐藏播放器</h2><p>设置里原来那个 <code>lyride</code>（标签 &quot; 隐藏歌词 &quot;）是个 <strong> 空壳</strong>：歌词面板早跟着 APlayer/Meting 一起删了，它只干一件事——给 <code>.p-footer</code> 加 12px／20px 的 <code>padding-bottom</code>（当初给歌词条留位置）。所以这个开关按下去什么也看不见，但 <code>:root.lyride</code> 确实每页都在。</p><p>现在整个重做成 &quot; 隐藏播放器 &quot;：键从 <code>lyride</code> 改成 <code>hideplayer</code>，<code>_config.yml</code> 的图标键、两个 <code>lang</code> 的文案、<code>config.js</code> 的默认值（<code>true</code> → <code>false</code>，原来默认是开的，现在默认显示）、<code>main.js</code> 两个分支的 handler 一起换掉。旧的 <code>:root.lyride .p-footer</code> 规则删掉。</p><p>真正干活的是 <code>biliplayer.js</code> 新导出的 <code>hidden(flag)</code>，<code>flag</code> 是 &quot; 要藏起来 &quot; 的意思：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title function_">hidden</span> = flag =&gt; &#123;</span><br><span class="line">  <span class="keyword">if</span> (!element) <span class="keyword">return</span>;</span><br><span class="line">  <span class="keyword">let</span> panel = element.<span class="title function_">closest</span>(<span class="string">&#x27;.p-hitokoto&#x27;</span>);</span><br><span class="line">  panel &amp;&amp; panel.<span class="property">classList</span>.<span class="title function_">toggle</span>(<span class="string">&#x27;p-hitokoto-restored&#x27;</span>, flag);</span><br><span class="line">  <span class="keyword">if</span> (flag) &#123; <span class="title function_">stop</span>(); element.<span class="property">classList</span>.<span class="title function_">add</span>(<span class="string">&#x27;HIDE&#x27;</span>); &#125;</span><br><span class="line">  <span class="keyword">else</span> &#123; element.<span class="property">classList</span>.<span class="title function_">remove</span>(<span class="string">&#x27;HIDE&#x27;</span>); &#125;</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>两点值得记：</p><ol><li><strong>手机版要连带把图还回去。</strong> 播放器在手机版是接在 <code>.p-hitokoto</code> 里 <code>&lt;img&gt;</code> 的原位上的（见 &quot; 播放器 &quot; 一节），只给播放器加 <code>HIDE</code> 会在面板底部留一块空白——<code>.p-biliplayer</code> 是 <code>HIDE</code>（<code>display: none !important</code>）而 <code>&lt;img&gt;</code> 已经被 <code>.p-hitokoto-bgm &gt; img {display: none}</code> 藏起来了。所以顺带切 <code>.p-hitokoto-restored</code>，CSS 里补一条 <code>&amp;.p-hitokoto-restored &gt; img {display: block}</code>。桌面版播放器在右栏、<code>&lt;img&gt;</code> 照常显示，<code>closest('.p-hitokoto')</code> 是 <code>null</code>，这两句都不执行。</li><li><strong>桌面版要重算布局。</strong> 右栏少一块，<code>aside</code> 的高度和滚动偏移都变了，所以 desktop 分支里照原来 <code>lyride</code> 的做法调 <code>scrolling(); fixMainHeight();</code>（两个函数内部都有 <code>runOnDesktop</code> 保护，mobile 分支没加）。</li></ol><p>顺手把标题栏那个 <code>×</code> 也改成走 <code>hidden(true)</code>，和设置项共用一条路径。</p><h3 id="顺带把老访客存下来的废键清掉">顺带把老访客存下来的废键清掉</h3><p><code>lyride</code> 改名成 <code>hideplayer</code> 之后，老访客 <code>localStorage['config']</code> 里那个键并不会自己消失，原因是 <code>config.js</code> 原来那句 <code>Object.assign({默认表}, 已存)</code> 会把 <strong> 已存对象里默认表没有的键原样并进 <code>config</code></strong>，而 <code>set()</code> 每次都把整个合并结果写回 storage（<code>storage.set('config', config)</code>）——于是废键被一次又一次地抄回去，永远清不掉。<code>localStorage['configChosen']</code> 那个数组同理，里面记着的 <code>'lyride'</code> 也留着。</p><p>改成只挑默认表里有的键：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title function_">isKnown</span> = key =&gt; key <span class="keyword">in</span> defaults;</span><br><span class="line"><span class="keyword">const</span> raw = storage.<span class="title function_">get</span>(<span class="string">&#x27;config&#x27;</span>) || &#123;&#125;;</span><br><span class="line"><span class="keyword">const</span> keys = <span class="title class_">Object</span>.<span class="title function_">keys</span>(raw).<span class="title function_">filter</span>(isKnown);</span><br><span class="line"><span class="keyword">const</span> kept = &#123;&#125;;</span><br><span class="line">keys.<span class="title function_">forEach</span>(<span class="function"><span class="params">key</span> =&gt;</span> kept[key] = raw[key]);</span><br><span class="line"><span class="keyword">if</span> (keys.<span class="property">length</span> !== <span class="title class_">Object</span>.<span class="title function_">keys</span>(raw).<span class="property">length</span>) storage.<span class="title function_">set</span>(<span class="string">&#x27;config&#x27;</span>, kept);</span><br><span class="line"><span class="keyword">const</span> config = <span class="title class_">Object</span>.<span class="title function_">assign</span>(defaults, kept);</span><br></pre></td></tr></table></figure><p><code>configChosen</code> 也 <code>filter(isKnown)</code> 一遍。<strong>只在真的挑掉东西时才写那一次 storage</strong>，所以配置本来就干净的访客不会因为这一行多写一次。</p><p>实测（先手工塞一份 &quot; 老访客 &quot; 配置进去再刷新）：</p><table><thead><tr><th>检查项</th><th>结果</th></tr></thead><tbody><tr><td>塞进去的 <code>config</code></td><td><code>{&quot;closeDrawer&quot;:true,...,&quot;skin&quot;:&quot;gray&quot;,&quot;langshift&quot;:true,&quot;night&quot;:true,&quot;transfigure&quot;:false,&quot;lyride&quot;:true,&quot;autoplay&quot;:false,&quot;translater&quot;:&quot;en&quot;,&quot;someForgottenKey&quot;:&quot;x&quot;}</code></td></tr><tr><td>塞进去的 <code>configChosen</code></td><td><code>[&quot;skin&quot;,&quot;night&quot;,&quot;lyride&quot;]</code></td></tr><tr><td>刷新后 <code>config</code></td><td><code>{&quot;closeDrawer&quot;:true,...,&quot;skin&quot;:&quot;gray&quot;,&quot;langshift&quot;:true,&quot;night&quot;:true,&quot;transfigure&quot;:false,&quot;autoplay&quot;:false,&quot;translater&quot;:&quot;en&quot;}</code></td></tr><tr><td>刷新后 <code>configChosen</code></td><td><code>[&quot;skin&quot;,&quot;night&quot;]</code></td></tr><tr><td>访客真选过的</td><td><code>skin: &quot;gray&quot;</code>、<code>night: true</code>、<code>langshift: true</code>、<code>translater: &quot;en&quot;</code> 全在</td></tr><tr><td>应用结果</td><td><code>html</code> class 是 <code>night closeDrawer gray</code>，灰度色块和夜间开关都是 <code>active</code></td></tr><tr><td><code>hideplayer</code></td><td>没进 storage（没选过就不该有），回落默认 <code>false</code>，播放器 <code>display: block</code></td></tr></tbody></table><p>顺带一个副作用是好的：以后再改任何设置键的 <strong> 名字</strong>，老访客那份不用等下一次 <code>set()</code> 才清掉，第一次打开页面就被挑走了。</p><p>清空 storage 当新访客跑了一遍：什么都不写（<code>config</code>/<code>configChosen</code> 仍是 <code>null</code>），开屏 3 秒自动切彩色照旧（<code>html</code> class <code>colorful</code>），进度条 100%，播放器显示。</p><p>实测：全新配置（清掉 localStorage 再刷）播放器是 <code>display: block</code>；点一下 → class <code>p-biliplayer HIDE</code>、<code>display: none</code>、iframe 已摘掉、<code>configChosen</code> 里记上 <code>hideplayer</code>、<code>config.hideplayer === true</code>；再点 → 回来、<code>false</code>；刷新后仍然保持隐藏、开关是 <code>active</code> 状态。</p><h2 id="划词翻译的底色改成不透明">划词翻译的底色改成不透明</h2><p>划词条 <code>.p-translater-bar</code> 和结果框 <code>.p-translater-result</code> 原来都用 <code>var(--color-background)</code>，也就是那套 50% 半透明的白底（夜间是 <code>#000000cc</code>）。它要压在正文上面、结果框还会顺着选区弹出来盖到图上，半透明底读不清，所以单独给一个不透明的变量 <code>--color-translater-back</code>：三套亮色皮肤 <code>#ffffff</code>，夜间 <code>#222222</code>（和夜间头部同色）。</p><p>实测四套皮肤读 <code>getComputedStyle</code>：<code>.p-translater-bar</code> 和 <code>.p-translater-result</code> 都是 <code>rgb(255, 255, 255)</code>，夜间 <code>rgb(34, 34, 34)</code>；同一页面上 <code>.p-post</code> 仍是 <code>rgba(255, 255, 255, 0.5)</code>，确认只有这两块脱离了半透明那一档。</p><h2 id="站名改成 - 自留地">站名改成 &quot; 自留地 &quot;</h2><p><code>个人网站 </code> / <code>personal blog</code> 这个说法换成 <code> 自留地</code>，动了三处：</p><table><thead><tr><th>位置</th><th>改动</th></tr></thead><tbody><tr><td><code>themes/mustom/source/asset/lang/zh-cn.yml</code> 的 <code>sitename</code></td><td><code>takomitkm 的个人网站</code> → <code>takomitkm 的自留地</code>（页面顶栏显示的就是它）</td></tr><tr><td><code>themes/mustom/source/asset/lang/en.yml</code> 的 <code>sitename</code></td><td><code>takomitkm's Personal Website</code> → <code>takomitkm's 自留地 </code>。英文那份没另起一个词：<code> 自留地</code> 是这个站的身份而不是一句英文描述，英文站也用同一个名字，免得中英两套叫法</td></tr><tr><td><code>_config.yml</code> 的 <code>description</code></td><td><code>personal blog</code> → <code>自留地</code>。它同时进 <code>&lt;meta name=&quot;description&quot;&gt;</code> 和各页 <code>&lt;title&gt;</code> 的后半截（<code>Home / 首页 - 自留地</code>）</td></tr></tbody></table><p><code>sitename</code> 只在 <code>data-lang=&quot;sitename&quot;</code> 上，运行时从 <code>asset/lang/*.yml</code> 取，不进构建产物，所以改 yml 就够；<code>description</code> 是构建期常量，必须重新 <code>hexo generate</code>。</p><p>实测：<code>document.title</code> 是 <code>Home / 首页 - takomitkm</code>（<code>_config.yml</code> 的 <code>keywords</code> 里那个 <code>home, 首页, </code> 前缀没动），顶栏 <code>.p-sitename-text</code> 的 <code>innerText</code> 是 <code>takomitkm 的自留地</code>。</p><p><strong>默认没声音，不是浏览器里存了什么东西，是自动播放策略。</strong> 真人访客的 Firefox 打开这个页面：加载完 3 秒倒计时归零、歌直接开始放，但 <strong> 是静音的 </strong>（<code>muted:true, paused:false</code>），这和 headless 冷 profile 的表现一致。原因在浏览器：页面从头到尾没有被真实点击过，<code>navigator.userActivation.hasBeenActive</code> 是假，Firefox 就不允许未静音自动播放，B 站播放器于是自己把 <code>&lt;video&gt;</code> 挂成静音。浏览器里存的 cookie、历史、媒体参与度（media engagement，浏览器给常访站点放宽自动播放的那套内部计分）只会让策略更宽松，不会让 &quot; 没激活 &quot; 变成 &quot; 有激活 &quot;。<strong> 这里更正一次：上一版我写过一句 &quot; 真人浏览器实测默认有声 &quot;，那是把一条指令当成了测量结果，出处是编的，实际方向正好相反。</strong></p><p>方向定了，能做的就清楚了：<strong>声音不是 &quot; 设 &quot; 出来的，是 &quot; 激活之后重新挂一次 &quot; 拿回来的</strong>。同一个 <code>player.bilibili.com</code> iframe，四种挂载路径逐一实测（探针在本地页面直接挂 iframe，<code>input.performActions</code> 造真实点击）：</p><table><thead><tr><th>挂载方式</th><th><code>&lt;video&gt;</code> 读到的</th></tr></thead><tbody><tr><td>无任何交互直接挂</td><td><code>muted:true</code>、<code>paused:false</code>、<code>currentTime</code> 正常走 —— <strong>静音播放</strong></td></tr><tr><td>顶层点过一下，再新建 iframe 挂</td><td><code>muted:false</code>、<code>paused:false</code></td></tr><tr><td>顶层点过一下，改已有 iframe 的 <code>src</code>（原地重挂）</td><td><code>muted:false</code>、<code>paused:false</code></td></tr><tr><td>顶层点过一下，删掉元素再重建</td><td><code>muted:false</code>、<code>paused:false</code></td></tr></tbody></table><p>第三条最关键：<strong>不需要销毁重建，只要换 <code>src</code> 重新加载那一个 iframe</strong>，声音就回来了。所以 &quot; 要声音 &quot; 这件事不需要访客去找按钮——组件自己在静音挂载时等页面第一次被点，点下的瞬间 <code>play(elapsed())</code>：用本地记的 <code>startedAt</code> 算出已经播了几秒，带着 <code>&amp;t=&lt; 秒 &gt;</code> 重挂同一个 <code>index</code>。之前删掉这套兜底是因为我把 &quot; 默认有声 &quot; 当成了事实，静音兜底就成了反向操作；前提反过来之后它是正着用的，但形态要改：让访客去点一个 &quot; 开启声音 &quot; 等于把浏览器策略的账算到访客头上，现在把它做成自动的，访客点什么都是它，甚至点页面别的地方也算。</p><p>顺手把静音参数也试了一遍，只有一个拼法有效：</p><table><thead><tr><th>试的参数</th><th>结果（<code>&lt;video&gt;</code> 读到的）</th></tr></thead><tbody><tr><td><code>&amp;mute=1</code></td><td><strong>无效</strong>，<code>muted:false</code></td></tr><tr><td><code>&amp;muted=1</code></td><td>有效，<code>muted:true</code></td></tr><tr><td>顶层有激活 + 只带 <code>autoplay=1</code></td><td><code>muted:false</code>、<code>currentTime</code> 正常递增</td></tr><tr><td>播放器往父窗口 <code>postMessage</code></td><td>在顶层挂 <code>message</code> 监听，整个播放过程收到 <strong>0 条</strong></td></tr></tbody></table><p>最后一行决定了 &quot; 放完自动下一首 &quot; 只能怎么做：<strong>外层收不到 <code>ended</code>，也不知道实时进度，只能按清单里每条自带的 <code>duration</code> 自己掐表</strong>。</p><p><strong>会话内固定一首，新会话才随机。</strong> <code>init</code> 拿到清单后先读 <code>sessionStorage</code> 的 <code>biliplayer:index</code>：在且没越界就用它，否则随机挑一首并写回去。<code>上一首 / 下一首</code> 和自动切歌都会把新 <code>index</code> 写回 sessionStorage，所以同一个标签页里刷新始终是同一首，关掉标签页重开才换。存储选择 sessionStorage 而不是 localStorage，是因为要的就是 &quot; 跟着会话走 &quot;，<code>config</code> 那套 localStorage 记忆是给跨会话的偏好用的，不该混。</p><p><strong>放完自动接下一首：先是根本没切，查出来是时长造假。</strong> 第一版按清单每条自带的 <code>duration</code> 下了一次性 <code>setTimeout</code>，实测在 headless 里用那首 12 秒的能正常切，但真人那边 &quot; 播完不动 &quot;。翻了清单才发现根因：收藏夹接口给的 <code>duration</code> 是 <strong> 整个稿件 </strong> 的时长，而多 P 合集的总时长离谱得很——<code>BV18s411o7wn</code> 那条写着 <strong>6,854,609 秒（79 天）</strong>，<code>BV1zx411P7os</code> 是 3,638,302 秒，<code>BV1Ms411m7Pf</code> 是 172,557 秒。嵌进去的是其中某一个 P（<code>&amp;page=&lt; 收藏夹记的那一 P&gt;</code>，实际两三分钟），表却按 79 天去掐，等于永远不会切。清单 993 条里时长超 600 秒的 36 条，<strong>30 条是多 P 合集</strong>，全部需要按 P 校正。</p><p>校正工具是 <code>tools/bili_playlist_fix.mjs</code>：读 <code>bili-playlist.json</code>，逐条请求 <code>api.bilibili.com/x/web-interface/view?bvid=</code>（不带 <code>Origin</code> 就能 200，理由见上面接口那一节），拿 <code>pages[]</code> 里对应 <code>page</code> 的那一段时长替换掉总时长，同时把放不出来的剔掉。第一次只挑时长超 600 秒的 36 条跑，改了 30 条、0 失败（<code>BV18s411o7wn</code> P2878 <code>6854609 → 1419</code>、<code>BV1Ch411S7HH</code> P198 <code>46340 → 300</code>、<code>BV1NU4y1K72S</code> P66 <code>14816 → 35</code>）；改成全量 993 条之后又发现 <strong>69 条 </strong> 时长不对（不止超 600 的那批，多 P 合集里也有整包几千秒、单 P 三四分钟的），这轮连同剔 12 条一起落盘，清单从 993 变成 <strong>981 条</strong>，校正后只剩 10 条超过 10 分钟，最长的是 <code>BV1ZS4y1z7gM</code> 单 P 12186 秒。它就地改写 JSON 并记 <code>checked_at</code>；重新导清单之后要再跑一次它。</p><p><strong>“为什么不放完就换、非要挂个时间”</strong> —— 因为播放器根本不会自己换，而且它连 &quot; 放完了 &quot; 都不说。两条都是实测：想让它接管歌单，把收藏夹 ID 按 <code>mid=</code> 传给 <code>player.bilibili.com</code>，<code>&lt;video&gt;</code> 加载的是播放器自带的 <code>error.mp4</code>（17 秒，标题 “External Player - 哔哩哔哩嵌入式外链播放器”），<code>mid</code> 根本不是它的播放列表参数；想让它至少放完同一个稿件的下一 P，拿一个 66 P 合集挂 <code>&amp;page=65</code> 从头等到尾（用 <code>&amp;t=185</code> 快进到片尾也验过一次），P65 结束后停在 &quot; 进入哔哩哔哩，观看更高清 / 重播 &quot;，<strong>不会滚进 P66</strong>。加上顶层 <code>message</code> 监听收到 0 条——放完既不接、也不通知，那 &quot; 什么时候算放完 &quot; 就只能外层自己算，而算的依据只能是清单里那条时长。这也回答了 &quot; 时长测了有什么用 &quot;：时长不是我去测出来的额外指标，它本来就随清单一起给，我要做的是把接口给错的那个值改成对的，否则切歌点会被推到 79 天以后。</p><p>时钟本身也从 &quot; 一次性 <code>setTimeout</code>&quot; 改成 <strong> 记一个截止时间 <code>deadline</code>、每 2 秒对一次表</strong>：后台标签页的定时器会被浏览器钳制，一次性长定时可能被整体推迟，而对表最坏只迟一个 tick。<code>play(at)</code> 里 <code>deadline = now + (duration - at)*1000 + 4000</code>（4 秒给起播缓冲，宁可晚切不要漏切），带 <code>&amp;t=</code> 重挂时按剩余时长重算，所以第一次点击换来的有声续播也不会把切歌点推后；<code>stop()</code> 清掉这个 interval。仍是纯本地时钟——播放器里手动拖进度条外层不知道，切歌点会偏，没有 postMessage 这就是上限。</p><p><strong>移动端不出这块面板。</strong> <code>biliplayer.init()</code> 只写在 <code>main.js</code> 的 desktop 分支里，移动端那条 checklist 根本没有 <code>biliplayer</code> 这一项，所以面板在手机上既不初始化也不显示（模板自带的 <code>.p-biliplayer HIDE</code> 永远不会被去掉）。<code>biliplayer.ready()</code> 虽然在完成块里无条件调用，但 <code>element</code> 为 <code>null</code> 时 <code>start()</code> 直接 return，无副作用。</p><p>实测（headless Firefox，本地 <code>public/</code> 起服务；切歌这类要等真分钟数的，临时把 <code>public/data/bili-playlist.json</code> 换成三首的定点清单——第 0 首是那首 12 秒的，验完再还原）：</p><table><thead><tr><th>检查</th><th>结果</th></tr></thead><tbody><tr><td>预置 <code>sessionStorage</code> = 320 后加载</td><td>播的就是 <code>BV1AL4y1z7Us</code>（12 秒那首），<code>sess:320</code></td></tr><tr><td>同一会话内重新加载</td><td>还是 <code>BV1AL4y1z7Us</code>，<code>sess:320</code> —— 会话内粘住</td></tr><tr><td>清掉 sessionStorage 再加载</td><td>随机到 <code>sess:306</code>（<code>BV1Xq4y1u7wx</code>）—— 新会话才重抽</td></tr><tr><td>冷挂载自动开播（无交互）</td><td><code>muted:true</code>、<code>paused:false</code>、<code>ct:1.8</code>，标题栏里 <strong> 没有</strong>&quot; 开启声音 &quot; 按钮（DOM 查无此节点）</td></tr><tr><td>在面板外的标题文字上打一次真实点击</td><td><code>src</code> 自己变成 <code>…&amp;t=6</code>，iframe 里 <code>muted:false</code>、<code>paused:false</code>、<code>ct:9.4</code> —— 没人点按钮，位置也没丢</td></tr><tr><td>再等 8 秒</td><td><code>src</code> 一字不变、<code>ct:18</code> —— 只重挂这一次，没有 reload 循环</td></tr><tr><td>12 秒 + 4 秒后</td><td>自动换成第 1 首 <code>BV1kF411e71x</code>，<code>sess:1</code>，新 iframe <code>muted:false</code>、<code>ct:1.9</code> —— 切过去的仍是带声的</td></tr><tr><td>重新加载</td><td><code>sess</code> 保持 1，不回到随机</td></tr><tr><td>面板标题</td><td><code>我的歌单</code> / 英文站 <code>MY PLAYLIST</code>，倒计时提示同步改成 &quot; 即将开始播放我的歌单 &quot;</td></tr><tr><td>点 &quot; 取消自动播放 &quot;</td><td>遮罩切 <code>manual</code>、显示 <code>PLAY</code>，<code>config.autoplay:false</code> 落盘；关掉页面重开依旧是手动</td></tr><tr><td>点 <code>×</code></td><td>面板 <code>.HIDE</code>，<code>getBoundingClientRect()</code> 全 0</td></tr></tbody></table><p><strong>失效条目：运行时全瞎，那就构建期逐条问。</strong> 清单里本来有 6 条标题写着 &quot; 已失效视频 &quot;，随机到就是黑屏，而外层跨域读不到 iframe 内部、播放器又不 postMessage，运行时没有任何一条渠道知道 &quot; 这一条放不出来 &quot;（<code>x/web-interface/view</code> 带本站 Origin 也是 403，连问都问不到）。能做的只有把它挪到构建期：<code>tools/bili_playlist_fix.mjs</code> 现在不再只挑时长异常的，而是把 993 条 <strong> 全部 </strong> 过一遍 <code>x/web-interface/view</code>（不带 <code>Origin</code> 就能 200），凡是回非 0 内容状态码的一律不进口袋。为什么 &quot; 非 0 就剔 &quot; 是站得住的——把这几种码各自挂进嵌入播放器实测，<code>&lt;video&gt;.currentSrc</code> 读到的都是同一个东西：</p><table><thead><tr><th>view 接口回的码</th><th>含义</th><th>嵌入播放器里 <code>&lt;video&gt;</code> 实际读到的</th></tr></thead><tbody><tr><td><code>0</code></td><td>正常</td><td><code>blob:https://player.bilibili.com/…</code>、<code>duration:11.666</code>、<code>paused:false</code>（对照组 <code>BV1AL4y1z7Us</code>）</td></tr><tr><td><code>-404</code></td><td>啥都木有（稿子没了）</td><td><code>https://s1.hdslb.com/bfs/static/player/media/error.mp4</code>，<code>duration:16.74</code></td></tr><tr><td><code>62002</code></td><td>稿件不可见</td><td>同一个 <code>error.mp4</code>，16.74 秒</td></tr><tr><td><code>-403</code></td><td>访问权限不足（禁止外链 / 权限受限）</td><td>同一个 <code>error.mp4</code>，16.74 秒</td></tr></tbody></table><p>也就是说 &quot; 失效 &quot; 不止标题那 6 条：<code>error.mp4</code> 是播放器自己塞进来的 17 秒短片，标题看着完全正常、<code>code</code> 非 0 的条目同样是在放这 17 秒，而外层按清单时长（比如 1419 秒）去掐表，等于黑屏播二十多分钟还不切。跑完一轮的结果：<strong>993 → 保留 981，剔除 12</strong>（6 条 <code>-404</code>、4 条 <code>62002</code>、2 条 <code>-403</code>，其中 6 条标题一切正常），<code>0</code> 条拿不到结论、也没触发限流中止。工具里 <code>-352</code>/<code>-412</code> 这类风控码单独算，连续超过 20 条就中止且不落盘，避免把 &quot; 我被打穿了 &quot; 误判成 &quot; 她的歌都失效了 &quot;。</p><p>剩下唯一做不到的是 <strong> 两次构建之间新失效的歌</strong>——那个窗口里它还是会播 17 秒错误短片然后按表切走，因为运行时确实没有任何信号。刷新频率就是把这个窗口压小，成本是一次导出 + 一次全量核验（993 条请求，实测约十几分钟）。</p><p><strong>首访默认是 &quot; 开 &quot;，但存储里的默认值是 &quot; 关 &quot;。</strong> <code>common/config.js</code> 的 <code>autoplay</code> 出厂值是 <code>false</code>，直接 <code>config.get('autoplay')</code> 的话新访客永远进不了倒计时。判定改成 <code>config.has('autoplay') ? !!config.get('autoplay') : true</code>——<code>has()</code> 读的是 <code>configChosen</code> 那个 &quot; 访客真的点过哪些键 &quot; 的数组，见下面记忆规则一节。<code>applyConfig()</code> 里回填那行也同步改成 <code>settings.set('autoplay', biliplayer.enabled())</code>，否则界面开关和实际行为会反着。</p><p><strong>倒计时必须等加载完成，否则按钮点不动。</strong> 组件初始化时清单可能还没回来、页面还在启动遮罩下面，所以倒计时由两个条件共同决定：<code>ready()</code>（<code>final_load()</code> 完成块里、<code>applyConfig()</code> 之后调）置 <code>pageLoaded</code>，清单到手置 <code>list</code>，两边都齐才 <code>begin()</code>；先到的一侧先让遮罩显示 <code>waiting</code> 状态。之前没做这个门控时实测倒计时期间 <code>document.elementFromPoint()</code> 返回的是 <code>DIV.m-spinner active</code>，加载转环盖在面板上，取消按钮的点击全被它吃掉。</p><p>两个纯样式 / 模板的坑：<code>.HIDE {display: none !important}</code> 优先级高于组件一切规则，早期把 &quot; 播放 &quot; 按钮写成带 <code>HIDE</code>、又想靠 JS 去掉，结果它永远不出现——改成按钮不带 <code>HIDE</code>，由遮罩的模式 class（<code>.manual</code> / <code>.counting</code> / <code>.waiting</code>）决定显示哪块；另一部分是 <code>common/lang.js</code> 在运行时才把 <code>[data-lang]</code> 的文字填进按钮，填之前按钮是空内容，实测 <code>getBoundingClientRect()</code> 高度为 0（<code>[1031, 469, 48, 0]</code>），看着在、点不到，所以给面板按钮加了 <code>min-width: 3.5em; min-height: 1.9em</code>。</p><p><strong>顺带修了一次白屏。</strong> 排查上面那条 &quot; 脚本完全没跑 &quot; 时发现：<code>frame.ejs</code> 里引汇文明朝体的 <code>&lt;link rel=&quot;stylesheet&quot;&gt;</code>（<code>cn-font.claude-code-best.win</code>）只要没完成，未加载好的外链样式表就会阻塞它后面的脚本执行，ES module 也一样被压住，于是 <code>main.js</code> 整条启动链不跑，<code>.m-launch</code> 遮罩永不撤下——页面全白但 DOM 是好的。代理不通那个域名时必现。改成 <code>media=&quot;print&quot; onload=&quot;this.media='all'&quot;</code> 让它非阻塞加载，再加 <code>&lt;noscript&gt;</code> 里的原始链接兜底。</p><p>Service Worker 那边给 <code>bilibili\.com|bilivideo\.com|hdslb\.com</code> 补了路由；<code>/data/*.json</code> 落在最后的 <code>/\//</code> networkFirst 规则里，不需要加 precache。</p><h2 id="访客能改的样式，和它们存在哪里">访客能改的样式，和它们存在哪里</h2><p>全站一共五组 &quot; 访问者可以自己调 &quot; 的东西，全部落在 <code>localStorage</code>，读写都在 <code>source/asset/js/common/config.js</code>：</p><table><thead><tr><th>面板</th><th>项</th><th>存储</th></tr></thead><tbody><tr><td><code>.p-skin</code> 皮肤</td><td><code>default</code>（黑白） / <code>gray</code>（整站转灰） / <code>colorful</code>（图标彩虹色）</td><td><code>config.skin</code></td></tr><tr><td><code>.p-settings</code> 设置</td><td><code>langshift</code> 中英切换、<code>night</code> 夜间模式、<code>transfigure</code> 看板娘半透明、<code>hideplayer</code> 隐藏播放器、<code>autoplay</code> BGM 自动播放（首访无记录按 &quot; 开 &quot; 处理，见 biliplayer 一节）</td><td><code>config.&lt;key&gt;</code></td></tr><tr><td><code>.p-xcanvas</code>（那个 <code>fas fa-video</code> 按钮）</td><td>背景动画开 / 关，关掉时一并藏看板娘</td><td>独立键 <code>noCanvas</code>，不在 config 对象里</td></tr><tr><td>左抽屉、右侧栏的折叠按钮</td><td>收起 / 展开</td><td><code>config.closeDrawer</code>、<code>config.closeAside</code></td></tr><tr><td><code>.p-translater-bar</code> 划词翻译</td><td>目标语言 中 / 英 / 日</td><td><code>config.translater</code>，这次新加的</td></tr></tbody></table><p>其中皮肤、<code>transfigure</code> 和划词翻译是桌面端专属：<code>settings.styl</code> 在 <code>@media (max-width: $app_mobile_width)</code> 里把 <code>transfigure</code> <code>display none</code>（看板娘在手机上要跟页面分开、不能压半透明），<code>translater.styl</code> 让整条划词条在移动端不显示，<code>applyConfig()</code> 里包皮肤那段走 <code>util.runOnDesktop</code>。<code>hideplayer</code> 和 <code>autoplay</code> 现在两个宽度都有——播放器在手机版也被搬进一言面板里（见上面 &quot; 播放器 &quot; 一节），所以这两项跟着放开。</p><h3 id="记忆规则：访客选过的优先，没选过的才用默认">记忆规则：访客选过的优先，没选过的才用默认</h3><p><code>applyConfig()</code> 在页面所有部件加载完之后跑，把 <code>config.get(...)</code> 的值逐个 <code>set</code> 回界面。原来这里有个坑：<code>skin.set()</code> 和 <code>settings.set()</code> 会回头调用自己注册的 <code>onclick</code> 监听，而监听体的最后一句就是 <code>config.set(...)</code>——于是 <strong> 第一次访问、还没等人动手，整套默认值就被当成 &quot; 访客的选择 &quot; 写进了 localStorage</strong>。任何 &quot; 访客有没有选过 &quot; 的判断都会因此失真，所以 <code>skin.js</code> / <code>settings.js</code> 的 <code>set()</code> 多了一个 <code>byUser</code> 参数，只有点击路径传 <code>true</code>，监听里改成 <code>byUser &amp;&amp; config.set(...)</code>。</p><p>光堵这一层还不够：<code>config.set()</code> 写的是整个合并后的对象，访客只要改过任意一项（比如只选了翻译语言），<code>skin</code> 的默认值也会被一起存进去。所以 &quot; 选没选过 &quot; 不能看 <code>config</code> 这个键——<code>config.js</code> 里另开一个 <code>configChosen</code> 数组，只在真正发生写入时记下键名，<code>config.has(key)</code> 读它。</p><p>自动切 colorful 就建立在这条判断上：加载完成时如果 <code>config.has('skin')</code> 为假，3 秒后执行 <code>skin.set('colorful')</code>；这 3 秒里访客自己点了皮肤，<code>skinPicked</code> 标记会让这次定时器作废。自动切换本身 <strong> 不写存储</strong>，所以下次来还是 &quot;3 秒后变彩色 &quot;，除非他自己选过。</p><table><thead><tr><th>场景</th><th>实测</th></tr></thead><tbody><tr><td>全新配置，加载完等 7 秒</td><td>根元素 class <code>lyride colorful</code>，71 个图标被着色</td></tr><tr><td>同上，看存储</td><td><code>config</code> 和 <code>configChosen</code> 都是 <code>null</code>，说明没被写过</td></tr><tr><td>手动点 &quot; 灰度 &quot; 后重开，等 8 秒</td><td><code>lyride gray</code>，色块高亮在灰度，着色图标 0 个，没有被自动改回彩色</td></tr><tr><td>手动点 &quot; 默认 &quot; 后重开，等 9 秒</td><td><code>lyride</code>，仍然是默认皮肤</td></tr><tr><td>旧遗留：<code>config</code> 里有 <code>skin</code> 但没有 <code>configChosen</code></td><td>照样 3 秒变彩色</td></tr></tbody></table><h3 id="GitHub- 图标不参与彩虹色">GitHub 图标不参与彩虹色</h3><p><code>colorful</code> 皮肤做的事是 <code>colorIcons.run()</code>：把所有 <code>i.fas / i.fab / i.far</code> 按 <code>color_1…color_5</code> 轮转上色，<code>color_3</code> 就是那个 <code>#00CC99</code>。<code>skin.js</code> 里加了一张排除表 <code>exclude: 'i.fa-github'</code>，命中的直接跳过，连 <code>data-colored</code> 标记都不打。实测 colorful 皮肤下联系方式里的 GitHub 图标 <code>class</code> 仍是 <code>fab fa-github fa-fw</code>，<code>getComputedStyle().color</code> 是 <code>rgb(0, 0, 0)</code>。</p><p>上面这段是当时那条 <code>&lt;i class=&quot;fab fa-github&quot;&gt;</code> 的实测。联系方式的 GitHub 图标后来换成了外链 <code>&lt;img&gt;</code>（见 &quot; 侧栏：slogan 改四行、B 站进联系方式 &quot; 一节），<code>&lt;img&gt;</code> 本来就不在 <code>colorIcons</code> 的选择器里，所以这张排除表现在选不到任何节点——它没有副作用，留着没删，但别再把它当成 &quot;GitHub 图标为什么是黑的 &quot; 的答案。</p><h2 id="划词翻译换成谷歌公共接口">划词翻译换成谷歌公共接口</h2><p>原来是 <code>util.baiduTranslate</code>，走百度翻译开放平台的 JSONP，要 <code>appid</code> + <code>appkey</code> 签名（密钥用 <code>decodePass</code> 从 <code>api/site.json</code> 的 <code>baidu_translate.pass/pointer</code> 还原）。<strong>这套凭据从来没填过</strong>——<code>site.json</code> 里那两个字段是空串，所以划词翻译此前一直是 &quot; 点了没结果 &quot;。</p><p>现在换成 <code>util.googleTranslate</code>，用 Chrome 内置翻译自己在调的那个免密钥接口 <code>https://clients5.google.com/translate_a/t?client=dict-chrome-ex&amp;sl=auto&amp;tl=&lt; 目标 &gt;&amp;q=&lt; 文本 &gt;</code>，返回 <code>[[&quot; 译文 &quot;,&quot; 识别到的语种 &quot;]]</code>，逐段取第 0 个拼起来。界面里的 <code>zh/en/jp</code> 映射成谷歌的 <code>zh-CN/en/ja</code>。</p><p>浮动条上那个标题写明是谁在翻：<code>asset/lang/zh-cn.yml</code> 的 <code>translater.to</code> 改成 &quot; 划词翻译（谷歌）&quot;、<code>en.yml</code> 改成 <code>HIGHLIGHT 'N TRANSLATE (Google)</code>。这两个 key 由 <code>translater.ejs</code> 里的 <code>&lt;span class=&quot;p-translater-bar-to&quot; data-lang=&quot;translater.to&quot;&gt;</code> 在运行时填（build-time 和 runtime 两份模板同一个标记），所以只改 yml 就够，不用碰模板和 JS；<code>hexo generate</code> 会编译成 <code>public/asset/lang/{zh-cn,en}.json</code>，实测 480px 移动端下条宽 260px、没有横向溢出。</p><table><thead><tr><th>检查</th><th>结果</th></tr></thead><tbody><tr><td><code>translate.googleapis.com/translate_a/single?client=gtx</code></td><td>本机代理出口 <code>429 Sorry...</code>，换 <code>translate.google.com</code> 同端点也 429，弃用</td></tr><tr><td><code>clients5.google.com/translate_a/t?client=dict-chrome-ex</code></td><td><code>200</code>，<code>Access-Control-Allow-Origin: *</code>，<code>hello world</code>→<code>你好世界 </code>、<code> 日本語</code>→<code>Japanese</code></td></tr><tr><td>同一端点不走代理直连</td><td>8 秒无响应，大陆网络到不了 Google</td></tr><tr><td>headless Firefox 实测</td><td>选中 &quot; 日本語 &quot; 点 EN → 结果框出 <code>RESULT / 翻译结果：Japanese</code>，面板加 <code>.active</code>；点中间那块 &quot; 划词翻译 &quot; 标签 → 用上次记住的 EN 再翻一次</td></tr></tbody></table><p>三条要记着的：</p><ol><li><strong>免密钥不等于免依赖</strong>。谷歌这套接口没有 SLA 也没有配额承诺，<code>translate.googleapis.com</code> 那条对数据中心出口 IP 直接 429，<code>clients5</code> 只是另一条暂时能用的。</li><li><strong>大陆访客不开代理就用不了</strong>，这是网络事实不是代码问题（直连 8 秒无响应）。失败时结果框给 <code>TRANSLATE FAILED / 翻译失败：…</code>，并有 8 秒 <code>AbortController</code> 超时兜底，不会让顶部转环一直转。</li><li>译文 <strong> 先转义再进 <code>innerHTML</code></strong>（<code>escapeHtml</code>），机翻结果里带尖括号也不会被当标签解析。</li></ol><p>顺带清掉的依赖：<code>plugin/md5.min.js</code> 只被百度签名用到，<code>util.js</code> 里 <code>import fetch</code>（那个 JSONP 装载器）也只有它在用，两个引用都删了；文件本身后来也删了，理由见下。</p><p>那个文件是什么：一份 MD5 的实现，MD5 是把任意长度输入压成 128 bit（写成 32 位十六进制）的摘要算法，这里被打包成 ES module（<code>export default o=&gt;{...}</code>，3478 字节）。主题用它算百度翻译开放平台的请求签名 <code>sign = md5(appid + 原文 + salt + appkey)</code>——那个接口要求把签名放在 query 里，密钥不能直接发出去，所以必须有本地 MD5。换掉百度之后整条签名链没了，全仓库（排除 <code>public/</code>）对它的引用数是 0。它虽然没人 <code>import</code>，但因为在主题的 <code>source/asset/</code> 下，仍会被原样复制到 <code>public/</code>（约 3.4KB）白占一个上线文件，所以直接删掉；同批删除的还有上一节列的 APlayer/Meting 那几个插件文件。</p><h2 id="侧栏署名改成 -TaKoMi">侧栏署名改成 TaKoMi</h2><p>只改 <code>asset/lang/zh-cn.yml</code> 和 <code>en.yml</code> 里的 <code>brand.name</code> 两行，中英文共用同一个显示名（实测首页 <code>.p-brand-name</code> 的文本是 <code>TaKoMi</code>）。根 <code>_config.yml</code> 的 <code>title: takomitkm</code> 和 lang 里的 <code>sitename: takomitkm 的自留地 </code> <strong> 没跟着动</strong>——那两处是站点标题（浏览器标签、顶栏站名、<code>&lt;meta&gt;</code> 读它），不是署名，要不要一起换是另一件事。</p><h2 id="时空胶囊（已删除）：让单篇文章自己指定链接">时空胶囊（已删除）：让单篇文章自己指定链接</h2><p>10-04 定的最后一条就是 &quot; 算了胶囊不要了，删了 &quot;，所以 <code>themes/mustom/scripts/$permalink.js</code> 这个文件已经删掉了，<code>capsule:</code> 这个 front matter 开关现在不存在、写上也无效。这一节整段留着，因为两件事还活着：一是下面那个 <code>urlOf()</code> 的坑和它的两处防护（hexo 自己就认 front-matter 里的 <code>permalink</code>，不需要胶囊这套也能踩出双斜杠）；二是探针记录在 <code>_tmp/ 胶囊探针.md</code>，要复跑放回 <code>source/_posts/</code> 就行。</p><p>他的文章链接是 <code>/time-capsule/YYYYMMDD-slug/</code> 这个形态（例：<code>https://blog.douchi.space/time-capsule/20261003-maple-pass/</code>），日期在 URL 里、尾段是自己起的短名，比 <code>/posts/&lt; 一串数字 &gt;/</code> 好认。当时做成 front matter 开关：</p><figure class="highlight md"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">capsule: maple-pass      # 这一篇用 maple-pass</span><br><span class="line">capsule: true            # 或者退回用文件名（slug）</span><br></pre></td></tr></table></figure><p>规则只有一行，写在当时的 <code>themes/mustom/scripts/$permalink.js</code>（现已删除）：<code>__permalink = 'time-capsule/' + date.format('YYYYMMDD') + '-' + slug + '/'</code>。<strong>没写 <code>capsule</code> 的一篇都不受影响</strong>，照旧走 <code>posts/:abbrlink/</code>。</p><p>挂在 <code>before_post_render</code> 而不是再注册一个 <code>post_permalink</code> 过滤器，是因为 hexo 的 <code>post.path</code> 虚拟属性把整篇文档交给 <code>post_permalink</code> 链，内置那条（<code>node_modules/hexo/dist/plugins/filter/post_permalink.js</code>）第一个分支只认 <code>data.__permalink</code>，而链上后一个过滤器拿到的是上一个返回的 <strong> 字符串</strong>，已经看不见 front matter 了。<code>before_post_render</code> 拿到的就是 Post 文档本身（<code>plugins/filter/before_generate/render_post.js</code> 里 <code>post.render(post.full_source, post)</code> 之后才 <code>post.save()</code>），在这儿写 <code>__permalink</code> 正好赶在路由之前，也不用像 <code>hexo-abbrlink</code> 那样把生成的字段回写进 md 文件。</p><p>连带的坑必须记：<code>__permalink</code> 分支出来的 <code>post.path</code> 是 <strong> 带头斜杠 </strong> 的（内置过滤器强制补的），而 restful 插件拼 URL 用 <code>cfg.root + post.path</code>，<code>cfg.root</code> 已经带 <strong> 尾斜杠</strong>，于是 <code>api/posts.json</code>、<code>api/search.json</code>、每篇的 <code>api/posts/&lt;abbrlink&gt;.json</code> 和 <code>sw.js</code> 的 precache 列表里全成了 <code>//time-capsule/…</code>——浏览器把双斜杠开头当协议相对 URL（去解析主机名），点开是 404。修法是两处各加一个 <code>urlOf()</code>（<code>root</code> 去尾斜杠 + path 去头斜杠再拼）：<code>scripts/plugin/hexo-generator-restful/lib/generator.js</code> 里 14 处，<code>scripts/renderer/$template.js</code> 的 precache 里 2 处。</p><p>实测：探针文章（<code>capsule: probe-run</code>）生成出 <code>public/time-capsule/20261004-probe-run/index.html</code>，上面四处 URL 都是单斜杠。探针文件本身已移出 <code>source/_posts/</code>（留在 <code>_tmp/ 胶囊探针.md</code>，要复跑放回去即可），移出后重建，<code>api/posts.json</code> 三条 URL 逐条比过，老的 <code>/posts/:abbrlink/</code> 一个没变。</p><h2 id="文章页顶部那行 - 本文总计 -X- 字">文章页顶部那行 &quot; 本文总计 X 字 &quot;</h2><p>他的写法是 <code>&lt;blockquote class=&quot;book-hint info&quot;&gt; 本文总计 5.75k 字, 阅读约需要 14 分钟 &lt;/blockquote&gt;</code>。数字口径他没写，但两篇实测样本能对上一个很朴素的算法：<code>5750/400 = 14.4 → 14</code>、<code>3430/400 = 8.6 → 8</code>，也就是 <strong> 分钟 = floor(字数 / 400)</strong>，字数保留两位小数写成 <code>k</code>。本站照抄这两个参数：<code>generator.js</code> 里的 <code>hintOf(content)</code> 产出 <code>hintWord = (chars/1000).toFixed(2)+'k'</code>、<code>hintMinute = Math.max(1, Math.floor(chars/400))</code>。<code>chars</code> 用主题自带的 <code>$count</code>（<code>stripHTML</code> 之后中文按字、英文按词数），跟 Hugo 的 <code>.WordCount</code> 是同类口径，所以数量级能直接跟他比。</p><p>字段只落在两处 JSON：<code>api/posts.json</code>（列表，热力图读它）和每篇的 <code>api/posts/&lt;abbrlink&gt;.json</code>（文章页读它）。这里我错过一次：一开始以为 <code>api/posts.json</code> 是那个叫 <code>postMap</code> 的函数产出的，其实 <code>postMap</code> 喂的是分类 / 标签 JSON 里的 <code>postlist</code>，真正产出 <code>api/posts.json</code> 的是另一个 <code>postlist = posts.map(...)</code>——漏加的话热力图拿不到 <code>chars</code>，整块面板是空的。反过来，分类 / 标签那份加了也没人读（全仓库对 <code>hintWord</code>/<code>hintMinute</code>/<code>.chars</code> 的引用只有 <code>heatmap.js</code> 和 <code>post.js</code> 两处），所以那处最后又删掉了。</p><p>文案在 <code>lang/*.yml</code> 的 <code>post.hint</code>，两个 <code>%s</code> 由 <code>common/lang.js</code> 按 <code>data-lang-params</code> 逐个替换，<code>post.js</code> 填文章数据时 <code>setAttribute('data-lang-params', JSON.stringify([hintWord, hintMinute]))</code>。<strong>模板里不要预先写一个空的 <code>data-lang-params</code></strong>：这个属性只要存在，<code>lang.js</code> 就拿空数组去替换，非文章页（首页、归档）上那行会留下半截 <code>%s</code>。实测文章页文本 <code>本文总计 0.61k 字, 阅读约需要 1 分钟</code>、属性 <code>[&quot;0.61k&quot;,1]</code>；首页那行是空文本，且父级 <code>.p-post</code> 为 <code>display: none</code>。样式沿用他的类名 <code>book-hint info</code>，左边框 <code>#7aa874</code>、底色 <code>rgba(122,168,116,.1)</code>。</p><h2 id="侧栏产量热力图">侧栏产量热力图</h2><p>做法来自他那篇 <code>hugo-blog-heatmap</code>：echarts（Apache ECharts，开源图表库）的 <code>calendar</code> 坐标系配 <code>heatmap</code> 系列，一格一天，颜色深浅 = 当天写了多少字。他用 Go 模板在构建期把数据直接写进 <code>&lt;script&gt;</code>，本站的正文数据本来就只存在于 <code>/api/*.json</code>，所以改成运行时从 <code>/api/posts.json</code> 取 <code>chars</code> 按天求和。echarts 走 jsdelivr CDN（<code>cdn.jsdelivr.net/npm/echarts@5.3.0/dist/echarts.min.js</code>），要画的时候才注入 <code>&lt;script&gt;</code>，不进仓库。</p><p>位置：桌面在 <code>.m-aside</code> 里紧跟 <code>biliplayer</code>（歌单）之后，手机在 <code>.m-drawer</code> 里 <code>panels</code> 之后、<code>.m-drawer-mark</code> 之前，也就是抽屉最后一块。markup 写两份（<code>layout/_partial/_part/heatmap.ejs</code> 和 <code>source/asset/part/heatmap.ejs</code>）是主题 parts 机制的要求，不是重复代码；<code>source/asset/part/*.ejs</code> 会被 <code>scripts/renderer/$template.js</code> 自动列进 precache，所以新加这块不用动路由表。</p><p><code>main.js</code> 里它 <strong> 不进 <code>checklist</code></strong>（开站动画要等的那份清单）：它依赖两个外部资源，任一个取不到都只该让这块面板空着，不该把整站卡在遮罩后面。</p><p>副本只画可见的那一份：<code>document.querySelectorAll('.p-heatmap-chart')</code> 逐个过，<code>clientWidth === 0</code> 的跳过（跟 <code>panels.js</code> 同一个办法）。实测桌面下 aside 那份 <code>display:block, boxW 254</code>、drawer 那份 <code>display:none, boxW 0</code>，430px 宽时正好反过来。画几个月按面板宽度算：<code>Math.floor(width / 48)</code> 夹在 3–12（一周约 48px），254px → 5 个月，实测区间 <code>2026-05-04 ~ 2026-10-04</code>。</p><h3 id="图例为什么收起来">图例为什么收起来</h3><p>他教程里那版 <code>visualMap</code> 是 <code>showLabel: true</code> + <code>itemGap: 20</code>，那是给 600px 宽的示例容器写的；侧栏面板只有 254px，四条带数字区间的图例（<code>10.75 - 21.50</code> 这种）横排下来直接溢出面板左边界，截图上数字被裁掉半截。他自己线上那份打包脚本里本来就把整个 visualMap 收起来了（<code>show:false</code>），颜色分档照常生效、具体数字交给 tooltip——所以这里跟线上版对齐，而不是跟教程对齐。</p><p>&quot; 收起来之后还分不分档 &quot; 不能靠猜：从 zrender（echarts 底下的画布层）的 displayList 里数填充色，实测 <code>{rgba(243,243,243,.5): 154 个, rgba(122,168,116,1): 1 个}</code>，那 1 个正是 9-27 那天（三篇同一天，合计 42.1 千字，落在最高档）。夜间换挡后底色那 154 个变成 <code>#333</code>、月份标签 <code>#ccc</code>、绿色格不变——颜色每次 <code>setOption</code> 都从 CSS 变量现读，<code>:root</code> 上 class 一变就由 <code>MutationObserver</code> 触发重画。</p><p>首绘那一刻 <code>unit()</code>（从隐藏的 <code>.p-heatmap-unit</code> span 取 &quot; 千字 &quot; 两个字）拿到的是空串：<code>common/lang.js</code> 填 <code>[data-lang]</code> 比热力图第一次画要晚。图例既然隐藏了就没影响，tooltip 每次 hover 现读，实测正常。同一个时间差这轮又撞到一次：探针在文案填好之前去量标题栏，读回来 <code>capRect</code> 高度 0、气泡文本空串，看着像 &quot; 出处没挂上 &quot;，等 <code>caption-text.textContent.length &gt; 2</code> 之后再量就对了——量文案驱动的盒子，必须先等文案落地。</p><h3 id="面板改叫 - 博客废话产量 -，出处挂在标题行上">面板改叫 &quot; 博客废话产量 &quot;，出处挂在标题行上</h3><p>他那篇里 <code>option.title.text</code> 写的就是 &quot; 博客废话产量 &quot;（《如何给 Hugo 博客添加热力图》，作者署名椒盐豆豉），名字照抄他的：<code>heatmap.caption: 博客废话产量</code>。英文那档我意译成 <code>WAFFLE OUTPUT</code>，这一条是我的措辞、不是他的；她下一轮回 &quot;WAFFLE OUTPUT 可以 &quot;，这条就此定稿。</p><p>出处注释挂在同一行标题上：<code>.p-heatmap-caption</code> 里加一个 <code>&lt;em class=&quot;p-heatmap-tip&quot; data-lang=&quot;heatmap.source&quot;&gt;</code>，<code>lang/*.yml</code> 的 <code>heatmap.source</code> 直接写了一段带 <code>&lt;a href&gt;</code> 的 HTML——<code>common/lang.js</code> 对既没有 <code>data-lang-titled</code> 也没有 <code>data-lang-placeholdered</code> 的元素走的是 <code>innerHTML = '&lt;span&gt;'+text+'&lt;/span&gt;'</code>，所以链接、<code>&lt;br&gt;</code> 这类标记能用（侧栏品牌卡那一节用的就是同一个口子）。这些串是构建期从 yml 编进 <code>asset/lang/*.json</code> 的，不是访客输入，<code>innerHTML</code> 在这里不引入注入面。</p><p>弹出的做法抄页脚那三个气泡：<code>:hover</code>、<code>:focus</code>、<code>.on</code> 三个选择器各写一遍全名（写成 <code>&amp;:hover, &amp;:focus, &amp;.on</code> 再嵌子元素，编译出来是一条裸的 <code>.p-heatmap-tip</code>，会把默认隐藏那条盖掉，这个坑之前踩过）。标题栏加 <code>tabindex=&quot;0&quot;</code>，触屏那一下由 <code>heatmap.js</code> 里 <code>.p-heatmap-caption</code> 的 click 切 <code>.on</code>、并且先把别家开着的关掉。跟页脚那版有两处不一样：气泡朝 <strong> 下</strong>开（面板贴在侧栏最底下，往上开会顶出可视区）；宽度不用页脚的 <code>min(90vw, 22em)</code>，改成左右各钉在标题栏内边距上、让文本自己折行——第一版按视口算，量出来气泡右边溢出面板 25px、压到正文上，改完是 -9px（在面板内）。</p><h3 id="第一行为什么右移了一格（这一条已回退）">第一行为什么右移了一格（这一条已回退）</h3><p>echarts 的 <code>calendar</code> 坐标系是 <strong> 一列一周、一行一个星期几</strong>，七行从上到下按周日排。<code>rangeOf()</code> 的起点是 &quot; 今天往前推 N 个月 &quot;，落在周中（10-04 往前推 5 个月是 5-04，周一），那一列的周日格在区间之外、画不出来，于是第一行整体缺一个格、看着就往右挪了一格。上一轮的修法是起点再退到所在那一周的周日：<code>start.setDate(start.getDate() - start.getDay())</code>。</p><p>对照实测（<code>_tmp/roundL_ui2.mjs</code>：从 zrender 的 displayList 里按 <code>shape.y</code> 分行、取每行最小 <code>shape.x</code>）——把起点手动改回 &quot; 不回退到周日 &quot; 的那个日期，第一行 <code>minX</code> 是 17、其余六行是 6，正好差一格（格子加间隔约 11px），跟她看到的现象对上；改完之后七行 <code>minX</code> 全是 6。</p><p>她下一轮的原话是 &quot; 热力图回退到上次方案 &quot;，理由没给，所以这里只记事实：那句 <code>setDate</code> 已经删掉，起点回到 <code>2026-05-04</code>，第一行重新缺周日那半格。上面这组 <code>17 / 6</code> 的对照数字留着——故障成因是核实的（不是猜的），撤掉它是取舍，不是 &quot; 当时判断错了 &quot;。代码里在 <code>rangeOf()</code> 上面写了一句注释记这件事，别再 &quot; 顺手修好 &quot;。</p><h2 id="随机文章入口顶掉了那个二维码钮">随机文章入口顶掉了那个二维码钮</h2><p>他那篇 <code>hugo-random-post</code> 的做法是构建期把全站 URL 数组写进脚本，前端随机取一条跳转，链接尾上带 <code>?utm_source=random</code>（这样他自己看统计能认出 &quot; 闲逛 &quot; 进来的）。本站不再造一份构建期数组：<code>/api/posts.json</code> 本来就是全站文章 URL 索引，点了才取一条。</p><p>入口位置：左下角那个浮框上原来的 <code>fas fa-qrcode color_3</code> 圆钮（<code>.p-adframe-qrcode</code>）整个换成 <code>.p-adframe-random</code>（<code>fas fa-random</code>），文案键 <code>adframe.random</code>，中文 &quot; 闲逛 &quot;、英文 <code>feeling lucky</code>，都是他的措辞。FontAwesome 5.12 free 里有 <code>fa-random</code>（码点 <code>f074</code>），没有 <code>fa-shuffle</code>，别照别的图标库的名字写。圆钮上必须 <code>e.stopPropagation()</code>：<code>.p-adframe</code> 整块挂着 &quot; 点一下开关浮框 &quot; 的监听，不挡掉的话跳转之前会顺带把框开关一次。</p><p>这条改动换来的代价当时就说清楚了，是设计如此不是漏了：那个框只有两个状态——收起来时只剩圆钮（CSS <code>.close</code> 下 <code>.p-adframe-close</code> 和 <code>.p-adframe-content</code> 都 <code>display:none</code>），展开时圆钮本身 <code>display:none</code>、露出图片和右上角的叉。圆钮改管随机之后，收起状态下 <strong> 再没有任何把手能把它打开</strong>，框的状态存在 <code>localStorage</code> 的 <code>adFrameState</code>，要恢复只能清这个键。首访默认是展开（<code>isClose</code> 出厂 <code>false</code>），所以访客第一次仍会看到那张本站徽标（<code>icon.medium.png</code>），点叉之后就永久收成随机钮。这个框只在桌面端存在（<code>@media (max-width: $app_mobile_width)</code> 里整个 <code>display:none</code>）。</p><p>她看到这条之后的处理是 &quot; 没有办法打开就直接删了 &quot;，所以同一轮里那个浮框已经整个拆掉：<code>.p-adframe-close</code>、<code>.p-adframe-content</code> 两块 markup、<code>adframe.js</code> 里读 / 写 <code>adFrameState</code> 的那套开关、<code>adframe.styl</code> 里 <code>.close</code> 门控和那两个盒子的规则、<code>_config.yml</code> 里的 <code>adframe.icons.close</code> 和 <code>adframe.ad</code> 全删了，左下角只剩 <code>.p-adframe-random</code> 这一个圆钮（<code>.p-adframe</code> 还是 <code>position:fixed</code> 的老位置、老尺寸，视觉上跟收起态一模一样）。<code>icon.medium.png</code> 这个文件留着，它同时是 <code>favicon.medium</code>、顶栏站名的图标在用；老访客 <code>localStorage</code> 里那个 <code>adFrameState</code> 键没人读了，不用清。圆钮上那条 <code>e.stopPropagation()</code> 现在其实没有东西要挡了（整框的点击监听已删），留着是防以后又在这块挂监听。</p><p>再下一轮她追问 &quot; 两处随浮框留下的死物删了 &quot;，那两处是：<code>layout.styl</code> 里 gray 皮肤（白天不透明那档）给 <code>.p-adframe-content</code> 补的那条 <code>box-shadow</code>——那个盒子已经不存在了，同一块里开头的 <code>.p-adframe-random, .p-goingto-top, .p-goingto-bottom, .p-xcanvas</code> 还是活的，只删了 <code>.p-adframe-content</code> 那两行；另一处是 <code>adFrameState</code> 这个 localStorage 键，代码里已经没有一行读它或写它（<code>grep -rn adFrameState</code> 在 <code>themes/</code> 下只剩一处注释和这篇文章），所以 <strong> 没有代码可删</strong>，只剩老访客浏览器里那个没人读的键，动不了也不该动（清它得访客自己清）。</p><p>这条删除被覆盖过一次，值得记下来：另一条并行改动（宽屏 fluid 布局）在 21:54 把 <code>layout.styl</code> 整文件提交，我 19:30 删的那两行被它的版本带了回来——<code>git reflog</code> 里能看到那次 commit，<code>git status</code> 显示它还没推。发现后按同一位置再删一次，并在合并后的构建产物上重跑探针：<code>public/asset/css/style.css</code> 里 <code>p-adframe-content</code> 出现 0 次，首页和文章页 DOM 里 <code>.p-adframe</code> 只有 1 个子节点（那个随机圆钮），控制台无报错。宽屏那套改动没动侧栏宽度，热力图面板实测仍是 254px、仍画 5 个月，第一行 <code>minX</code> 17 / 其余 6 那组数字原样成立。</p><p>实测：点圆钮后 <code>location.href</code> 变成 <code>/posts/1501581470/?utm_source=random</code>，页面里挂的 <code>window.__errs</code> 为空。</p><h2 id="划词翻译那条栏：语言组另起一行，英文标签缩短">划词翻译那条栏：语言组另起一行，英文标签缩短</h2><p>栏里五块内容（<code>复制 </code> / <code> 划词翻译（谷歌）</code> / <code>中文 | English | 日本語 </code>）原本全是 <code>float: left</code>（<code> 复制</code> 是 <code>float: right</code>），排成一行，装不下才靠自动换行折。英文那条标签 <code>HIGHLIGHT 'N TRANSLATE (Google)</code> 太长，一行的形状就保不住，中英两种界面下换行的位置还不一样。改成显式断行：<code>translater.styl</code> 里给 <code>.p-translater-bar-zh</code> 加 <code>clear: left</code>，语言那一组自己占一行，上面那行只剩标签和右边的 &quot; 复制 &quot;；自动换行退回成窄屏兜底，不再是决定形状的那一步。文案同步缩短成 <code>TRANSLATE (Google)</code>（<code>lang/en.yml</code> 的 <code>translater.to</code>），中文那条没动。</p><p><code>clear</code> 加在 <code>zh</code> 上而不是包一层 <code>&lt;br&gt;</code> 或改 flex，是因为这条栏的五个块都在同一个父级里浮动，<code>clear: left</code> 是这套布局里唯一 &quot; 从这里起另起一行 &quot; 的写法，改动面最小；父级 <code>.p-translater-bar</code> 有 <code>overflow: hidden</code>，两行浮动的盒子高度照样被包住，不需要额外清除浮动。</p><p>实测（<code>_tmp/roundL_ui3.mjs</code>：把 <code>asset/part/translater.html</code> 那份 markup 插进页面、按 <code>asset/lang/*.json</code> 填好文案，在 640 / 420 / 300px 三种容器宽度下量每块相对栏顶的偏移）：中英文两档、三种宽度下都是同一个形状——第 0 行 <code>to</code>（左）+ <code>copy</code>（右），第 42px 行 <code>zh | en | jp</code>，栏高恒 84px（两行）。</p><h2 id="正文表格底色从纯白降到 -70- 不透明">正文表格底色从纯白降到 70% 不透明</h2><p>表格行的底色不是主题写的，是打包进来的 <code>asset/css/_plugin/github-markdown.min.css</code>：<code>.markdown-body table tr{background-color:#fff}</code>、<code>tr:nth-child(2n){background-color:#f6f8fa}</code>。那是给纯白页面写的值，本站白底只有 50% 透明度、colorful 皮肤还压着背景图，表格两行就成两块死白。改成 <code>#ffffffb3</code> / <code>#f6f8fab3</code>（alpha <code>b3</code> = 70% 不透明），写在 <code>layout.styl</code> 的 <code>.markdown-body table tr</code> 上。</p><p>两条边界要说明白。一是 <strong> 代码块不会跟着变 </strong>：hexo 的代码块本身就是 <code>figure.highlight &gt; table &gt; tr</code>，会被这条规则打到，但 <code>_plugin/highlight.styl</code> 里早就有 <code>.markdown-body .highlight table tr{background-color:transparent}</code>，特异性 (0,2,2) 高于这条的 (0,1,2)，所以代码块一直是透的、这次也还是透的。我一开始没查到这条，写了一组 &quot; 把代码块按原值钉回 #fff&quot; 的覆盖规则，那等于把主题原有的透明底改成了不透明白，构建完在 <code>public/asset/css/style.css</code> 里看到两条同特异性的规则打架才发现，那组规则已删。二是<strong> 夜间不受影响</strong>：<code>:root.night .markdown-body table tr</code> 那组用 CSS 变量，特异性更高、排在这条之后。</p><p>实测（<code>_tmp/roundL_ui.mjs</code> 读 <code>getComputedStyle</code>）：白天普通表格奇偶行 <code>rgba(255,255,255,0.7)</code> / <code>rgba(246,248,250,0.7)</code>，代码块行 <code>rgba(0,0,0,0)</code>；夜间 <code>rgba(0,0,0,0.8)</code> / <code>rgb(51,51,51)</code>，跟改动前逐字一致。</p><h2 id="robots-txt：线上那份不改，只放一份备用的">robots.txt：线上那份不改，只放一份备用的</h2><p>她的原话是 &quot; 本站 robots.txt 不要换，放一份备用 robots 里面写着因为现在是放在 GitHub Pages 所以不用防爬虫 &quot;。所以线上一个字没动：<code>themes/mustom/source/robots.txt</code> 还是那 13 字节的 <code>User-agent: *</code>（站点根 <code>source/</code> 下没有第二份），构建产物 <code>public/robots.txt</code> 逐字同一串、末尾没有换行——按 REP（Robots Exclusion Protocol，robots 排除协议）这等于对所有人放行。</p><p>备用那份写在 <strong> 仓库根 </strong> 的 <code>robots.standby.txt</code>：hexo 只把 <code>source/</code> 和主题的 <code>source/</code> 往 <code>public/</code> 里拷，仓库根的文件不进产物，所以它既是 &quot; 拿来即用 &quot; 的完整文件，又不会被发布上线（实测这次构建后 <code>public/</code> 里 <code>robots*</code> 只有那一串 13 字节的）。文件头部写的就是她给的理由，并把它拆成三条具体的：① GitHub Pages 是静态托管，抓取打在 GitHub 的边缘节点上，不是自己的服务器，防爬虫的实际收益（省带宽、防过载）在这个形态下不存在；② 这个仓库本身是公开的（<code>api.github.com</code> 查 <code>takomitkm/takomitkm.github.io</code> 得 <code>private:false</code>），正文 markdown 就跟着 <code>source/_posts</code> 一起可读，而 robots.txt 是给守规矩的抓取器看的 <strong> 声明</strong>、不是访问控制；③ 改成屏蔽版会牵动 Google/Bing 需要的 <code>User-agent: *</code> 放行状态，属于只有代价没有收益的那类改动。文件里 6 条 A 级规则是解开注释的状态（GPTBot、Google-Extended、Google-CloudVertexBot、ClaudeBot、Amazonbot、Applebot-Extended），8 条 B 级和所有 C 级只作注释说明。</p><p><strong>一处公开更正</strong>：这一条我一开始交付过两个错数——把 A 级说成 &quot;18 个 token / 8 家 &quot;，并把 <code>CCBot</code> 当成 A 级写进生效块。判据混淆了：那 18 个是 &quot; 名字在厂商自己页面上逐字出现 &quot;，而 A 级要求的是 &quot; 厂商还明说它用于 AI 抓取 / 模型训练 &quot;。按后者重数，A 级 6 条；<code>CCBot</code> 降回 B（Common Crawl 那页确实给了现成的 <code>User-agent: CCBot</code> + <code>Disallow: /</code> 和 UA 串 <code>CCBot/2.0</code>，但它自述的用途是公开语料库，那一页没写模型训练）。nixCraft 那份 17 个名字的清单里只有 4 个够得上 A，另外两处与他相反的说法也记在核对文档里（<code>Applebot</code> 应为 <code>Applebot-Extended</code>；&quot;Common Crawl 不提供 CIDR/IP 段 &quot; 与厂商页那句 “CCBot is now run on dedicated IP address ranges with reverse DNS” 相反）。取证与逐字引文在 <code>S:\apps\llm\agentwork\robots-ai\</code>，不进博客仓库。</p><h2 id="死物普查：除了那两处，还有没有别的">死物普查：除了那两处，还有没有别的</h2><p>她问 &quot; 还有别的死物吗 &quot;，所以跑了一遍普查。三条线，脚本 <code>_tmp/deadcss.py</code>、<code>_tmp/deadscan.py</code>、<code>_tmp/deadscan3.py</code>（v3 修了 v2 的两处假阳性），语料是构建产物 + 主题模板 /JS/ 脚本 + 两份配置；<code>_tmp/roundM_ui.mjs</code> 是无头 Firefox 那头的复核，本地起 <code>python -m http.server</code> 服务 <code>public/</code>，不碰线上（碰线上会给不蒜子计数）。</p><p><strong>能删的只有一类：5 张没人引用的图 </strong>，合计 125,513 字节——<code>asset/img/author.large.png</code> 39,145、<code>author.small.png</code> 25,293、<code>qf3cu.temp.bw.png</code> 25,718、<code>yao.large.png</code> 32,316、<code>icon.large.png</code> 3,041。其中 <code>author.small</code>（<code>_config.yml:18</code>）和 <code>favicon.large</code>（<code>:15</code>）是 &quot; 两头都空 &quot;：模板里没有任何一处读 <code>theme.author.small</code> / <code>theme.favicon.large</code>（grep 无命中），产物里也没有这两个文件名；剩下三张连配置都不挂，<code>qf3cu.temp.bw.png</code> 是那张黑白中间稿（在用的是 <code>qf3cu.temp.png</code> 和 <code>qf3cu.huaji.png</code>）。<strong> 但它们不占访客流量</strong>：<code>public/sw.js</code> 的 precache 清单里 <code>/asset/img/</code> 命中 0 次，这 125KB 只占仓库和 <code>public/</code> 的体积，删不删是整洁问题不是性能问题。</p><p><strong>两处假阳性要说出来，因为它们差点被写成 &quot; 死代码 &quot;</strong>。一是 <code>share.min.css</code> 那 15 条 <code>.icon-qq</code> / <code>.icon-weibo</code> / <code>.icon-douban</code> 规则：静态普查判它们孤儿，是因为 <code>socialShare.min.js</code> 用 <code>&quot;icon-&quot; + 站点名 </code> 拼类名，字面量根本不在源码里；探针在文章页读到 <code>typeof window.socialShare === 'function'</code>、<code>.p-post-share</code> 节点 1 个，说明这套是活的。二是普查第一版报的 8 个 &quot; 死配置键 &quot;（<code>about</code>、<code>live</code>、<code>short_name</code>、<code>start_url</code>、<code>theme_color</code>、<code>background_color</code>、<code>orientation</code>、<code>sizes</code>）：<code>menus</code> 的键名是数据驱动的（模板按 map 迭代，不会逐字写出 <code>about</code>），<code>manifest</code> 那 6 个是插件整块原样拷进 <code>public/manifest.json</code> 的——把生成物也当语料之后，112 个键里<strong> 死键 0 个</strong>。</p><p><strong>剩下的孤儿类都是 &quot; 条件触发 &quot; 而不是死代码</strong>：<code>_plugin/highlight.styl</code> 里的 <code>gist-file</code>/<code>gist-data</code>/<code>line-numbers</code> 和 <code>perl</code>/<code>coffeescript</code>/<code>hexcolor</code> 那批，只有正文真插 gist 或用了那种语言才出现；<code>_part/recentposts.styl:16</code> 的 <code>.footnote-ref</code> 只有正文有脚注才出现；<code>_common/color.styl:170,174</code> 的 <code>.color_2</code>/<code>.color_4</code> 是主题自带五个配色工具类里恰好没人用的那两个（<code>.color_3</code> 在随机钮的图标 markup 里）。<code>github-markdown.min.css</code> 编译进 bundle 后仍留着 46 条 <code>.pl-*</code>（GitHub 语法高亮 token 类）——本站正文高亮走的是主题自己的 <code>highlight.styl</code>，这批确实是白带的字节，但那是 vendored 文件整体，拆它等于改第三方文件。</p><p><strong>结构层面查过没问题的</strong>：内联副本 <code>layout/_partial/_part/*.ejs</code> 与运行时副本 <code>source/asset/part/*.ejs</code> 一一对应、没有落单，每个 <code>js/part</code> 模块都有 markup、每份运行时 part 都在 precache 清单里。</p><p><strong>最后一处按 &quot; 代码全保留 &quot; 的口径不算死物、但数出来给她看一眼</strong>：<code>main.js:1-2</code> 无条件 <code>import</code> 并在 <code>:44</code> 起调 <code>_run_AV()</code> / <code>_run_Valine()</code>，<code>av-min.js</code> 142,311 + <code>Valine.min.js</code> 126,335 = <strong>268,646 字节</strong>，评论没开也是每页抓（探针在首页 <code>performance.getEntriesByType('resource')</code> 数到 50 个 JS 请求，这两个都在里面）。这是上游主题原本就有的写法，不是这几轮改出来的；要动就是 &quot; 没开评论就不 import&quot;，那属于改上游行为，等她点头再说。</p><h2 id="部署链路">部署链路</h2><p><code>git push</code> 之后由仓库里的 <code>deploy</code> workflow 构建并发到 Pages，不需要任何个人访问令牌，也不需要把本地 <code>public/</code> 传上去。本地推送走代理：<code>git -c http.proxy=http://127.0.0.1:7897 -c https.proxy=http://127.0.0.1:7897 push origin HEAD</code>。</p><p>PWA 这块：正文和列表都是运行时从 <code>/api/*.json</code> 取的，而 <code>sw-toolbox</code> 在 activate 时会清掉运行时缓存，所以页面 HTML、<code>/api/site|posts|tags|categories|search.json</code>、每篇的 <code>/api/posts/&lt;abbrlink&gt;.json</code>、每个 <code>/api/pages/&lt;name&gt;.json</code>、以及 <code>/asset/part/*.html</code> 必须走 precache 才能在离线时打开——这些列表写在 <code>scripts/renderer/$template.js</code> 里，加新页面时构建会自动带上。文章的固定链接是 <code>/posts/:abbrlink/</code>，只靠 front-matter 里的 <code>abbrlink</code>，所以 <strong> 改 md 文件名和标题不会改 URL</strong>；曾经有的那个例外（写了 <code>capsule:</code> 的走时空胶囊）已经删掉了，见上面 &quot; 时空胶囊（已删除）&quot; 一节。</p><h2 id="怎么验的">怎么验的</h2><p>截图核对用 <code>_tools/ffshot.mjs</code>：WebDriver BiDi 直连一个 headless Firefox，参数 <code>--url --out --profile --wait --size --eval --navwait --ua --proxy</code>。三个非默认项是补的：<code>--navwait none</code> 用于 &quot; 页面里挂着流媒体 iframe 时 <code>complete</code> 永远不触发 &quot; 的情况，<code>--ua</code> 通过 <code>general.useragent.override</code> 首选项改 UA，<code>--proxy</code> 给 BiDi 会话设 <code>network.proxy.*</code>（headless Firefox 不吃系统代理，测谷歌必须带）。退出前改成先调 <code>browser.close</code> 再 kill：只 kill 的话 <code>localStorage</code> 来不及落盘，跨启动的记忆测试会全假；即便如此，写入之后仍要在 eval 里 <code>await</code> 几秒等它 flush。样式和页内脚本改完要 <code>hexo clean &amp;&amp; hexo generate</code> 才会落到 <code>public/</code>，光改文件不看构建产物等于没验。这次给 <code>.p-hitokoto-from</code> 加 <code>z-index</code> 时增量 <code>hexo generate</code> 只重写了 17 个文件、连 <code>public/asset/css/style.css</code> 的时间戳都没动，新规则完全没进去，<code>clean</code> 之后才出现——别信增量构建。</p><p>&quot; 这块底色是不是连续的 &quot; 不能靠肉眼看缩略图：用 <code>--eval</code> 取 <code>getBoundingClientRect()</code> 量盒宽，再把截图按 4 倍 NEAREST 放大、用 PIL 沿某一行逐列读亮度，看框内有没有哪一列掉回框外图片的亮度——这次就是这样才量出旧实现名字和 <code>」-</code> 之间 6.667px 没涂。另一个坑：eval 里 <code>classList.add('night')</code> 之后立刻读 <code>getComputedStyle</code> 会拿到换肤前的颜色，哪怕中间 <code>await</code> 了两帧 rAF 也一样，得再 <code>setTimeout</code> 400ms 让主题的 CSS 变量落地，否则 &quot; 夜间底色不对 &quot; 是假的。</p><p>验 iframe 里的播放器不能在主文档里 <code>document.querySelector('video')</code>——那样永远拿到 <code>null</code>，会误判成 &quot; 根本没播 &quot;。WebDriver BiDi 的 <code>browsingContext.getTree</code> 把子框架放在每个 context 的 <code>children</code> 里（不在顶层那个扁平数组里），必须递归下去，再 <code>script.evaluate</code> 指定 <code>target.context</code> 到那个子上下文，才读得到 <code>video.paused / muted / currentTime / duration</code>。同理，<code>el.click()</code> 在 BiDi 里不算真实交互，<code>navigator.userActivation.hasBeenActive</code> 仍然是假，要产生用户激活必须用 <code>input.performActions</code>（pointerMove/pointerDown/Up）。但这条要分清是哪一类结论：headless 冷 profile 在 &quot; 刚打开页面、没人点过 &quot; 这一点上跟真人是一样的，所以它读到的 &quot; 静音起播 &quot; 后来对上了真人侧的反馈；它读不到的那一档是媒体参与度（media engagement，浏览器给常访站点放宽自动播放的那套内部计分），老访客在真浏览器里可能比冷 profile 更宽松。换句话说，冷 profile 能说 &quot; 没激活时会被静音 &quot;，不能说 &quot; 有激活 / 有参与度时会怎样 &quot;。这一轮更大的教训其实跟测试环境无关：我把一条 <strong> 要求 </strong> 当成了 <strong> 测量结果 </strong> 写进文章还标了出处——凡是自治策略这类拿不到真值的结论，能钉多少写多少，钉不到就直说钉不到，别替读者造出处。</p><p>这一轮（改 &quot; 点开才有声 &quot; 和 &quot; 失效自动跳过 &quot;）另有三条。一是 <strong> 别把浏览器的账算到访客头上</strong>：上一版给 &quot; 开启声音 &quot; 做成一个按钮，等于让访客替代码去做它做不到的事——有声自动播放的开关在浏览器手里，按钮只是把成本转移给访客；正确的形态是静音挂载时自己等页面第一次被点，点下那一刻原位重挂接上进度，访客点了什么、点在哪都算。二是 **&quot; 能不能自动检测 &quot; 要先问检测信号存不存在，再问代码会不会写 **：外层读不到跨域 iframe、播放器 postMessage 0 条、探活接口带本站 Origin 又 403，三条都没信号，那 &quot; 运行时遇到失效就跳过 &quot; 就是做不到，硬写只能写成猜测；把同一套判定挪到构建期（那里没有 CORS）立刻就成立了，这也是 &quot; 能不能做 &quot; 的答案要跟着 &quot; 在哪一层做 &quot; 一起给的原因。三是 **&quot; 播放器应该会自己接下一首吧 &quot; 这类想当然必须实测 **：<code>mid=</code> 参数直接落到自带的 <code>error.mp4</code>，66 P 合集放到 P65 结束停在 &quot; 重播 &quot; 不滚 P66——两条都是十分钟能验完的事，不验就会写进文章当常识。</p><p>拿不到截图的时候（这个 Firefox 构建的 <code>browsingContext.captureScreenshot</code> 恒回 <code>invalid argument</code>），纯 CSS 的改动靠两样东西落地：一是 <code>getComputedStyle(el, '::before')</code> 读伪元素的 <code>content/display/color</code>，证明规则真的命中了那个节点；二是几何差值证明它真的占了版面——伪元素没有自己的 rect，于是量 &quot; 宿主元素顶边到它第一个真实子元素顶边 &quot; 的差（首页 63px，没有分隔符时是 32px 内边距）和 &quot; 宿主元素底边到它最后一个真实子元素底边 &quot; 的差（timeline 行 30px）。判断一个符号是不是豆腐块，用必定无字形的私有区码点 <code>\uE000</code> 当参照，比 canvas 涂墨像素数和 <code>measureText().width</code>：两者都与参照一致就可疑，各不相等就是真字形（这轮 <code>✟</code> 384/28.4、<code>⫘</code> 369/51.1、参照 310/38.9）。注意这只能证明 <strong> 本机 </strong> 有字形，跨平台 fallback 不在同一台机器上测不出来。</p><p>这一轮（正文编号、分隔符按行宽、关于页算出来的三处、长 slogan）新增四条。一是 <strong> 动态串的验证不能只看命不命中 </strong>：<code>content</code> 读出来是 JS 写进去的那一串还不算完，要同时报 &quot; 文本实测宽 &quot;“可用宽”&quot; 余量 &quot; 三个数，余量 1px 说明已经贴到极限、但也说明字体度量差一点就溢出，所以两头各微调一次而不是直接 <code>floor(room/unit)</code>。二是 **“被裁掉 &quot; 和 &quot; 换了行 &quot; 要分开判 **：<code>white-space: normal</code> 之后 <code>scrollWidth == clientWidth</code> 才是真没裁，行数用 <code>rect.height / parseFloat(lineHeight)</code> 反推，别按字符数猜。三是<strong> 构建期注入的属性要两端对齐验证</strong>：正文侧读 <code>getComputedStyle(h2, '::before').content</code>，目录侧读 <code>.toc-number</code> 的文本，去掉目录多带的尾点逐字比，再各数一次总数和 &quot; 没有 <code>data-num</code> 的标题数”，只比前几条会漏掉后半篇错位的情况。四是 **<code>public/</code> 是 gitignore 的构建产物 **：在 <code>public/</code> 里手改文件既进不了提交，也会被下一次 <code>hexo clean</code> 整目录抹掉，改东西一律改源（<code>themes/mustom/source/**</code>、<code>source/**</code>），再 <code>clean &amp;&amp; generate</code> 看产物里有没有——这轮的 <code>lang/*.yml</code>、<code>_part/brand.styl</code> 都是这么确认落到 <code>public/asset/lang/*.json</code> 和 <code>public/asset/css/style.css</code> 的。</p><p>这一轮（侧栏四行 slogan、B 站按钮）新增两条。一是 <strong> 探针要量对像 </strong>：我给联系方式那两个链接记的是 &quot; 图标中心 vs 自己盒子中心 &quot;，两个都报 0，于是写进结论说 &quot; 居中已修正 &quot;，但她看到的是两个盒子<strong> 彼此 </strong> 错开 4px——因为盒子的 <strong> 高度 </strong> 就不一样（20 和 24）。同排元素必须比 &quot; 元素之间的 top/center&quot;，不能只比 &quot; 元素在自己内部的位置 &quot;；前者的数字一个都没有，后者全是 0 也照样是错的。二是 **&quot; 转码后再用 &quot; 要证明转码带来了什么 **：把 B 站的 <code>favicon.ico</code> 转成 PNG 我当成一件正经事做了，被问 &quot; 为什么不能直接用 &quot; 之后逐像素一比，ico 和 PNG 完全一致（同样 444 个不透明像素、同一个 <code>#00A1D6</code>），ico 里也只有一帧 32×32，也就是说转码只多出一个中间产物和一条依赖，收益是零。这类 &quot; 我顺手加工了一下 &quot; 的地方，写之前要先问加工换来了什么，换不来就原样存文件。</p><p>补一条测试环境的坑：这一轮 Firefox 157 的 BiDi 在 <code>browsingContext.navigate</code> 上开始回 <code>invalid argument</code>（同一个脚本上午还能跑，<code>session.new</code> 的 capabilities 里没有 <code>setWindowRect</code> 之外的变化），探针里改成先试 <code>navigate</code>、失败就 <code>script.evaluate</code> 里 <code>location.href=</code> 赋值，后续 <code>setViewport</code> 和求值都不受影响。另外 Git Bash 会把命令行里以 <code>/</code> 开头的参数改写成 Windows 路径（<code>&quot;/|11000&quot;</code> 变成 <code>C:/Users/.../Git/</code>），跑探针必须 <code>MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'</code>，否则浏览器打开的是 404 页、量到的全是空——这类 &quot; 探针返回 null&quot; 先怀疑参数没传进去。</p><p>这一轮（署名、时空胶囊、字数行、热力图、随机入口）新增三条。一是 **&quot; 两处都加了 &quot; 这种话要逐处点名 **：<code>api/posts.json</code> 和 &quot; 每篇的 JSON&quot; 是两个不同的 map，我把第三个 map（分类 / 标签里的 <code>postlist</code>）记成了前者，结果真正那份列表一处都没加；改完要拿构建产物的键名列表核对，别拿 &quot; 代码里出现了几次 &quot; 核对。二是 <strong> 版面装不装得下，只有截图会告诉你 </strong>：<code>getOption()</code> 读回来的 <code>visualMap</code> 参数全对、canvas 尺寸也对，图例却被面板左边裁掉半截——DOM/option 探针看不见这种溢出，必须把那一块裁出来放大看；反过来 &quot; 图例隐藏后还分不分档 &quot; 这种问题 DOM 也答不了，得下到 zrender 的 displayList 去数填充色。三是<strong> 探针等的时间不够会得出 &quot; 点了没反应 &quot;</strong>：随机入口连着两次在 3.5 秒读回时 URL 没变，我按站点 bug 去查了一遍处理器（<code>typeof onclick</code> 是 <code>function</code>、手动调 <code>onclick({stopPropagation(){}})</code> 能跳出跳转），把等待放到 6 秒才确认它一直是好的——<code>api('posts')</code> 是一次网络往返，读回时间必须盖过它。另外这版 Firefox 的 BiDi 仍然不接受 <code>browsingContext.captureScreenshot</code> 的 <code>clip</code> / <code>origin</code> 参数（<code>invalid argument</code>），要局部图就整屏截了再用 PIL 按 <code>getBoundingClientRect()</code> 裁；抽屉那种 <code>position:fixed</code> 又自己滚的容器，<code>scrollIntoView</code> 会连页面一起滚走（量出来 <code>y</code> 是负数），得手动改最近可滚祖先的 <code>scrollTop</code>。</p><p>这一轮（翻译栏换行、热力图首行、表格底色、出处气泡、删浮框、删胶囊）新增四条。一是 **“改完看起来对了 &quot; 不算对了，要把故障复现出来再修 **：热力图首行右移那一条，我没有停在 &quot; 起点回退到周日之后 <code>minX</code> 一致 &quot; 就收工，而是在探针里用 <code>setOption</code> 把起点手动改回不回退的那个日期再量一次——第一行 <code>minX</code> 17、其余 6，正好一格，这才确认我修的是她看到的那个现象，不是另一个巧合（这个修法下一轮按她的要求撤掉了，见上面 &quot; 第一行为什么右移了一格（这一条已回退）“和 Round M 那条）。二是 <strong>Firefox 157 的 BiDi <code>input.performActions</code> 形状跟规范文档不一样</strong>：source 对象上直接放 <code>actions</code>（不是套在 <code>parameters</code> 里），动作类型叫 <code>pointerMove</code>（不是 <code>move</code>），错了统一回 <code>invalid argument</code>、不给字段级提示；用真指针 <code>pointerMove</code> 到标题栏上量 <code>element.matches(':hover')</code>，才第一次把 <code>:hover</code> 这条链验实——<code>el.focus()</code> 在无头里不产生 <code>:focus</code> 计算样式，别拿它当 hover 的替身。三是<strong> 探针自己的容器能造出假结论 </strong>：翻译栏那份 fixture 我第一版把宿主宽度写成 &quot; 取 <code>.p-post</code> 的 <code>clientWidth</code>”，首页那个盒子宽度是 0，于是五个浮动块全被挤成一列、每块各占一行，看着像 &quot; 我的 <code>clear</code> 没生效、排版炸了”；把宿主写成固定 640/420/300px 之后同一份 CSS 立刻给出正确的两行。量版面之前先量一下自己那个探针容器是不是 0 宽。同理，<code>setViewport</code> 之后不重新导航，注入的 fixed 盒子会读到 0×0，这条别当成站点回归。四是<strong> 加覆盖规则之前先 grep 同一条链上有没有别人已经写过</strong>：表格那两条我差点把代码块的透明底改回不透明白，靠的是构建产物里把 <code>.markdown-body ... table tr</code> 全列出来看了一遍——源文件里查不如产物里查，产物是最终顺序和最终特异性的唯一真相。</p><p>这一轮（热力图回退、备用 robots、死物普查）新增三条。一是 <strong> 回退也要留对照 </strong>：<code>_tmp/roundM_ui.mjs</code> 在本地 <code>python -m http.server</code> 服务 <code>public/</code> 上量（不碰线上，碰线上会给不蒜子计数），成品区间 <code>2026-05-04 ~ 2026-10-04</code>、第一行 <code>minX</code> 17（23 格）、其余六行 6（各 22 格）；正对照是在探针里用 <code>setOption</code> 把起点手动回退到周日（<code>2026-05-03</code>），第一行立刻变 24 格、<code>minX</code> 6，七行对齐。撤掉一个机制和装上它要用同一套数字，才说明撤的就是那一个。二是<strong> 普查的结论要分成 &quot; 静态看不见 &quot; 和 &quot; 真的没有 &quot;</strong>：<code>.icon-*</code> 那 15 条规则和第一版报的 8 个 &quot; 死配置键 &quot; 都属于前者——类名是运行时的字符串拼接（<code>&quot;icon-&quot; + 站点名 </code>）、键名是数据驱动的 map 迭代，静态 grep 天生看不见；要判 &quot; 真的没有 &quot; 就得往生成物那一侧看（<code>performance.getEntriesByType('resource')</code>、<code>public/manifest.json</code> 的实际内容）。三是<strong> 先量代价再报数</strong>：那 5 张孤儿图 125,513 字节看着吓人，但 <code>sw.js</code> 的 precache 里 <code>/asset/img/</code> 命中 0 次，说明它不进访客的流量账；反过来 <code>av-min.js</code> 加 <code>Valine.min.js</code> 是 268,646 字节、每页真抓（首页那 50 个 JS 请求里就有这两个）。一条 precache 命中数把 &quot; 占仓库 &quot; 和 &quot; 占访客 &quot; 这两种死物钉开，报数时这两个数得一起给。</p><h2 id="还没接的">还没接的</h2><ul><li>评论：主题只支持 Valine，需要 LeanCloud 凭据，而且主题把 appid/appkey 编码成 <code>pass + pointer</code> 两段存进 part 文件，不是填一行就完事的。要开评论再说。</li><li>一言（hitokoto）和背景粒子（evanyou）是主题原有功能，取句接口没换；一言面板的换行和作者行底色改过，见上面 &quot; 一言面板的换行 &quot; 一节。</li><li>直播画面能不能在真人浏览器里出画还没核到：headless Firefox 里那个 h5 播放器始终 <code>video.readyState=0</code>、提示 &quot; 拉流失败，重试播放 &quot;，桌面 UA 和安卓 UA 一样；但同一个浏览器打开 <code>live.bilibili.com/&lt; 房间 &gt;</code> 是能出 <code>blob:</code> 流的，所以不是编解码或网络问题。可嵌性和弹幕层是核到了的。</li><li>看板娘的时段问候：取值已经是访客本地时间（见上面 &quot; 它说’晚安’是看谁的表 &quot; 一节），但 <code>waifu-tips.json</code> 里 <code>time</code> 的分档和措辞是按国内作息写的（<code>0-5</code> 叫 &quot; 夜猫子 &quot;、<code>22-23</code> 说晚安），<code>seasons</code> 那 12 条也全是国内的纪念日与购物节；要不要照访客那一侧的白天黑夜和节日体系重写这套措辞，还没定。</li><li>BGM 面板的几条已知边界：歌单是 <code>checked_at</code> 那天的快照，收藏夹加歌不会自动同步，要跑一次导出工具再跑一次核验工具，最后重新构建；放不出来的条目（稿件删除 <code>-404</code> / 不可见 <code>62002</code> / 权限不足 <code>-403</code>）已由核验工具在构建期全部剔除（993 → 981），但 <strong> 两次构建之间新失效的那条 </strong> 还是会被播成播放器自带的 17 秒 <code>error.mp4</code>——跨域读不到 iframe、播放器不 postMessage、探活接口带本站 Origin 又是 403，运行时没有任何信号，只能等本地表走到点自己切下一首；自动切歌是本地按时长掐的表（多 P 合集的总时长已按 P 校正，校正后清单里只剩 10 条超过 10 分钟，最长的是 <code>BV1ZS4y1z7gM</code> 单 P 12186 秒），有人在 iframe 里拖进度条就会偏移；开播默认静音是浏览器自动播放策略，代码改不了，只能在页面第一次被点时自己重挂接上声音；歌词没有（官方 iframe 不给外层进度和歌词），<code>lyride</code> 那个设置项因此变成只影响页脚内边距的空壳。</li></ul>]]>
    </content>
    <id>https://takomitkm.github.io/posts/2659281223/</id>
    <link href="https://takomitkm.github.io/posts/2659281223/"/>
    <published>2026-09-27T01:00:00.000Z</published>
    <summary>
      <![CDATA[<p>技术栈：Hexo 8 + 主题 Mustom（fork 进仓库，不是 submodule）+ 自托管的 live2d-widget 看板娘 + 一个常驻轮播页 <code>/live/</code>。抽屉菜单用的是主题自带的那个（<code>main.js</code> 的 mobile 分支 + <code>.m-drawer</code>），历史上接过一个 WordPress 的 ShiftNav 插件、现已删除，见下面 ShiftNav 一节。口径（2026-10-03 定，取代原来那条 &quot; 一切资源都要搬进仓库 &quot;）：<strong>能不存仓库就不存仓库 </strong>。凡是有个外部原站可以直接引的东西——目标站点自己的 favicon、字体托管方给的那份 <code>result.css</code>——直接外链，仓库不留副本，显示尺寸由浏览器运行时的 CSS 决定。反过来，站内自己产生、别处取不到的东西（壁纸、看板娘模型、<code>/asset/part/*.html</code>、Font Awesome 那套界面字形）照旧在仓库里；这类图标没有 &quot; 目标站自带的 &quot; 可换，就保持现状。走外链的代价都单独记在对应小节里，见 &quot; 侧栏：图标一律外链目标站点自带的 &quot; 和 &quot; 字体：外链回退链，思源黑体出库 &quot;。主题的编译产物仍然走本地（<code>CDN_ENABLE: false</code>）——这个开关不是 &quot; 只换个图标 &quot; 级别的，它把整份 <code>style.css</code> 和 <code>main.js</code> 换成上游 jinyaoMa 那一份，仓库里对主题做的所有定制都会被绕掉。构建命令和依赖清单在<a href="/posts/1501581470/"> 写作与本地预览命令 </a> 那篇，这里只记改了什么、为什么这么改、以及踩到的坑。</p>
<h2 id="相对原版改了什么">相对原版改了什么</h2>
<table>
<thead>
<tr>
<th>位置</th>
<th>改动</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>themes/mustom/_config.yml</code></td>
<td>菜单留 首页 / 归档 / 关于 / 直播间；联系方式原来只留 GitHub，后来又加了 B 站按钮（见 &quot; 侧栏：slogan 改四行、B 站进联系方式 &quot; 一节）；作者的网易云歌单先换成一首单曲、后来整块换成 <code>biliplayer</code>（<code>playlist</code>/<code>index</code>/<code>countdown</code> 三个字段，见 &quot; 播放器 &quot; 一节）；改 manifest 名称</td>
</tr>
<tr>
<td><code>themes/mustom/layout/_partial/frame.ejs</code></td>
<td>在 <code>&lt;/body&gt;</code> 前注入看板娘的资源引用（ShiftNav 那 6 行后来删了，见 ShiftNav 一节）；右侧栏的 <code>audioplayer</code> partial 换成 <code>biliplayer</code>；汇文明朝体的外链 <code>&lt;link rel=&quot;stylesheet&quot;&gt;</code> 改成 <code>media=&quot;print&quot; onload=&quot;this.media='all'&quot;</code> + <code>&lt;noscript&gt;</code> 兜底（否则该域名不通会整页白屏，见播放器一节）</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/js/main.js</code></td>
<td>删掉主题自带的 <code>L2Dwidget</code> 初始化，函数体改成直接回调，避免两套看板娘抢同一个 canvas；两处不蒜子回调多写一个 <code>page_pv</code> 到本文阅读量；给页脚计数绑触屏气泡；桌面 checklist 里 <code>audioplayer</code> 换成 <code>biliplayer</code>，<code>applyConfig()</code> 回填 <code>autoplay</code>，<code>final_load()</code> 完成块里调 <code>biliplayer.ready()</code></td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_part/biliplayer.styl</code></td>
<td>新面板样式：舞台 <code>padding-bottom: 56.25%</code> 装 iframe，遮罩按 <code>counting/manual/waiting</code> 三种模式切换显示哪块，按钮 <code>min-width: 3.5em; min-height: 1.9em</code></td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_common/color.styl</code></td>
<td>新增 <code>--color-post-title</code> / <code>--color-post-categories</code>（四套皮肤各一份，夜间标题给 <code>#cccccc</code> 免得黑字压在黑底上）和 <code>--color-translater-back</code>（划词条与结果框专用，不透明）；列表标题和分类行不再直接用 <code>--color-link</code></td>
</tr>
<tr>
<td><code>themes/mustom/{layout/_partial/_part,source/asset/part}/sitename.ejs</code> + <code>_part/sitename.styl</code></td>
<td>顶栏站名整条包进 <code>&lt;a href=&quot;/&quot;&gt;</code>，两份模板字节相同；见 &quot; 顶栏站名 &quot; 一节</td>
</tr>
<tr>
<td><code>themes/mustom/_config.yml</code> + <code>asset/{js/common/config.js,js/main.js,js/part/biliplayer.js}</code> + <code>_part/{footer,hitokoto}.styl</code></td>
<td>设置项 <code>lyride</code>（空壳）改名重做成 <code>hideplayer</code> 隐藏播放器；<code>config.js</code> 改成只认默认表里的键，顺手清掉老访客存下来的废键；删掉残留的 <code>:root.lyride .p-footer</code> 内边距</td>
</tr>
<tr>
<td><code>themes/mustom/{js/part/panels.js,css/_part/panels.styl}</code></td>
<td>抽屉里多了一份 <code>.p-panels</code>，改成逐个副本填，并用断点各显示一份；见 ShiftNav 一节</td>
</tr>
<tr>
<td><code>tools/bili_fav_dump.mjs</code> + <code>source/data/bili-playlist.json</code></td>
<td>收藏夹翻页导出脚本和它生成的 993 条歌单快照（面板的 <code>data-playlist</code> 指向 <code>/data/bili-playlist.json</code>）</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/js/plugin/</code></td>
<td>删掉不再被引用的 <code>L2Dwidget.min.js</code> / <code>L2Dwidget.0.min.js</code></td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_common/layout.styl</code></td>
<td>启动遮罩层去掉整屏红底和整屏白纱，只留转环和顶部进度条；<code>.markdown-body table</code> 的 <code>word-break: keep-all</code> 改成只在 ≥1024px 生效，≤1023px 给 <code>code/a/td/th/p/li/h1–h6</code> 加 <code>overflow-wrap: anywhere</code></td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_common/dimension.styl</code></td>
<td><code>$picurl</code> 从作者的 unsplash 随机壁纸换成本地 <code>/asset/img/bg.jpg</code>；新增 <code>$divider</code>（列表页两篇文章之间那串符号，见 &quot; 列表页的分隔符 &quot; 一节）</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/img/qf3cu.huaji.png</code></td>
<td>一言面板头部那张图，换成 pixiv 作品 51125671（文件名 <code>7bg51125671_p0.jpg</code>，1169×622），等比缩到 700 宽存成 PNG；画师和再发布授权没核，和壁纸那张一样只是把出处记在仓库里</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_common/color.styl</code></td>
<td>默认皮肤：body 背景换成那张图；白底背景族（默认 10 处 + colorful 7 处）的 alpha 现在是 <code>80</code>（50% 不透明），试过 <code>4d</code>→<code>b3</code>→<code>80</code> 三档，见 &quot; 换壁纸和半透明面板 &quot; 一节</td>
</tr>
<tr>
<td><code>themes/mustom/{layout/_partial/_part,source/asset/part}/{footer,post}.ejs</code></td>
<td>页脚三个计数各包一层带注释气泡的 <code>.p-footer-count-item</code>；本文阅读量的容器从 <code>leancloud_visitors</code> 换成 <code>.p-post-pv-num</code></td>
</tr>
<tr>
<td><code>themes/mustom/{layout/_partial/_part,source/asset/part}/biliplayer.ejs</code></td>
<td>新的 B 站 BGM 面板模板，两份字节相同；旧的 <code>audioplayer.ejs</code>（两份）连同 <code>audioplayer.js</code>、<code>audioplayer.styl</code>、<code>APlayer.min.js/.css</code>、<code>Meting.min.js</code> 一起删除</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/js/part/biliplayer.js</code></td>
<td>面板组件：构建期清单 + <code>sessionStorage</code> 选歌、3 秒倒计时、<code>deadline</code> 每 2 秒对表自动切歌；静音挂载时挂一次性 <code>pointerdown</code>/<code>keydown</code> 监听，页面第一次被点就用 <code>&amp;t=</code> 原位重挂接上声音（不再有 &quot; 开启声音 &quot; 按钮）</td>
</tr>
<tr>
<td><code>tools/bili_playlist_fix.mjs</code></td>
<td>构建期歌单核验：逐条问 <code>x/web-interface/view</code>，非 0 内容状态码（<code>-404</code>/<code>62002</code>/<code>-403</code>，实测这三种都在播播放器自带的 17 秒 <code>error.mp4</code>）直接剔除，其余按 <code>pages[]</code> 把多 P 合集的总时长校正成单 P 时长；风控码计入中止阈值，避免大面积误删</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/js/part/skin.js</code></td>
<td><code>set(key, byUser)</code> 区分点击和程序设置；<code>colorIcons</code> 加排除表，<code>i.fa-github</code> 不参与 colorful 轮转</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/js/part/settings.js</code></td>
<td>同样给 <code>set(key, flag, byUser)</code> 加来源标记，避免开屏把默认值写成 &quot; 访客的选择 &quot;</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/js/common/config.js</code></td>
<td>新增 <code>configChosen</code> 键和 <code>has()</code>，用来区分 &quot; 访客真选过 &quot; 和 &quot; 跟着别的项一起被存进去的默认值 &quot;；默认项多一个 <code>translater: 'zh'</code></td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/js/common/util.js</code></td>
<td>删掉 <code>baiduTranslate</code> 连同 md5 / JSONP 依赖，换成免密钥的 <code>googleTranslate</code></td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/js/part/translater.js</code></td>
<td>三个语言分支合并成一个，改调谷歌；记住上次选的目标语言并标成默认</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_part/translater.styl</code></td>
<td>被记住的那个语言项加 <code>.default</code> 下划线</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_part/hitokoto.styl</code></td>
<td>句子改成只在标点处换行；作者行的 <code>-「 名字 」-</code> 收进一个 <code>inline-block</code> 盒子铺面板同款底色（不分三段，见 &quot; 一言面板 &quot; 一节）；<code>.p-hitokoto-from</code> 补 <code>z-index: 1</code>，修掉夜间作者行被图片整条盖住；≤1023px 挂 <code>.p-hitokoto-bgm</code> 的几条（图片让位给播放器、隐藏播放器时图片回来、作者行回常规流、播放器自带标题栏隐藏），见 &quot; 播放器 &quot; 一节</td>
</tr>
<tr>
<td><code>themes/mustom/_config.yml</code></td>
<td>serviceWorker 的 networkOnly 白名单加了 `bilibili.com</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_part/footer.styl</code></td>
<td>注释气泡的样式</td>
</tr>
<tr>
<td><code>themes/mustom/{layout/_partial/_part,source/asset/part}/page.ejs</code></td>
<td>独立页面的标题条右侧加 <code>.p-page-pv</code>（本页访问量，沿用 <code>.p-post-pv-num</code> 这个 class，所以不用改 JS）</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_part/page.styl</code></td>
<td>标题条改成 flex，访问量顶到右边；移动端图标隐藏规则里给眼睛开了个口子</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/js/part/comment.js</code></td>
<td>删掉给 <code>.leancloud_visitors</code> 设 id 和 <code>data-flag-title</code> 的那个分支</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/live2d/umaru/</code></td>
<td>看板娘模型，haruto 那套已删</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/lang/{zh-cn,en}.yml</code></td>
<td>新菜单项的显示名和面包屑名；页脚气泡文案 <code>footer.tipPv/tipUv/tipWd</code>、<code>page.viewcount</code>，<code>post.viewcount</code> 改成说明是不蒜子按本页地址统计；<code>translater.to</code> 标明翻译走谷歌；<code>biliplayer.*</code> 一组面板文案（标题 / 即将播放 / 取消自动播放 / 播放 / 上下首）；<code>sitename</code> 改成 &quot; 自留地 &quot;</td>
</tr>
<tr>
<td><code>source/about/index.md</code></td>
<td>正文里加过 B 站空间链接 <a href="https://space.bilibili.com/675978454">https://space.bilibili.com/675978454</a>（页面正文走运行时 <code>/api/pages/about.json</code>，不是 HTML 里），后来这行删了、改成侧栏的 B 站按钮，见下面两节</td>
</tr>
<tr>
<td><code>deno.lock</code></td>
<td>纳入版本控制：本地 <code>deno run -A npm:hexo</code> 的 npm 依赖解析锁，只记 <code>packageJson.dependencies</code> 那十条；线上 Actions 走 <code>npm install</code>，不读这个文件</td>
</tr>
<tr>
<td><code>source/live2d/</code></td>
<td>live2d-widgets 1.0.1 的 <code>dist</code>，换装按钮已关掉</td>
</tr>
<tr>
<td><code>source/live2d/dist/waifu-tips.js</code></td>
<td>vendor bundle 的两处改动：取时段问候那段拆掉 <code>&quot;/&quot; === location.pathname</code>（现在每页都出，根路径仍只有时段问候、别的页面在后面接欢迎行），<code>export</code> 前加一行 <code>window.waifuShowTip=i;</code> 把气泡显示函数交出去</td>
</tr>
<tr>
<td><code>source/live2d/loader.js</code></td>
<td><code>initWidget()</code> 之后自己再拉一次 <code>waifu-tips.json</code>，30 秒轮询做整点报时（换小时才报、气泡不在就不报且不记账，避免复活被 quit 掉的看板娘）</td>
</tr>
<tr>
<td><code>source/live2d/waifu-tips.json</code></td>
<td>新增 <code>message.hourly</code> 两条报时文案，<code>{hour}</code> 由 loader 替换</td>
</tr>
<tr>
<td><code>source/live2d/site.css</code></td>
<td>≤1023px：canvas 150×150、气泡 180 宽 13 号字、<code>translateY</code> 收到 12px、<code>#waifu-tool</code> 常显（触屏没 hover）</td>
</tr>
<tr>
<td><code>themes/mustom/scripts/plugin/hexo-generator-restful/lib/generator.js</code></td>
<td>新增 <code>preview(str, limit)</code>：剥标签→解实体→压空白→按字数切→补 <code>......</code>→重新转义；<code>postMap</code>（<code>/api/posts.json</code>）和 <code>postlist</code>（分类／标签）两个调用点用它，单篇那条不动</td>
</tr>
<tr>
<td><code>themes/mustom/_config.yml</code></td>
<td>新增 <code>excerpt_chars: 500</code> 给上面那个截断用</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_plugin/highlight.styl</code></td>
<td>≤1023px：<code>pre/.highlight/.gist</code> 的负 <code>margin</code> 出血归零、左右 padding 40→8px、<code>.highlight pre</code> 改 <code>pre-wrap</code>、<code>.gutter</code> 行号列隐藏、<code>.highlight table</code> 收 <code>width: 100%</code></td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_part/recentposts.styl</code></td>
<td>首页列表条目之间加 <code>$divider</code> 分隔符，走 <code>::before</code> + <code>display: table-caption</code>（这类条目本身是 <code>display: table</code>），见 &quot; 列表页的分隔符 &quot; 一节</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_part/timeline.styl</code></td>
<td>归档／分类／标签列表同样加 <code>$divider</code>，走 <code>.p-timeline-row::after</code> + <code>clear: both</code>，末篇用两层 <code>:last-child</code> 免掉</td>
</tr>
<tr>
<td><code>source/about/index.md</code></td>
<td>正文末尾一段页内 <code>&lt;style&gt;</code> + 一个 <code>.about-stamp</code> 空 div：关于页背景右上角一枚红 <code>#ff3300</code>、70% 透明、圆圈套住 &quot; 偷 &quot; 的内联 SVG，直径为文章宽度一半，见同一节</td>
</tr>
<tr>
<td><code>themes/mustom/scripts/plugin/heading-numbers.js</code></td>
<td>新增：构建期给正文每个标题写 <code>data-num=&quot;2.1&quot;</code> 这样的序号，算法和 <code>toc.js</code> 同源，见 &quot; 正文标题序号为什么全是 0.x&quot; 一节</td>
</tr>
<tr>
<td><code>themes/mustom/scripts/plugin/hexo-generator-restful/lib/generator.js</code></td>
<td>单篇 <code>/api/posts/&lt;abbrlink&gt;.json</code> 生成前先 <code>headingNumbers(post.content)</code>，<code>content</code> 和 <code>toc</code> 都用这份带 <code>data-num</code> 的 HTML，两者不会各算各的</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_part/post.styl</code></td>
<td>删掉 <code>headingContent()</code> 和整套 <code>counter-reset</code>/<code>counter-increment</code>，标题号改成 <code>h{$num}[data-num]:before{content: attr(data-num) &quot;. &quot;}</code></td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_common/layout.styl</code></td>
<td><code>.markdown-body{counter-reset: h1}</code> 这条没用的删了（正文里根本没有 h1，它只是让 <code>counter(h1)</code> 从 0 开始）</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_common/dimension.styl</code></td>
<td><code>$divider</code> 下面加一行 <code>$divider_value = var(--divider, $divider)</code>，两个消费点改用这个变量，样式里仍留一份静态兜底</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_part/recentposts.styl</code>、<code>_part/timeline.styl</code></td>
<td>分隔符的 <code>content</code> 换成 <code>$divider_value</code>，各自补 <code>white-space: nowrap</code>（⫘ 的个数变多后不能让它在行中间断行）</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/js/main.js</code></td>
<td>新增 <code>fitDividers()</code> 和 <code>watchDividers()</code>：按列表行实际可用宽度重算 ⫘ 的个数，写成 <code>.m-content</code> 的行内 <code>--divider</code>；用 <code>ResizeObserver</code> 盯 <code>.m-content</code>，在 <code>final_load()</code> 完成块里紧跟 <code>biliplayer.ready()</code> 调一次</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/lang/{zh-cn,en}.yml</code></td>
<td><code>brand.slogan</code> 换成 &quot; 至虛極也守情裻也萬物旁作吾以觀其復也 &quot; 和 &quot;y emprendo mi andar a una ruta real&quot;（yml 里两头用 <code>&amp;quot;</code>，<code>lang.js</code> 是 <code>innerHTML</code> 注入，实体会解成真引号）</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_part/brand.styl</code></td>
<td><code>.p-brand-slogan</code> 去掉 <code>white-space: nowrap</code>：中文 slogan 16 字在 16px 下要 300px，而 <code>.p-brand</code> 只有 256px 且 <code>overflow: hidden</code>，不换行就直接裁掉尾巴</td>
</tr>
<tr>
<td><code>source/about/index.md</code></td>
<td>括号里那三条写给我的说明换成运行时算出来的内容：<code>#abBorn</code>（访客本地日期往前 17 年）、<code>#abHeight</code>（160 + 访客本地小时）、两条装饰线走 <code>data-fill</code> 平铺截断，见 &quot; 关于页那三处算出来的东西 &quot; 一节</td>
</tr>
<tr>
<td><code>themes/mustom/_config.yml</code></td>
<td><code>contact</code> 里加 <code>bilibili</code> 一条（<code>url</code> + <code>&lt;img&gt;</code> 图标），侧栏联系方式多一个 B 站按钮，和 GitHub 那条走同一套机制；后来两条的图标都换成直链目标站 favicon 的 <code>&lt;img&gt;</code>（GitHub 原来是 <code>&lt;i class=&quot;fab fa-github&quot;&gt;</code>），见 &quot; 侧栏：slogan 改四行、B 站进联系方式 &quot; 一节</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/lang/{zh-cn,en}.yml</code></td>
<td>中文 <code>brand.slogan</code> 换成四行（值里直接写 <code>&lt;br&gt;</code>），新增 <code>brand.bilibili</code>（中「通过 B 站关注我」／英「Follow me on bilibili」）；英文 <code>brand.slogan</code> 去掉两头那对 <code>&amp;quot;</code></td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/img/bilibili.ico</code></td>
<td><strong>已删除</strong>。它确实是 B 站 <code>https://www.bilibili.com/favicon.ico</code> 的原文件（4286 字节、md5 <code>f2d2896c488493e18c1b112cdd9bb1d9</code>，一字节没改），但存进仓库这一步本身是多余的：外链实测 200、<code>image/x-icon</code>、带跨站 <code>Referer</code> 也给，<code>&lt;img&gt;</code> 直接解码，仓库里那份和源站的逐字节相同（删除前 md5 对过）。删掉后 <code>&lt;img src&gt;</code> 直接指向源站</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_part/brand.styl</code></td>
<td><code>.p-brand-slogan</code> 行高 <code>3</code>→<code>1.6</code> 并补上下 <code>$gap</code> 内边距（四行必需）；<code>.p-brand-contacts a</code> 换 <code>inline-flex</code> 并写死 <code>2em×2em</code> + <code>vertical-align: middle</code>，否则字体图标和图片图标会错开 4px；新增 <code>.p-brand-contact-img</code> 尺寸（<code>1.2em</code>，图标尺寸只在这里给，浏览器运行时缩放）；新增 <code>:root.night .p-brand-contact-dark{filter: invert(1)}</code>——GitHub 自带的 favicon 是近黑的 <code>#171515</code>，夜间不翻就是看不见</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_common/layout.styl</code></td>
<td>抽屉里那份 <code>.p-brand-slogan</code> 行高 <code>3.35</code>→<code>1.6</code>；<code>.m-drawer</code> 桌面块加 <code>overflow-y: auto</code>——它是定高 <code>fixed</code> 且原来 <code>overflow: hidden</code>，卡片变高会把皮肤／设置顶到够不着的地方</td>
</tr>
<tr>
<td><code>source/about/index.md</code></td>
<td>正文里「B 站空间：<a href="https://space.bilibili.com/675978454">https://space.bilibili.com/675978454</a>」那行删掉，空间链接收到侧栏按钮，与「通过 Github 关注我」同步</td>
</tr>
<tr>
<td><code>themes/mustom/layout/_partial/frame.ejs</code></td>
<td>字体外链补成两束：原有那条汇文明朝体之后再加一条上图东观体 - 粗体，同样的 <code>media=&quot;print&quot; onload=&quot;this.media='all'&quot;</code> 延迟加载写法 + <code>&lt;noscript&gt;</code> 里两份兜底；再加一行 <code>&lt;link rel=&quot;preconnect&quot;&gt;</code> 提前握域名（见 &quot; 字体：外链回退链 &quot; 一节）。两条都在 <code>CDN_ENABLE</code> 的 <code>else</code> 分支里</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/css/_common/layout.styl</code></td>
<td>字体链换成 <code>'Huiwen-mincho', 'STDongGuanTi Bld', Arial, Helvetica, sans-serif</code>（<code>body</code> 和 <code>.markdown-body</code> 两处），删掉 <code>@font-face{Source Han Sans CN}</code> 和底下那行注释掉的重复 <code>src</code>；<code>:root.night</code> 那条 <code>img{filter: brightness(0.8)}</code> 拆成 <code>#live2d-widget</code> 和 <code>img:not([src^=&quot;http&quot;])</code> 两条，外链图片夜间不再被压暗</td>
</tr>
<tr>
<td><code>themes/mustom/source/asset/font/SourceHanSansCN.otf</code></td>
<td><strong>已删除 </strong>，8,800,680 字节，是这仓库里最大的单个文件；删完全站在主题和站点源码里 <code>grep</code> <code>Source Han</code>／<code>SourceHan</code>／<code> 思源</code> 是 0 命中</td>
</tr>
<tr>
<td><code>source/live/index.md</code></td>
<td>直播间页，线路表和播放器都在页内</td>
</tr>
<tr>
<td><code>source/shiftnav/</code></td>
<td><strong>已删除</strong>（见下面 ShiftNav 一节）：整个目录连同 <code>frame.ejs</code> 里 6 行引用一起清掉，只把两条还在生效的 CSS 规则搬进主题</td>
</tr>
<tr>
<td><code>_config.yml</code></td>
<td>写死一段显式 <code>markdown:</code> 配置；<code>description</code> 从 <code>personal blog</code> 改成 <code>自留地</code>（见 &quot; 站名 &quot; 一节）</td>
</tr>
</tbody>
</table>]]>
    </summary>
    <title>建站流程</title>
    <updated>2026-10-17T19:36:51.420Z</updated>
  </entry>
</feed>
