<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>Looks</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://7788dev.github.io/</id>
  <link href="https://7788dev.github.io/" rel="alternate"/>
  <link href="https://7788dev.github.io/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, Looks</rights>
  <subtitle>属于我的思考</subtitle>
  <title>Looks Blog</title>
  <updated>2026-05-10T09:13:28.393Z</updated>
  <entry>
    <author>
      <name>Looks</name>
    </author>
    <category term="逆向" scheme="https://7788dev.github.io/categories/%E9%80%86%E5%90%91/"/>
    <category term="逆向" scheme="https://7788dev.github.io/tags/%E9%80%86%E5%90%91/"/>
    <category term="猫眼" scheme="https://7788dev.github.io/tags/%E7%8C%AB%E7%9C%BC/"/>
    <category term="字体反爬" scheme="https://7788dev.github.io/tags/%E5%AD%97%E4%BD%93%E5%8F%8D%E7%88%AC/"/>
    <category term="Python" scheme="https://7788dev.github.io/tags/Python/"/>
    <content>
      <![CDATA[<blockquote><p>这篇只做记录。文中涉及的算法、密钥、接口仅用于说明反爬机制，不提供可直接运行的采集脚本。</p></blockquote><h2 id="TL-DR"><a href="#TL-DR" class="headerlink" title="TL;DR"></a>TL;DR</h2><p>目标是 <code>https://piaofang.maoyan.com/i/dashboard/movie</code> 这块实时大盘。接口一共四道坎：</p><ol><li><strong>signKey</strong> MD5 签名，拼接字符串里带固定密钥</li><li><strong>mygsig</strong> 美团自研的风控签名，由 <code>0.0.67_tool.js</code> 生成</li><li><strong>WuKong</strong> headless 检测，URL 里带个 <code>WuKongReady=h5</code> 就过</li><li><strong>woff 字体加密</strong> 数字不是明文，是每次动态生成的字体文件里的 glyph</li></ol><p>前三个是纯算法，可以完全脱离浏览器。第四个最烦——woff 里的 cmap 每次都不一样，Unicode 到数字的映射要你自己想办法认出来。</p><h2 id="一、接口长什么样"><a href="#一、接口长什么样" class="headerlink" title="一、接口长什么样"></a>一、接口长什么样</h2><figure class="highlight awk"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs awk">GET https:<span class="hljs-regexp">//</span>piaofang.maoyan.com<span class="hljs-regexp">/i/</span>api<span class="hljs-regexp">/dashboard-ajax/m</span>ovie<br></code></pre></td></tr></table></figure><p>Query 参数：</p><table><thead><tr><th>参数</th><th>说明</th></tr></thead><tbody><tr><td><code>movieId</code></td><td>电影 ID，空字符串代表查全部</td></tr><tr><td><code>orderType</code></td><td>0&#x3D;综合票房，1&#x3D;分账票房</td></tr><tr><td><code>uuid</code></td><td>页面初始化时服务端下发</td></tr><tr><td><code>timeStamp</code></td><td>毫秒时间戳</td></tr><tr><td><code>User-Agent</code></td><td>Base64(UA) 字符串</td></tr><tr><td><code>index</code></td><td><code>Math.floor(1000 * Math.random() + 1)</code></td></tr><tr><td><code>channelId</code></td><td>固定 <code>40009</code>，也可以从页面数据里读</td></tr><tr><td><code>sVersion</code></td><td>签名版本，固定 2</td></tr><tr><td><code>signKey</code></td><td>MD5 签名（核心）</td></tr><tr><td><code>WuKongReady</code></td><td>固定 <code>h5</code></td></tr></tbody></table><p>请求头里另外需要：</p><table><thead><tr><th>Header</th><th>说明</th></tr></thead><tbody><tr><td><code>m-appkey</code></td><td>固定 <code>fe_com.sankuai.movie.fe.ipro</code></td></tr><tr><td><code>mygsig</code></td><td>风控签名</td></tr><tr><td><code>uid</code></td><td>从 HTML 里 <code>&lt;meta name=&quot;csrf&quot;&gt;</code> 拿，偶尔可以为空</td></tr><tr><td><code>M-TRACEID</code></td><td>一个随机负数 ID，长度 19 位左右</td></tr></tbody></table><h2 id="二、signKey"><a href="#二、signKey" class="headerlink" title="二、signKey"></a>二、signKey</h2><p>这个最简单。源码在 <code>https://s0.pipi.cn/festatic/moviepro/js/largeScreenMovieIndex_e52ad780.js</code> 第 8710 行附近，简化后的算法：</p><figure class="highlight javascript"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-keyword">function</span> <span class="hljs-title function_">getQueryKey</span>(<span class="hljs-params">&#123; channelId, timeStamp &#125;</span>) &#123;<br>  <span class="hljs-keyword">var</span> params = &#123;<br>    <span class="hljs-attr">method</span>: <span class="hljs-string">&#x27;GET&#x27;</span>,<br>    <span class="hljs-attr">timeStamp</span>: timeStamp || +<span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(),<br>    <span class="hljs-string">&#x27;User-Agent&#x27;</span>: <span class="hljs-variable language_">window</span>.<span class="hljs-title function_">btoa</span>(navigator.<span class="hljs-property">userAgent</span>),<br>    <span class="hljs-attr">index</span>: <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">floor</span>(<span class="hljs-number">1000</span> * <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">random</span>() + <span class="hljs-number">1</span>),<br>    <span class="hljs-attr">channelId</span>: channelId,            <span class="hljs-comment">// 40009</span><br>    <span class="hljs-attr">sVersion</span>: <span class="hljs-number">2</span>,<br>    <span class="hljs-attr">key</span>: <span class="hljs-string">&#x27;A013F70DB97834C0A5492378BD76C53A&#x27;</span>  <span class="hljs-comment">// 固定密钥</span><br>  &#125;;<br><br>  <span class="hljs-comment">// 按对象遍历顺序拼接 k=v&amp;k=v（空值会写成 key=&#x27;&#x27;）</span><br>  <span class="hljs-keyword">var</span> paramStr = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">keys</span>(params).<span class="hljs-title function_">reduce</span>(<span class="hljs-keyword">function</span> (<span class="hljs-params">str, k</span>) &#123;<br>    <span class="hljs-keyword">return</span> params[k] === <span class="hljs-number">0</span> || params[k]<br>      ? str + <span class="hljs-string">&#x27;&amp;&#x27;</span> + k + <span class="hljs-string">&#x27;=&#x27;</span> + params[k]<br>      : str + <span class="hljs-string">&#x27;&amp;&#x27;</span> + k + <span class="hljs-string">&quot;=&#x27;&#x27;&quot;</span>;<br>  &#125;, <span class="hljs-string">&#x27;&#x27;</span>).<span class="hljs-title function_">slice</span>(<span class="hljs-number">1</span>);<br><br>  <span class="hljs-keyword">var</span> signKey = <span class="hljs-title function_">md5</span>(paramStr.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/\s+/g</span>, <span class="hljs-string">&#x27; &#x27;</span>));<br><br>  <span class="hljs-keyword">delete</span> params.<span class="hljs-property">method</span>;<br>  <span class="hljs-keyword">delete</span> params.<span class="hljs-property">key</span>;  <span class="hljs-comment">// 这两个不发到 URL</span><br><br>  <span class="hljs-keyword">return</span> &#123; <span class="hljs-attr">finalQuery</span>: &#123; ...params, signKey &#125;, signKey &#125;;<br>&#125;<br></code></pre></td></tr></table></figure><p>Python 复现：</p><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><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs python"><span class="hljs-keyword">def</span> <span class="hljs-title function_">get_sign_key</span>(<span class="hljs-params">timestamp, ua_b64, index, channel_id=<span class="hljs-string">&#x27;40009&#x27;</span></span>):<br>    params_str = (<br>        <span class="hljs-string">f&quot;method=GET&quot;</span><br>        <span class="hljs-string">f&quot;&amp;timeStamp=<span class="hljs-subst">&#123;timestamp&#125;</span>&quot;</span><br>        <span class="hljs-string">f&quot;&amp;User-Agent=<span class="hljs-subst">&#123;ua_b64&#125;</span>&quot;</span><br>        <span class="hljs-string">f&quot;&amp;index=<span class="hljs-subst">&#123;index&#125;</span>&quot;</span><br>        <span class="hljs-string">f&quot;&amp;channelId=<span class="hljs-subst">&#123;channel_id&#125;</span>&quot;</span><br>        <span class="hljs-string">f&quot;&amp;sVersion=2&quot;</span><br>        <span class="hljs-string">f&quot;&amp;key=A013F70DB97834C0A5492378BD76C53A&quot;</span><br>    )<br>    <span class="hljs-keyword">return</span> hashlib.md5(params_str.encode()).hexdigest()<br></code></pre></td></tr></table></figure><p>注意三件事：</p><ul><li><strong>拼接顺序固定</strong>，跟 JS 里 <code>Object.keys</code> 的遍历顺序一致，别自作主张排序</li><li><code>User-Agent</code> 要先 Base64 再写进字符串，别把明文 UA 塞进去</li><li><code>key</code> 参与签名但<strong>不</strong>发到 URL</li></ul><h2 id="三、mygsig"><a href="#三、mygsig" class="headerlink" title="三、mygsig"></a>三、mygsig</h2><p><code>mygsig</code> 是美团风控 SDK 生成的设备指纹签名，出自 <code>https://s0.pipi.cn/mediaplus/basic_tools_js/0.0.67_tool.js</code>，代码经过重度混淆。不带这个 header 一般直接返回空数据。</p><p>结构长这样：</p><figure class="highlight json"><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><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;m1&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;0.0.3&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;m2&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">0</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;m3&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;0.0.67_tool&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;ms1&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;6d78fd79...&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;ts&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">1778400808222</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;ts1&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">1778400522822</span><br><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><ul><li><code>m1</code>、<code>m3</code> 写死</li><li><code>m2</code> 写 0</li><li><code>ts</code> 当前时间戳（毫秒）</li><li><code>ts1</code> 页面加载时间戳，首次请求时可以取 <code>ts - 几百ms</code></li><li><code>ms1</code> 是核心</li></ul><p><code>ms1</code> 的算法扒出来之后是这样：</p><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><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><code class="hljs python"><span class="hljs-keyword">def</span> <span class="hljs-title function_">get_mygsig</span>(<span class="hljs-params">full_url, timestamp, page_load_ts</span>):<br>    parsed = urlparse(full_url)<br>    qp = parse_qs(parsed.query, keep_blank_values=<span class="hljs-literal">True</span>)<br>    merged = &#123;k: v[<span class="hljs-number">0</span>] <span class="hljs-keyword">for</span> k, v <span class="hljs-keyword">in</span> qp.items()&#125;<br>    merged[<span class="hljs-string">&#x27;path&#x27;</span>] = parsed.path  <span class="hljs-comment"># 把 path 也塞进去</span><br><br>    <span class="hljs-comment"># 按 key 字母顺序排（不区分大小写），取 value 用 &quot;_&quot; 连起来</span><br>    sorted_entries = <span class="hljs-built_in">sorted</span>(merged.items(), key=<span class="hljs-keyword">lambda</span> x: x[<span class="hljs-number">0</span>].lower())<br>    joined = <span class="hljs-string">&quot;_&quot;</span>.join(<span class="hljs-built_in">str</span>(v) <span class="hljs-keyword">for</span> _, v <span class="hljs-keyword">in</span> sorted_entries)<br><br>    <span class="hljs-comment"># &quot;581409236#&quot; + joined + &quot;$&quot; + ts -&gt; md5</span><br>    full = <span class="hljs-string">f&quot;581409236#<span class="hljs-subst">&#123;joined&#125;</span>$<span class="hljs-subst">&#123;timestamp&#125;</span>&quot;</span><br>    ms1 = hashlib.md5(full.encode()).hexdigest()<br><br>    <span class="hljs-keyword">return</span> json.dumps(&#123;<br>        <span class="hljs-string">&quot;m1&quot;</span>: <span class="hljs-string">&quot;0.0.3&quot;</span>, <span class="hljs-string">&quot;m2&quot;</span>: <span class="hljs-number">0</span>, <span class="hljs-string">&quot;m3&quot;</span>: <span class="hljs-string">&quot;0.0.67_tool&quot;</span>,<br>        <span class="hljs-string">&quot;ms1&quot;</span>: ms1, <span class="hljs-string">&quot;ts&quot;</span>: timestamp, <span class="hljs-string">&quot;ts1&quot;</span>: page_load_ts<br>    &#125;, separators=(<span class="hljs-string">&quot;,&quot;</span>, <span class="hljs-string">&quot;:&quot;</span>))<br></code></pre></td></tr></table></figure><p>几个容易踩的点：</p><ul><li><strong>value 里该有空串就给空串</strong>，<code>parse_qs</code> 在 <code>keep_blank_values=False</code> 时会把 <code>movieId=</code> 这种空参丢掉，签出来就错</li><li><strong>排序按 key 小写做</strong>，<code>User-Agent</code> 会排到 <code>uid</code> 附近，不是按原大小写</li><li>要把 <code>path</code>（不带 query）作为额外字段一起参与，这个设计是想抗”改 URL 路径”的攻击</li></ul><p>这一套算法里最容易被卡的是”忘把 <code>path</code> 加进去”以及”空参没保留”。</p><h2 id="四、WuKong"><a href="#四、WuKong" class="headerlink" title="四、WuKong"></a>四、WuKong</h2><p><code>https://s0.pipi.cn/mediaplus/basic_tools_js/WuKong_1.0.2.min.js</code> 这个脚本干的事是：</p><ul><li>浏览器环境里扫 headless 特征（<code>navigator.webdriver</code>、Plugin 数量、<code>window.chrome</code> 等）</li><li>通过之后在 URL 里加 <code>WuKongReady=h5</code></li></ul><p>纯 Python 脚本绕这一层的办法很朴素：<strong>直接把 <code>WuKongReady=h5</code> 拼进 URL</strong>。这是个被动检查点，服务端只是看参数在不在，不会回过头去挑战你的运行时。</p><h2 id="五、woff-字体加密（最花时间的一步）"><a href="#五、woff-字体加密（最花时间的一步）" class="headerlink" title="五、woff 字体加密（最花时间的一步）"></a>五、woff 字体加密（最花时间的一步）</h2><p>响应 body 里的票房数字不是明文，是像这样的 HTML 实体：</p><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs xml"><span class="hljs-symbol">&amp;#xe886;</span><span class="hljs-symbol">&amp;#xf16b;</span><span class="hljs-symbol">&amp;#xf23f;</span><span class="hljs-symbol">&amp;#xf05a;</span><br></code></pre></td></tr></table></figure><p>对应的 CSS 在 <code>fontStyle</code> 字段里，是一段 <code>@font-face</code>：</p><figure class="highlight css"><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><code class="hljs css"><span class="hljs-keyword">@font-face</span> &#123;<br>  <span class="hljs-attribute">font-family</span>: <span class="hljs-string">&quot;mtsi-font&quot;</span>;<br>  <span class="hljs-attribute">src</span>: <span class="hljs-built_in">url</span>(<span class="hljs-string">&quot;//s3plus.meituan.net/v1/mss_73a511b8f91f43d0bdae92584ea6330b/font/abc123.woff&quot;</span>) <span class="hljs-built_in">format</span>(<span class="hljs-string">&quot;woff&quot;</span>);<br>&#125;<br></code></pre></td></tr></table></figure><p>每次请求 <code>.woff</code> 的 URL 和里面的 cmap 都不一样。浏览器拿到字体文件自己渲染出正确数字，我们要自己把这个映射重建出来。</p><h3 id="思路"><a href="#思路" class="headerlink" title="思路"></a>思路</h3><p><code>.woff</code> 里的 <code>cmap</code> 表只告诉你”Unicode → GlyphID”，但没告诉你 GlyphID 对应哪个数字。数字长什么样是藏在 <code>glyf</code> 表（TrueType 字形轮廓）里的。</p><p>能跑的方案大致三条：</p><ol><li><strong>轮廓特征匹配</strong>（本文方案）：提取每个 glyph 的轮廓数量、点数、宽高比、内外轮廓位置，按规律分配数字</li><li><strong>OCR</strong>：把字体渲染成图片后用 tesseract 或自训模型认</li><li><strong>模板字体对比</strong>：找一份正常的数字字体做基准，用 glyph 路径做相似度匹配</li></ol><p>方案一最快、不依赖额外模型，但要你亲自看过几个样本的 glyph 轮廓找规律。</p><h3 id="用-fontTools-提特征"><a href="#用-fontTools-提特征" class="headerlink" title="用 fontTools 提特征"></a>用 fontTools 提特征</h3><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><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><code class="hljs python"><span class="hljs-keyword">from</span> fontTools.ttLib <span class="hljs-keyword">import</span> TTFont<br><span class="hljs-keyword">from</span> io <span class="hljs-keyword">import</span> BytesIO<br><span class="hljs-keyword">import</span> requests<br><br><span class="hljs-keyword">def</span> <span class="hljs-title function_">collect_glyphs</span>(<span class="hljs-params">woff_url</span>):<br>    resp = requests.get(woff_url)<br>    font = TTFont(BytesIO(resp.content))<br>    cmap = font.getBestCmap()<br>    glyf = font[<span class="hljs-string">&#x27;glyf&#x27;</span>]<br><br>    info = []<br>    <span class="hljs-keyword">for</span> code, name <span class="hljs-keyword">in</span> cmap.items():<br>        <span class="hljs-keyword">if</span> code &lt;= <span class="hljs-number">0xFF</span>:            <span class="hljs-comment"># 只看自定义区</span><br>            <span class="hljs-keyword">continue</span><br>        g = glyf[name]<br>        <span class="hljs-keyword">if</span> g.numberOfContours &lt;= <span class="hljs-number">0</span>:<br>            <span class="hljs-keyword">continue</span><br>        coords = <span class="hljs-built_in">list</span>(g.coordinates)<br>        xs = [c[<span class="hljs-number">0</span>] <span class="hljs-keyword">for</span> c <span class="hljs-keyword">in</span> coords]<br>        ys = [c[<span class="hljs-number">1</span>] <span class="hljs-keyword">for</span> c <span class="hljs-keyword">in</span> coords]<br>        info.append(&#123;<br>            <span class="hljs-string">&#x27;unicode&#x27;</span>: code,<br>            <span class="hljs-string">&#x27;name&#x27;</span>: name,<br>            <span class="hljs-string">&#x27;contours&#x27;</span>: g.numberOfContours,<br>            <span class="hljs-string">&#x27;points&#x27;</span>: <span class="hljs-built_in">len</span>(coords),<br>            <span class="hljs-string">&#x27;width&#x27;</span>:  <span class="hljs-built_in">max</span>(xs) - <span class="hljs-built_in">min</span>(xs),<br>            <span class="hljs-string">&#x27;height&#x27;</span>: <span class="hljs-built_in">max</span>(ys) - <span class="hljs-built_in">min</span>(ys),<br>            <span class="hljs-string">&#x27;endPts&#x27;</span>: <span class="hljs-built_in">list</span>(g.endPtsOfContours),<br>        &#125;)<br>    <span class="hljs-keyword">return</span> info, glyf, cmap<br></code></pre></td></tr></table></figure><h3 id="按轮廓数分组"><a href="#按轮廓数分组" class="headerlink" title="按轮廓数分组"></a>按轮廓数分组</h3><p>看过几个样本后，规律其实挺稳：</p><table><thead><tr><th>数字</th><th>轮廓数</th><th>备注</th></tr></thead><tbody><tr><td>1</td><td>1</td><td>宽度最小</td></tr><tr><td>7</td><td>1</td><td>除 1 外点数最少</td></tr><tr><td>2 &#x2F; 3 &#x2F; 5</td><td>1</td><td>上下半部分 x 坐标偏移区分</td></tr><tr><td>4</td><td>2</td><td>内轮廓就是一个三角形，点数最少</td></tr><tr><td>0</td><td>2</td><td>内轮廓居中</td></tr><tr><td>6</td><td>2</td><td>内轮廓偏下</td></tr><tr><td>9</td><td>2</td><td>内轮廓偏上</td></tr><tr><td>8</td><td>3（或 2 里点数最多）</td><td>有两个内空洞</td></tr></tbody></table><p>识别逻辑大致长这样：</p><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><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br></pre></td><td class="code"><pre><code class="hljs python"><span class="hljs-keyword">def</span> <span class="hljs-title function_">identify</span>(<span class="hljs-params">glyphs, glyf, cmap</span>):<br>    mapping = &#123;&#125;<br>    one   = [g <span class="hljs-keyword">for</span> g <span class="hljs-keyword">in</span> glyphs <span class="hljs-keyword">if</span> g[<span class="hljs-string">&#x27;contours&#x27;</span>] == <span class="hljs-number">1</span>]<br>    two   = [g <span class="hljs-keyword">for</span> g <span class="hljs-keyword">in</span> glyphs <span class="hljs-keyword">if</span> g[<span class="hljs-string">&#x27;contours&#x27;</span>] == <span class="hljs-number">2</span>]<br>    three = [g <span class="hljs-keyword">for</span> g <span class="hljs-keyword">in</span> glyphs <span class="hljs-keyword">if</span> g[<span class="hljs-string">&#x27;contours&#x27;</span>] &gt;= <span class="hljs-number">3</span>]<br><br>    <span class="hljs-comment"># 8: 三轮廓</span><br>    <span class="hljs-keyword">for</span> g <span class="hljs-keyword">in</span> three:<br>        mapping[g[<span class="hljs-string">&#x27;unicode&#x27;</span>]] = <span class="hljs-number">8</span><br><br>    <span class="hljs-comment"># 1: 一轮廓里最窄</span><br>    one.sort(key=<span class="hljs-keyword">lambda</span> x: x[<span class="hljs-string">&#x27;width&#x27;</span>])<br>    mapping[one[<span class="hljs-number">0</span>][<span class="hljs-string">&#x27;unicode&#x27;</span>]] = <span class="hljs-number">1</span><br>    <span class="hljs-comment"># 7: 剩下里点数最少</span><br>    rest = one[<span class="hljs-number">1</span>:]<br>    rest.sort(key=<span class="hljs-keyword">lambda</span> x: x[<span class="hljs-string">&#x27;points&#x27;</span>])<br>    mapping[rest[<span class="hljs-number">0</span>][<span class="hljs-string">&#x27;unicode&#x27;</span>]] = <span class="hljs-number">7</span><br>    <span class="hljs-comment"># 2/3/5: 上下 x 重心偏移</span><br>    others = rest[<span class="hljs-number">1</span>:]<br>    <span class="hljs-keyword">for</span> g <span class="hljs-keyword">in</span> others:<br>        cs = <span class="hljs-built_in">list</span>(glyf[cmap[g[<span class="hljs-string">&#x27;unicode&#x27;</span>]]].coordinates)<br>        mid = (<span class="hljs-built_in">min</span>(c[<span class="hljs-number">1</span>] <span class="hljs-keyword">for</span> c <span class="hljs-keyword">in</span> cs) + <span class="hljs-built_in">max</span>(c[<span class="hljs-number">1</span>] <span class="hljs-keyword">for</span> c <span class="hljs-keyword">in</span> cs)) / <span class="hljs-number">2</span><br>        up = [c[<span class="hljs-number">0</span>] <span class="hljs-keyword">for</span> c <span class="hljs-keyword">in</span> cs <span class="hljs-keyword">if</span> c[<span class="hljs-number">1</span>] &gt;  mid]<br>        lo = [c[<span class="hljs-number">0</span>] <span class="hljs-keyword">for</span> c <span class="hljs-keyword">in</span> cs <span class="hljs-keyword">if</span> c[<span class="hljs-number">1</span>] &lt;= mid]<br>        g[<span class="hljs-string">&#x27;shift&#x27;</span>] = (<span class="hljs-built_in">sum</span>(up)/<span class="hljs-built_in">len</span>(up) <span class="hljs-keyword">if</span> up <span class="hljs-keyword">else</span> <span class="hljs-number">0</span>) - (<span class="hljs-built_in">sum</span>(lo)/<span class="hljs-built_in">len</span>(lo) <span class="hljs-keyword">if</span> lo <span class="hljs-keyword">else</span> <span class="hljs-number">0</span>)<br>    others.sort(key=<span class="hljs-keyword">lambda</span> x: x[<span class="hljs-string">&#x27;shift&#x27;</span>], reverse=<span class="hljs-literal">True</span>)<br>    mapping[others[<span class="hljs-number">0</span>][<span class="hljs-string">&#x27;unicode&#x27;</span>]] = <span class="hljs-number">2</span>  <span class="hljs-comment"># 上重心偏右</span><br>    mapping[others[<span class="hljs-number">1</span>][<span class="hljs-string">&#x27;unicode&#x27;</span>]] = <span class="hljs-number">3</span>  <span class="hljs-comment"># 居中</span><br>    mapping[others[<span class="hljs-number">2</span>][<span class="hljs-string">&#x27;unicode&#x27;</span>]] = <span class="hljs-number">5</span>  <span class="hljs-comment"># 上重心偏左</span><br><br>    <span class="hljs-comment"># 2轮廓: 4, 0, 6, 9</span><br>    two.sort(key=<span class="hljs-keyword">lambda</span> x: x[<span class="hljs-string">&#x27;points&#x27;</span>])<br>    mapping[two[<span class="hljs-number">0</span>][<span class="hljs-string">&#x27;unicode&#x27;</span>]] = <span class="hljs-number">4</span>     <span class="hljs-comment"># 点数最少 = 三角形内轮廓</span><br>    rest2 = two[<span class="hljs-number">1</span>:]<br>    <span class="hljs-keyword">for</span> g <span class="hljs-keyword">in</span> rest2:<br>        cs = <span class="hljs-built_in">list</span>(glyf[cmap[g[<span class="hljs-string">&#x27;unicode&#x27;</span>]]].coordinates)<br>        end = <span class="hljs-built_in">list</span>(glyf[cmap[g[<span class="hljs-string">&#x27;unicode&#x27;</span>]]].endPtsOfContours)<br>        c1 = cs[:end[<span class="hljs-number">0</span>]+<span class="hljs-number">1</span>]<br>        c2 = cs[end[<span class="hljs-number">0</span>]+<span class="hljs-number">1</span>:end[<span class="hljs-number">1</span>]+<span class="hljs-number">1</span>] <span class="hljs-keyword">if</span> <span class="hljs-built_in">len</span>(end) &gt; <span class="hljs-number">1</span> <span class="hljs-keyword">else</span> []<br>        inner = c1 <span class="hljs-keyword">if</span> <span class="hljs-built_in">len</span>(c1) &lt; <span class="hljs-built_in">len</span>(c2) <span class="hljs-keyword">else</span> c2<br>        <span class="hljs-keyword">if</span> inner:<br>            mid = (<span class="hljs-built_in">min</span>(c[<span class="hljs-number">1</span>] <span class="hljs-keyword">for</span> c <span class="hljs-keyword">in</span> cs) + <span class="hljs-built_in">max</span>(c[<span class="hljs-number">1</span>] <span class="hljs-keyword">for</span> c <span class="hljs-keyword">in</span> cs)) / <span class="hljs-number">2</span><br>            g[<span class="hljs-string">&#x27;inner_rel&#x27;</span>] = <span class="hljs-built_in">sum</span>(c[<span class="hljs-number">1</span>] <span class="hljs-keyword">for</span> c <span class="hljs-keyword">in</span> inner)/<span class="hljs-built_in">len</span>(inner) - mid<br>    rest2.sort(key=<span class="hljs-keyword">lambda</span> x: <span class="hljs-built_in">abs</span>(x.get(<span class="hljs-string">&#x27;inner_rel&#x27;</span>, <span class="hljs-number">0</span>)))<br>    mapping[rest2[<span class="hljs-number">0</span>][<span class="hljs-string">&#x27;unicode&#x27;</span>]] = <span class="hljs-number">0</span>   <span class="hljs-comment"># 最居中</span><br>    tail = <span class="hljs-built_in">sorted</span>(rest2[<span class="hljs-number">1</span>:], key=<span class="hljs-keyword">lambda</span> x: x.get(<span class="hljs-string">&#x27;inner_rel&#x27;</span>, <span class="hljs-number">0</span>))<br>    mapping[tail[<span class="hljs-number">0</span>][<span class="hljs-string">&#x27;unicode&#x27;</span>]] = <span class="hljs-number">6</span>    <span class="hljs-comment"># 偏下</span><br>    mapping[tail[<span class="hljs-number">1</span>][<span class="hljs-string">&#x27;unicode&#x27;</span>]] = <span class="hljs-number">9</span>    <span class="hljs-comment"># 偏上</span><br>    <span class="hljs-keyword">return</span> mapping<br></code></pre></td></tr></table></figure><p>这段逻辑我跑过一批样本，准确率在 99% 左右。偶尔会在字体 hinting 比较奇怪的情况下把 3 和 5 认反，容忍不了的话可以再加一层”上半部分开口方向”的特征做 double check。</p><h3 id="拿到映射之后"><a href="#拿到映射之后" class="headerlink" title="拿到映射之后"></a>拿到映射之后</h3><p>替换 HTML 实体就简单了：</p><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><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs python"><span class="hljs-keyword">import</span> re<br><br><span class="hljs-keyword">def</span> <span class="hljs-title function_">decode</span>(<span class="hljs-params">encoded, mapping</span>):<br>    <span class="hljs-keyword">def</span> <span class="hljs-title function_">repl</span>(<span class="hljs-params">m</span>):<br>        code = <span class="hljs-built_in">int</span>(m.group(<span class="hljs-number">1</span>), <span class="hljs-number">16</span>)<br>        <span class="hljs-keyword">return</span> <span class="hljs-built_in">str</span>(mapping.get(code, <span class="hljs-string">&#x27;?&#x27;</span>))<br>    <span class="hljs-keyword">return</span> re.sub(<span class="hljs-string">r&#x27;&amp;#x([a-f0-9]+);&#x27;</span>, repl, encoded)<br></code></pre></td></tr></table></figure><p>响应里 <code>movieList.list[i].boxSplitUnit.num</code> 就是加密数字，配合 <code>.unit</code>（万 &#x2F; 亿）拼回去。</p><h2 id="六、踩过的坑"><a href="#六、踩过的坑" class="headerlink" title="六、踩过的坑"></a>六、踩过的坑</h2><p>除了算法本身，零零碎碎的细节踩过好几次：</p><p><strong>响应里其实有两套数字字段</strong>。<code>boxSplitUnit</code> 是综合票房，<code>splitBoxSplitUnit</code> 是分账票房，别只解了一个。</p><p><strong>字体文件偶尔返回 0 字节</strong>。美团的 CDN 在切源时会抽风，重试一次就好，别立刻判定算法错了。</p><p><strong>UA 一定要和 sign 时用的同一个</strong>。发请求用的 <code>User-Agent</code>、签名里做 Base64 的 <code>User-Agent</code>、浏览器里看到的 <code>User-Agent</code>，三者要完全一致，差一个字符都算错签。</p><p><strong><code>updateGapSecond</code> 告诉你下次该几秒后请求</strong>。猫眼大盘默认 5 秒一刷，如果你硬刷密度过高会直接进风控名单，需要尊重这个字段。</p><p><strong><code>uid</code> 可以留空</strong>，但留空时有小概率拿到空 list。最稳的做法是走一次首页 <code>dashboard/movie</code>，从 HTML 里 <code>&lt;meta name=&quot;csrf&quot;&gt;</code> 读出来塞进去。</p><h2 id="七、完整链路"><a href="#七、完整链路" class="headerlink" title="七、完整链路"></a>七、完整链路</h2><p>从零到拿到一行明文数据，实际走的链路：</p><figure class="highlight markdown"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><code class="hljs markdown"><span class="hljs-bullet">1.</span> GET /i/dashboard/movie<br><span class="hljs-code">     拉 HTML，正则扒出 uuid、csrf、channelId</span><br><span class="hljs-code">     记一下 page_load_ts = 当前毫秒</span><br><span class="hljs-code"></span><br><span class="hljs-bullet">2.</span> 本地算 signKey(ts, base64(UA), index, channelId)<br><br><span class="hljs-bullet">3.</span> 拼 URL，加 WuKongReady=h5<br><br><span class="hljs-bullet">4.</span> 本地算 mygsig(url, ts, page<span class="hljs-emphasis">_load_</span>ts)<br><br><span class="hljs-bullet">5.</span> GET /i/api/dashboard-ajax/movie<br><span class="hljs-code">     Headers: m-appkey, mygsig, uid, M-TRACEID</span><br><span class="hljs-code">     拿到 JSON + fontStyle</span><br><span class="hljs-code"></span><br><span class="hljs-bullet">6.</span> 从 fontStyle 里 regex 出 .woff URL<br><span class="hljs-code">     下载 -&gt; fontTools 解析 -&gt; 按轮廓特征认数字</span><br><span class="hljs-code"></span><br><span class="hljs-bullet">7.</span> 对 movieList 里每条 boxSplitUnit.num 做实体替换<br></code></pre></td></tr></table></figure><p>这一套跑通之后，每 5 秒拉一次即可，纯 Python，不需要 Chrome，不需要 Playwright。</p><h2 id="为什么不上浏览器"><a href="#为什么不上浏览器" class="headerlink" title="为什么不上浏览器"></a>为什么不上浏览器</h2><ul><li><strong>目标本来就是去浏览器化</strong>。上 Playwright 的话，直接 <code>page.content()</code> 抓渲染后的 DOM 就完事了，这篇文章就不存在</li><li><strong>想把字体反爬这个玩法搞清楚</strong>。动态字体 + cmap 每次变，是国内反爬的”阳关道 + 独木桥”组合：简单粗暴但拿捏新手</li><li><strong>想看看字形识别到底要做多细</strong>。现在答案是：10 个数字用 4~5 个轮廓特征就能 99% 正确</li></ul><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>从”看源码”到”签名算对”，整个链路大概花了一个半小时；字体识别逻辑又花了两个半小时——几乎所有时间都在看 glyph 轮廓。这也是国内反爬的常态：加密本身不难，烦的是那些<strong>不太成文的规则</strong>，比如空参该怎么拼、path 要不要进签、UA 用哪一版。一个一个试，试到字节完全一致为止。</p><hr><blockquote><p>文章仅记录方法论和接口形态，不提供任何可直接运行的采集脚本。<br>具体字段值、字体识别的完整训练数据、批量采集框架一律不公开。<br>技术点适用于合法场景：E2E 测试、数据接口文档化、前端混淆代码的逆向分析。</p></blockquote>]]>
    </content>
    <id>https://7788dev.github.io/2026/05/10/maoyan-boxoffice-reverse/</id>
    <link href="https://7788dev.github.io/2026/05/10/maoyan-boxoffice-reverse/"/>
    <published>2026-05-10T08:50:00.000Z</published>
    <summary>把猫眼票房专业版的实时接口从浏览器环境里剥出来——signKey、mygsig、WuKong、woff 字体加密，用纯 Python 走完一次完整请求。</summary>
    <title>猫眼票房专业版纯算逆向</title>
    <updated>2026-05-10T09:13:28.393Z</updated>
  </entry>
  <entry>
    <author>
      <name>Looks</name>
    </author>
    <category term="教程" scheme="https://7788dev.github.io/categories/%E6%95%99%E7%A8%8B/"/>
    <category term="Hexo" scheme="https://7788dev.github.io/tags/Hexo/"/>
    <category term="Fluid" scheme="https://7788dev.github.io/tags/Fluid/"/>
    <category term="CSS" scheme="https://7788dev.github.io/tags/CSS/"/>
    <category term="前端" scheme="https://7788dev.github.io/tags/%E5%89%8D%E7%AB%AF/"/>
    <category term="博客魔改" scheme="https://7788dev.github.io/tags/%E5%8D%9A%E5%AE%A2%E9%AD%94%E6%94%B9/"/>
    <content>
      <![CDATA[<blockquote><p>如果你刚看完上一篇<a href="/2026/05/10/build-blog-with-hexo/">《用 Hexo 搭建属于自己的 Blog》</a>，那你现在有一个能跑起来的博客。</p><p>这篇继续往下，讲我这个站后来加的那些东西——<strong>美化、Bing 壁纸、音乐墙、春节灯笼、健身打卡</strong>。每一块都尽量做到”复制三个文件就能用”。</p><p>难度比上一篇高，但也没多高。你不需要懂很深的 JS，按顺序抄 + 改路径就行。</p></blockquote><h2 id="改造的总原则"><a href="#改造的总原则" class="headerlink" title="改造的总原则"></a>改造的总原则</h2><p>先把原则摆在前面，不然越改越乱。</p><p><strong>原则一：不动主题源码</strong><br>主题放在 <code>node_modules/hexo-theme-fluid/</code> 里，改了就升不动主题了。所有自定义都走 <code>_config.fluid.yml</code> 里的 <code>custom_css</code> &#x2F; <code>custom_js</code> &#x2F; <code>custom_head</code>，加文件不改文件。</p><p><strong>原则二：样式全走 CSS 变量</strong><br>一个站点颜色、字体、阴影、圆角这些东西出现的地方太多。把它们集中到一个 <code>:root</code> 里当作<strong>设计令牌</strong>（design tokens），其它地方只引用变量。想换风格的时候只改一处。</p><p><strong>原则三：自定义页面 &#x3D; markdown + 自定义 JS + 自定义 CSS</strong><br>每个”花活页面”都是这个套路：一个 <code>.md</code> 负责写 HTML 结构，一个 <code>.js</code> 负责交互，一份 <code>.css</code> 负责样式。后面音乐墙、健身打卡都是这么做的。</p><h2 id="一、挂载自定义样式和脚本"><a href="#一、挂载自定义样式和脚本" class="headerlink" title="一、挂载自定义样式和脚本"></a>一、挂载自定义样式和脚本</h2><p>先把”挂”的动作做对。打开 <code>_config.fluid.yml</code>，加这一段：</p><figure class="highlight yaml"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-comment"># 挂载自定义 JS</span><br><span class="hljs-attr">custom_js:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">/js/bing-banner.js</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">/js/iciba-slogan.js</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">/js/site-uptime.js</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">/js/spring-festival-lanterns.js</span><br><br><span class="hljs-comment"># 挂载自定义 CSS</span><br><span class="hljs-attr">custom_css:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">/css/custom.css</span><br><br><span class="hljs-comment"># 往 &lt;head&gt; 里追加的自定义 HTML（OG / JSON-LD / preconnect 等）</span><br><span class="hljs-attr">custom_head:</span> <span class="hljs-string">&#x27;</span><br><span class="hljs-string">  &lt;link rel=&quot;icon&quot; type=&quot;image/svg+xml&quot; href=&quot;/favicon.svg&quot;&gt;</span><br><span class="hljs-string">  &lt;link rel=&quot;preconnect&quot; href=&quot;https://bing.biturl.top&quot; crossorigin&gt;</span><br><span class="hljs-string">  &lt;link rel=&quot;preconnect&quot; href=&quot;https://fonts.googleapis.com&quot;&gt;</span><br><span class="hljs-string">  &lt;link rel=&quot;preconnect&quot; href=&quot;https://fonts.gstatic.com&quot; crossorigin&gt;</span><br><span class="hljs-string">  &lt;link rel=&quot;stylesheet&quot; href=&quot;https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap&quot;&gt;</span><br><span class="hljs-string">&#x27;</span><br></code></pre></td></tr></table></figure><p>Fluid 会把 <code>/js/xxx.js</code> 和 <code>/css/xxx.css</code> 映射到 <code>source/js/</code> 和 <code>source/css/</code> 这两个目录。也就是说，<strong>把文件放到 <code>source/js/</code> 或 <code>source/css/</code>，它就会被拷到产物里</strong>。非常适合用来塞自定义代码。</p><p>没有这两个目录的同学先建好：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">mkdir</span> -p <span class="hljs-built_in">source</span>/js <span class="hljs-built_in">source</span>/css<br></code></pre></td></tr></table></figure><h2 id="二、设计令牌-·-一切的底座"><a href="#二、设计令牌-·-一切的底座" class="headerlink" title="二、设计令牌 · 一切的底座"></a>二、设计令牌 · 一切的底座</h2><p>新建 <code>source/css/custom.css</code>，最顶上写这一段：</p><figure class="highlight css"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br></pre></td><td class="code"><pre><code class="hljs css"><span class="hljs-comment">/* ---- 设计令牌 ---- */</span><br><span class="hljs-selector-pseudo">:root</span> &#123;<br>  <span class="hljs-comment">/* 字体 */</span><br>  <span class="hljs-attr">--font-sans</span>: <span class="hljs-string">&quot;Inter&quot;</span>, -apple-system, BlinkMacSystemFont, <span class="hljs-string">&quot;SF Pro Text&quot;</span>,<br>    <span class="hljs-string">&quot;PingFang SC&quot;</span>, <span class="hljs-string">&quot;Microsoft YaHei UI&quot;</span>, sans-serif;<br>  <span class="hljs-attr">--font-mono</span>: <span class="hljs-string">&quot;JetBrains Mono&quot;</span>, <span class="hljs-string">&quot;SF Mono&quot;</span>, Menlo, Consolas, monospace;<br><br>  <span class="hljs-comment">/* 圆角 */</span><br>  <span class="hljs-attr">--radius-xs</span>: <span class="hljs-number">6px</span>;<br>  <span class="hljs-attr">--radius-sm</span>: <span class="hljs-number">10px</span>;<br>  <span class="hljs-attr">--radius-md</span>: <span class="hljs-number">14px</span>;<br>  <span class="hljs-attr">--radius-lg</span>: <span class="hljs-number">20px</span>;<br>  <span class="hljs-attr">--radius-xl</span>: <span class="hljs-number">28px</span>;<br><br>  <span class="hljs-comment">/* 多层阴影（模仿 Material / Apple 的真·阴影） */</span><br>  <span class="hljs-attr">--shadow-1</span>: <span class="hljs-number">0</span> <span class="hljs-number">1px</span> <span class="hljs-number">2px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">15</span>, <span class="hljs-number">23</span>, <span class="hljs-number">42</span>, <span class="hljs-number">0.04</span>), <span class="hljs-number">0</span> <span class="hljs-number">1px</span> <span class="hljs-number">3px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">15</span>, <span class="hljs-number">23</span>, <span class="hljs-number">42</span>, <span class="hljs-number">0.06</span>);<br>  <span class="hljs-attr">--shadow-2</span>: <span class="hljs-number">0</span> <span class="hljs-number">4px</span> <span class="hljs-number">10px</span> -<span class="hljs-number">4px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">15</span>, <span class="hljs-number">23</span>, <span class="hljs-number">42</span>, <span class="hljs-number">0.08</span>),<br>              <span class="hljs-number">0</span> <span class="hljs-number">8px</span> <span class="hljs-number">24px</span> -<span class="hljs-number">8px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">15</span>, <span class="hljs-number">23</span>, <span class="hljs-number">42</span>, <span class="hljs-number">0.10</span>);<br>  <span class="hljs-attr">--shadow-3</span>: <span class="hljs-number">0</span> <span class="hljs-number">10px</span> <span class="hljs-number">20px</span> -<span class="hljs-number">10px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">15</span>, <span class="hljs-number">23</span>, <span class="hljs-number">42</span>, <span class="hljs-number">0.12</span>),<br>              <span class="hljs-number">0</span> <span class="hljs-number">20px</span> <span class="hljs-number">40px</span> -<span class="hljs-number">20px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">15</span>, <span class="hljs-number">23</span>, <span class="hljs-number">42</span>, <span class="hljs-number">0.16</span>);<br><br>  <span class="hljs-comment">/* 缓动曲线 */</span><br>  <span class="hljs-attr">--ease-out</span>: <span class="hljs-built_in">cubic-bezier</span>(<span class="hljs-number">0.16</span>, <span class="hljs-number">1</span>, <span class="hljs-number">0.3</span>, <span class="hljs-number">1</span>);<br>  <span class="hljs-attr">--ease-in-out</span>: <span class="hljs-built_in">cubic-bezier</span>(<span class="hljs-number">0.4</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0.2</span>, <span class="hljs-number">1</span>);<br><br>  <span class="hljs-comment">/* 语义色（Nord 柔和化） */</span><br>  <span class="hljs-attr">--accent</span>: <span class="hljs-number">#5e81ac</span>;<br>  <span class="hljs-attr">--accent-strong</span>: <span class="hljs-number">#4c7099</span>;<br>  <span class="hljs-attr">--accent-soft</span>: <span class="hljs-built_in">rgba</span>(<span class="hljs-number">94</span>, <span class="hljs-number">129</span>, <span class="hljs-number">172</span>, <span class="hljs-number">0.12</span>);<br>  <span class="hljs-attr">--hairline</span>: <span class="hljs-built_in">rgba</span>(<span class="hljs-number">15</span>, <span class="hljs-number">23</span>, <span class="hljs-number">42</span>, <span class="hljs-number">0.08</span>);<br>  <span class="hljs-attr">--muted-bg</span>: <span class="hljs-built_in">rgba</span>(<span class="hljs-number">15</span>, <span class="hljs-number">23</span>, <span class="hljs-number">42</span>, <span class="hljs-number">0.04</span>);<br>&#125;<br><br><span class="hljs-comment">/* 暗色模式覆盖 */</span><br><span class="hljs-selector-tag">html</span><span class="hljs-selector-attr">[data-user-color-scheme=<span class="hljs-string">&quot;dark&quot;</span>]</span> &#123;<br>  <span class="hljs-attr">--shadow-1</span>: <span class="hljs-number">0</span> <span class="hljs-number">1px</span> <span class="hljs-number">2px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0.4</span>), <span class="hljs-number">0</span> <span class="hljs-number">1px</span> <span class="hljs-number">3px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0.3</span>);<br>  <span class="hljs-attr">--shadow-2</span>: <span class="hljs-number">0</span> <span class="hljs-number">4px</span> <span class="hljs-number">10px</span> -<span class="hljs-number">4px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0.45</span>),<br>              <span class="hljs-number">0</span> <span class="hljs-number">8px</span> <span class="hljs-number">24px</span> -<span class="hljs-number">8px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0.40</span>);<br>  <span class="hljs-attr">--shadow-3</span>: <span class="hljs-number">0</span> <span class="hljs-number">10px</span> <span class="hljs-number">20px</span> -<span class="hljs-number">10px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0.5</span>),<br>              <span class="hljs-number">0</span> <span class="hljs-number">20px</span> <span class="hljs-number">40px</span> -<span class="hljs-number">20px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0.55</span>);<br><br>  <span class="hljs-attr">--accent</span>: <span class="hljs-number">#88c0d0</span>;<br>  <span class="hljs-attr">--accent-strong</span>: <span class="hljs-number">#a6d4e0</span>;<br>  <span class="hljs-attr">--accent-soft</span>: <span class="hljs-built_in">rgba</span>(<span class="hljs-number">136</span>, <span class="hljs-number">192</span>, <span class="hljs-number">208</span>, <span class="hljs-number">0.15</span>);<br>  <span class="hljs-attr">--hairline</span>: <span class="hljs-built_in">rgba</span>(<span class="hljs-number">255</span>, <span class="hljs-number">255</span>, <span class="hljs-number">255</span>, <span class="hljs-number">0.08</span>);<br>  <span class="hljs-attr">--muted-bg</span>: <span class="hljs-built_in">rgba</span>(<span class="hljs-number">255</span>, <span class="hljs-number">255</span>, <span class="hljs-number">255</span>, <span class="hljs-number">0.04</span>);<br>&#125;<br><br><span class="hljs-comment">/* 全站字体 */</span><br><span class="hljs-selector-tag">html</span>, <span class="hljs-selector-tag">body</span> &#123;<br>  <span class="hljs-attribute">font-family</span>: <span class="hljs-built_in">var</span>(--font-sans);<br>  -webkit-<span class="hljs-attribute">font-smoothing</span>: antialiased;<br>  -moz-osx-<span class="hljs-attribute">font-smoothing</span>: grayscale;<br>  <span class="hljs-attribute">text-rendering</span>: optimizeLegibility;<br>&#125;<br><br><span class="hljs-selector-tag">body</span> &#123; <span class="hljs-attribute">font-size</span>: <span class="hljs-number">16px</span>; <span class="hljs-attribute">line-height</span>: <span class="hljs-number">1.7</span>; &#125;<br><br><span class="hljs-comment">/* 选中态 */</span><br><span class="hljs-selector-pseudo">::selection</span> &#123;<br>  <span class="hljs-attribute">background</span>: <span class="hljs-built_in">var</span>(--accent-soft);<br>  <span class="hljs-attribute">color</span>: <span class="hljs-built_in">var</span>(--accent-strong);<br>&#125;<br></code></pre></td></tr></table></figure><p>这几行看起来没啥效果，但整个站之后所有的卡片、按钮、阴影、边框都会从这里取值。<strong>以后想换风格，改这一段就行</strong>，不用全局搜索替换。</p><h2 id="三、Nord-配色-·-覆盖主题颜色"><a href="#三、Nord-配色-·-覆盖主题颜色" class="headerlink" title="三、Nord 配色 · 覆盖主题颜色"></a>三、Nord 配色 · 覆盖主题颜色</h2><p>Fluid 自己提供了一组变量让你在 <code>_config.fluid.yml</code> 里改：</p><figure class="highlight yaml"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">color:</span><br>  <span class="hljs-comment"># 背景与文本</span><br>  <span class="hljs-attr">body_bg_color:</span> <span class="hljs-string">&quot;#eceff4&quot;</span><br>  <span class="hljs-attr">body_bg_color_dark:</span> <span class="hljs-string">&quot;#1c2230&quot;</span><br>  <span class="hljs-attr">text_color:</span> <span class="hljs-string">&quot;#2e3440&quot;</span><br>  <span class="hljs-attr">text_color_dark:</span> <span class="hljs-string">&quot;#d8dee9&quot;</span><br>  <span class="hljs-attr">sec_text_color:</span> <span class="hljs-string">&quot;#4c566a&quot;</span><br>  <span class="hljs-attr">sec_text_color_dark:</span> <span class="hljs-string">&quot;#9aa4b7&quot;</span><br><br>  <span class="hljs-comment"># 顶部导航</span><br>  <span class="hljs-attr">navbar_bg_color:</span> <span class="hljs-string">&quot;#2e3440&quot;</span><br>  <span class="hljs-attr">navbar_bg_color_dark:</span> <span class="hljs-string">&quot;#242933&quot;</span><br><br>  <span class="hljs-comment"># 悬浮面板、卡片</span><br>  <span class="hljs-attr">board_color:</span> <span class="hljs-string">&quot;#ffffff&quot;</span><br>  <span class="hljs-attr">board_color_dark:</span> <span class="hljs-string">&quot;#2b3242&quot;</span><br><br>  <span class="hljs-comment"># 文章链接与悬浮</span><br>  <span class="hljs-attr">post_link_color:</span> <span class="hljs-string">&quot;#5e81ac&quot;</span><br>  <span class="hljs-attr">post_link_color_dark:</span> <span class="hljs-string">&quot;#88c0d0&quot;</span><br>  <span class="hljs-attr">link_hover_color:</span> <span class="hljs-string">&quot;#88c0d0&quot;</span><br>  <span class="hljs-attr">link_hover_color_dark:</span> <span class="hljs-string">&quot;#8fbcbb&quot;</span><br><br>  <span class="hljs-comment"># 线条、按钮、滚动条</span><br>  <span class="hljs-attr">line_color:</span> <span class="hljs-string">&quot;#e5e9f0&quot;</span><br>  <span class="hljs-attr">line_color_dark:</span> <span class="hljs-string">&quot;#3b4252&quot;</span><br>  <span class="hljs-attr">scrollbar_color:</span> <span class="hljs-string">&quot;#c9ccd1&quot;</span><br>  <span class="hljs-attr">scrollbar_hover_color:</span> <span class="hljs-string">&quot;#8fbcbb&quot;</span><br></code></pre></td></tr></table></figure><p>刷一下：从暖色默认变成冷静的 Nord 雪原风，Banner 上任何壁纸都压得住。</p><h2 id="四、Bing-每日壁纸-Banner"><a href="#四、Bing-每日壁纸-Banner" class="headerlink" title="四、Bing 每日壁纸 Banner"></a>四、Bing 每日壁纸 Banner</h2><p>默认首页 banner 是一张静态图，太无聊。做成每天自动换 Bing 首页壁纸：</p><p><strong>新建 <code>source/js/bing-banner.js</code></strong>：</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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br></pre></td><td class="code"><pre><code class="hljs js">(<span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123;<br>  <span class="hljs-string">&#x27;use strict&#x27;</span>;<br><br>  <span class="hljs-keyword">var</span> <span class="hljs-variable constant_">PRIMARY_API</span>  = <span class="hljs-string">&#x27;https://bing.biturl.top/?resolution=1920&amp;format=json&amp;index=0&amp;mkt=zh-CN&#x27;</span>;<br>  <span class="hljs-keyword">var</span> <span class="hljs-variable constant_">FALLBACK_IMG</span> = <span class="hljs-string">&#x27;https://bing.img.run/1920x1080.php&#x27;</span>;<br>  <span class="hljs-keyword">var</span> <span class="hljs-variable constant_">STORAGE_KEY</span>  = <span class="hljs-string">&#x27;bing-banner-cache-v2&#x27;</span>;<br>  <span class="hljs-keyword">var</span> <span class="hljs-variable constant_">ROLLOVER_HOUR</span> = <span class="hljs-number">7</span>; <span class="hljs-comment">// 每天早 7 点换图</span><br><br>  <span class="hljs-keyword">function</span> <span class="hljs-title function_">currentDayKey</span>(<span class="hljs-params"></span>) &#123;<br>    <span class="hljs-keyword">var</span> now = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>();<br>    <span class="hljs-keyword">if</span> (now.<span class="hljs-title function_">getHours</span>() &lt; <span class="hljs-variable constant_">ROLLOVER_HOUR</span>) now.<span class="hljs-title function_">setDate</span>(now.<span class="hljs-title function_">getDate</span>() - <span class="hljs-number">1</span>);<br>    <span class="hljs-keyword">return</span> now.<span class="hljs-title function_">getFullYear</span>() + <span class="hljs-string">&#x27;-&#x27;</span> + (now.<span class="hljs-title function_">getMonth</span>() + <span class="hljs-number">1</span>) + <span class="hljs-string">&#x27;-&#x27;</span> + now.<span class="hljs-title function_">getDate</span>();<br>  &#125;<br><br>  <span class="hljs-keyword">function</span> <span class="hljs-title function_">setBanner</span>(<span class="hljs-params">url</span>) &#123;<br>    <span class="hljs-keyword">var</span> banner = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">getElementById</span>(<span class="hljs-string">&#x27;banner&#x27;</span>);<br>    <span class="hljs-keyword">if</span> (!banner || !url) <span class="hljs-keyword">return</span>;<br>    <span class="hljs-comment">// 预加载，拿到图才切，避免白屏</span><br>    <span class="hljs-keyword">var</span> img = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Image</span>();<br>    img.<span class="hljs-property">onload</span>  = <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123; banner.<span class="hljs-property">style</span>.<span class="hljs-property">backgroundImage</span> = <span class="hljs-string">&quot;url(&#x27;&quot;</span> + url + <span class="hljs-string">&quot;&#x27;)&quot;</span>; &#125;;<br>    img.<span class="hljs-property">onerror</span> = <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123; <span class="hljs-keyword">if</span> (url !== <span class="hljs-variable constant_">FALLBACK_IMG</span>) <span class="hljs-title function_">setBanner</span>(<span class="hljs-variable constant_">FALLBACK_IMG</span>); &#125;;<br>    img.<span class="hljs-property">src</span> = url;<br>  &#125;<br><br>  <span class="hljs-keyword">function</span> <span class="hljs-title function_">readCache</span>(<span class="hljs-params"></span>) &#123;<br>    <span class="hljs-keyword">try</span> &#123;<br>      <span class="hljs-keyword">var</span> obj = <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">parse</span>(<span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">getItem</span>(<span class="hljs-variable constant_">STORAGE_KEY</span>) || <span class="hljs-string">&#x27;null&#x27;</span>);<br>      <span class="hljs-keyword">return</span> (obj &amp;&amp; obj.<span class="hljs-property">day</span> === <span class="hljs-title function_">currentDayKey</span>()) ? obj.<span class="hljs-property">url</span> : <span class="hljs-literal">null</span>;<br>    &#125; <span class="hljs-keyword">catch</span> (_) &#123; <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>; &#125;<br>  &#125;<br>  <span class="hljs-keyword">function</span> <span class="hljs-title function_">writeCache</span>(<span class="hljs-params">url</span>) &#123;<br>    <span class="hljs-keyword">try</span> &#123; <span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">setItem</span>(<span class="hljs-variable constant_">STORAGE_KEY</span>, <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(&#123; <span class="hljs-attr">url</span>: url, <span class="hljs-attr">day</span>: <span class="hljs-title function_">currentDayKey</span>() &#125;)); &#125; <span class="hljs-keyword">catch</span> (_) &#123;&#125;<br>  &#125;<br><br>  <span class="hljs-keyword">function</span> <span class="hljs-title function_">fetchBing</span>(<span class="hljs-params"></span>) &#123;<br>    <span class="hljs-keyword">if</span> (!(<span class="hljs-string">&#x27;fetch&#x27;</span> <span class="hljs-keyword">in</span> <span class="hljs-variable language_">window</span>)) <span class="hljs-keyword">return</span> <span class="hljs-title function_">setBanner</span>(<span class="hljs-variable constant_">FALLBACK_IMG</span>);<br>    <span class="hljs-title function_">fetch</span>(<span class="hljs-variable constant_">PRIMARY_API</span>, &#123; <span class="hljs-attr">cache</span>: <span class="hljs-string">&#x27;no-store&#x27;</span> &#125;)<br>      .<span class="hljs-title function_">then</span>(<span class="hljs-keyword">function</span> (<span class="hljs-params">r</span>) &#123; <span class="hljs-keyword">return</span> r.<span class="hljs-property">ok</span> ? r.<span class="hljs-title function_">json</span>() : <span class="hljs-literal">null</span>; &#125;)<br>      .<span class="hljs-title function_">then</span>(<span class="hljs-keyword">function</span> (<span class="hljs-params">data</span>) &#123;<br>        <span class="hljs-keyword">if</span> (data &amp;&amp; data.<span class="hljs-property">url</span>) &#123; <span class="hljs-title function_">setBanner</span>(data.<span class="hljs-property">url</span>); <span class="hljs-title function_">writeCache</span>(data.<span class="hljs-property">url</span>); &#125;<br>        <span class="hljs-keyword">else</span> <span class="hljs-title function_">setBanner</span>(<span class="hljs-variable constant_">FALLBACK_IMG</span>);<br>      &#125;)<br>      .<span class="hljs-title function_">catch</span>(<span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123; <span class="hljs-title function_">setBanner</span>(<span class="hljs-variable constant_">FALLBACK_IMG</span>); &#125;);<br>  &#125;<br><br>  <span class="hljs-keyword">function</span> <span class="hljs-title function_">init</span>(<span class="hljs-params"></span>) &#123;<br>    <span class="hljs-keyword">var</span> cached = <span class="hljs-title function_">readCache</span>();<br>    <span class="hljs-keyword">if</span> (cached) <span class="hljs-title function_">setBanner</span>(cached);<br>    <span class="hljs-keyword">else</span> <span class="hljs-title function_">fetchBing</span>();<br>  &#125;<br><br>  <span class="hljs-keyword">if</span> (<span class="hljs-variable language_">document</span>.<span class="hljs-property">readyState</span> === <span class="hljs-string">&#x27;loading&#x27;</span>) <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">addEventListener</span>(<span class="hljs-string">&#x27;DOMContentLoaded&#x27;</span>, init);<br>  <span class="hljs-keyword">else</span> <span class="hljs-title function_">init</span>();<br>&#125;)();<br></code></pre></td></tr></table></figure><p>几个做对的小事：</p><ul><li><strong>localStorage 缓存</strong>：当天只请求一次接口，之后从缓存读</li><li><strong>预加载再切</strong>：如果直接 <code>background-image: url(...)</code>，图还在下载时背景是白的，闪一下很难看</li><li><strong>失败降级</strong>：Bing 接口挂了切回本地图片，不会开天窗</li><li><strong>早 7 点换图</strong>：半夜打开博客还是上一张壁纸，早上起来才换，避免凌晨写文章时壁纸突然变了</li></ul><h2 id="五、音乐墙"><a href="#五、音乐墙" class="headerlink" title="五、音乐墙"></a>五、音乐墙</h2><p>这个模块最有意思，做成”唱片墙 + 播放器”。不用任何 JS 库，原生 HTML5 <code>&lt;audio&gt;</code> 够用。</p><h3 id="5-1-准备音乐文件"><a href="#5-1-准备音乐文件" class="headerlink" title="5.1 准备音乐文件"></a>5.1 准备音乐文件</h3><p>在 <code>source/music/</code> 下塞音频和封面：</p><figure class="highlight jboss-cli"><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><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs jboss-cli">source/music/<br>├── index.md               <span class="hljs-comment"># 页面本身</span><br>├── audio/<br>│   ├── 你瞒我瞒<span class="hljs-string">.mp3</span><br>│   ├── 演员<span class="hljs-string">.mp3</span><br>│   └── <span class="hljs-string">...</span><br>└── covers/<br>    ├── 01-cover.jpg<br>    └── <span class="hljs-string">...</span><br></code></pre></td></tr></table></figure><p>Hexo 会把整个 <code>source/music/</code> 原样拷贝到产物。音频文件直接可以用 <code>/music/audio/xxx.mp3</code> 访问。</p><h3 id="5-2-页面结构"><a href="#5-2-页面结构" class="headerlink" title="5.2 页面结构"></a>5.2 页面结构</h3><p><code>source/music/index.md</code>：</p><figure class="highlight markdown"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br></pre></td><td class="code"><pre><code class="hljs markdown">---<br>title: 音乐<br>layout: page<br>date: 2026-05-09 00:00:00<br><span class="hljs-section">comments: false</span><br><span class="hljs-section">---</span><br><br>&#123;% raw %&#125;<br><span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;music-wall&quot;</span>&gt;</span></span><br>  <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">p</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;music-intro&quot;</span>&gt;</span></span>最近在循环的几首歌。点击封面试听，再点一次暂停。<span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span></span><br><br>  <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;music-grid&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;music-grid&quot;</span> <span class="hljs-attr">aria-label</span>=<span class="hljs-string">&quot;歌单&quot;</span>&gt;</span></span><span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span><br><br>  &lt;!-- 极简播放器 --&gt;<br>  <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;music-player&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;music-player&quot;</span> <span class="hljs-attr">hidden</span>&gt;</span></span><br><span class="hljs-code">    &lt;div class=&quot;mp-top&quot;&gt;</span><br><span class="hljs-code">      &lt;img class=&quot;mp-cover&quot; alt=&quot;&quot;&gt;</span><br><span class="hljs-code">      &lt;div class=&quot;mp-info&quot;&gt;</span><br><span class="hljs-code">        &lt;div class=&quot;mp-title&quot;&gt;&lt;/div&gt;</span><br><span class="hljs-code">        &lt;div class=&quot;mp-artist&quot;&gt;&lt;/div&gt;</span><br><span class="hljs-code">      &lt;/div&gt;</span><br><span class="hljs-code">      &lt;div class=&quot;mp-controls&quot;&gt;</span><br><span class="hljs-code">        &lt;button type=&quot;button&quot; class=&quot;mp-btn mp-prev&quot; aria-label=&quot;上一首&quot;&gt;</span><br><span class="hljs-code">          &lt;svg viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6 6h2v12H6zm3.5 6l8.5 6V6z&quot; fill=&quot;currentColor&quot;/&gt;&lt;/svg&gt;</span><br><span class="hljs-code">        &lt;/button&gt;</span><br><span class="hljs-code">        &lt;button type=&quot;button&quot; class=&quot;mp-btn mp-play&quot; aria-label=&quot;播放/暂停&quot;&gt;</span><br><span class="hljs-code">          &lt;svg class=&quot;mp-ico-play&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M8 5v14l11-7z&quot; fill=&quot;currentColor&quot;/&gt;&lt;/svg&gt;</span><br><span class="hljs-code">          &lt;svg class=&quot;mp-ico-pause&quot; viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M6 5h4v14H6zm8 0h4v14h-4z&quot; fill=&quot;currentColor&quot;/&gt;&lt;/svg&gt;</span><br><span class="hljs-code">        &lt;/button&gt;</span><br><span class="hljs-code">        &lt;button type=&quot;button&quot; class=&quot;mp-btn mp-next&quot; aria-label=&quot;下一首&quot;&gt;</span><br><span class="hljs-code">          &lt;svg viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M16 6h2v12h-2zM6 18l8.5-6L6 6z&quot; fill=&quot;currentColor&quot;/&gt;&lt;/svg&gt;</span><br><span class="hljs-code">        &lt;/button&gt;</span><br><span class="hljs-code">      &lt;/div&gt;</span><br><span class="hljs-code">    &lt;/div&gt;</span><br><span class="hljs-code">    &lt;div class=&quot;mp-bottom&quot;&gt;</span><br><span class="hljs-code">      &lt;span class=&quot;mp-time mp-time-current&quot;&gt;0:00&lt;/span&gt;</span><br><span class="hljs-code">      &lt;div class=&quot;mp-progress&quot; role=&quot;slider&quot; tabindex=&quot;0&quot;</span><br><span class="hljs-code">           aria-label=&quot;播放进度&quot; aria-valuemin=&quot;0&quot; aria-valuemax=&quot;100&quot; aria-valuenow=&quot;0&quot;&gt;</span><br><span class="hljs-code">        &lt;div class=&quot;mp-progress-track&quot;&gt;</span><br><span class="hljs-code">          &lt;div class=&quot;mp-progress-buffer&quot;&gt;&lt;/div&gt;</span><br><span class="hljs-code">          &lt;div class=&quot;mp-progress-fill&quot;&gt;&lt;/div&gt;</span><br><span class="hljs-code">          &lt;div class=&quot;mp-progress-thumb&quot;&gt;&lt;/div&gt;</span><br><span class="hljs-code">        &lt;/div&gt;</span><br><span class="hljs-code">      &lt;/div&gt;</span><br><span class="hljs-code">      &lt;span class=&quot;mp-time mp-time-total&quot;&gt;0:00&lt;/span&gt;</span><br><span class="hljs-code">    &lt;/div&gt;</span><br><span class="hljs-code">    &lt;audio id=&quot;mp-audio&quot; preload=&quot;none&quot;&gt;&lt;/audio&gt;</span><br><span class="hljs-code">  &lt;/div&gt;</span><br><span class="hljs-code">&lt;/div&gt;</span><br><span class="hljs-code"></span><br><span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span>&gt;</span></span><br>  window.MUSIC<span class="hljs-emphasis">_LIST = [</span><br><span class="hljs-emphasis">    &#123; title: &quot;你瞒我瞒&quot;, artist: &quot;陈柏宇&quot;, cover: &quot;/music/covers/01-cover.jpg&quot;, audio: &quot;/music/audio/你瞒我瞒.mp3&quot; &#125;,</span><br><span class="hljs-emphasis">    &#123; title: &quot;演员&quot;,   artist: &quot;薛之谦&quot;, cover: &quot;/music/covers/04-cover.jpg&quot;, audio: &quot;/music/audio/演员.mp3&quot; &#125;</span><br><span class="hljs-emphasis">  ];</span><br><span class="hljs-emphasis"><span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></span></span><br><span class="hljs-emphasis"><span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">defer</span> <span class="hljs-attr">src</span>=<span class="hljs-string">&quot;/js/music-wall.js&quot;</span>&gt;</span></span><span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></span></span><br><span class="hljs-emphasis">&#123;% endraw %&#125;</span><br></code></pre></td></tr></table></figure><p>两个要点：</p><ul><li><strong><code></code></strong> 是必须的。Hexo 的模板引擎会尝试解析 <code>&#123;&#123; &#125;&#125;</code>，包在 raw 里面防止它乱搞</li><li><strong>歌单数据 <code>window.MUSIC_LIST</code></strong> 直接写在页面里，这是最简单的做法。后续想做”改歌单不用改 HTML”可以把它拆到 <code>/music/list.json</code></li></ul><h3 id="5-3-交互逻辑"><a href="#5-3-交互逻辑" class="headerlink" title="5.3 交互逻辑"></a>5.3 交互逻辑</h3><p><code>source/js/music-wall.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><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br></pre></td><td class="code"><pre><code class="hljs js">(<span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123;<br>  <span class="hljs-string">&#x27;use strict&#x27;</span>;<br><br>  <span class="hljs-keyword">function</span> <span class="hljs-title function_">init</span>(<span class="hljs-params"></span>) &#123;<br>    <span class="hljs-keyword">var</span> list = <span class="hljs-variable language_">window</span>.<span class="hljs-property">MUSIC_LIST</span>;<br>    <span class="hljs-keyword">if</span> (!<span class="hljs-title class_">Array</span>.<span class="hljs-title function_">isArray</span>(list) || !list.<span class="hljs-property">length</span>) <span class="hljs-keyword">return</span>;<br><br>    <span class="hljs-comment">// 1. 根据 MUSIC_LIST 生成唱片卡片</span><br>    <span class="hljs-title function_">buildCards</span>(list);<br><br>    <span class="hljs-comment">// 2. 拿到所有 DOM</span><br>    <span class="hljs-keyword">var</span> audio   = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">getElementById</span>(<span class="hljs-string">&#x27;mp-audio&#x27;</span>);<br>    <span class="hljs-keyword">var</span> player  = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">getElementById</span>(<span class="hljs-string">&#x27;music-player&#x27;</span>);<br>    <span class="hljs-keyword">var</span> cards   = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelectorAll</span>(<span class="hljs-string">&#x27;.music-card&#x27;</span>);<br>    <span class="hljs-comment">// ... 其它元素</span><br><br>    <span class="hljs-keyword">var</span> state = &#123; <span class="hljs-attr">index</span>: -<span class="hljs-number">1</span>, <span class="hljs-attr">seeking</span>: <span class="hljs-literal">false</span> &#125;;<br><br>    <span class="hljs-comment">// 3. 切歌 / 播放 / 暂停</span><br>    <span class="hljs-keyword">function</span> <span class="hljs-title function_">loadSong</span>(<span class="hljs-params">i, autoplay</span>) &#123;<br>      <span class="hljs-keyword">var</span> song = list[i];<br>      state.<span class="hljs-property">index</span> = i;<br>      player.<span class="hljs-title function_">removeAttribute</span>(<span class="hljs-string">&#x27;hidden&#x27;</span>);<br>      audio.<span class="hljs-property">src</span> = <span class="hljs-built_in">encodeURI</span>(song.<span class="hljs-property">audio</span>);<br>      <span class="hljs-comment">// 更新封面、标题、歌手 ...</span><br>      <span class="hljs-keyword">if</span> (autoplay) audio.<span class="hljs-title function_">play</span>().<span class="hljs-title function_">catch</span>(<span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123;&#125;);<br>    &#125;<br><br>    <span class="hljs-comment">// 4. 卡片点击 -&gt; 切歌</span><br>    cards.<span class="hljs-title function_">forEach</span>(<span class="hljs-keyword">function</span> (<span class="hljs-params">card, i</span>) &#123;<br>      card.<span class="hljs-title function_">addEventListener</span>(<span class="hljs-string">&#x27;click&#x27;</span>, <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123;<br>        <span class="hljs-keyword">if</span> (i === state.<span class="hljs-property">index</span>) &#123;<br>          audio.<span class="hljs-property">paused</span> ? audio.<span class="hljs-title function_">play</span>() : audio.<span class="hljs-title function_">pause</span>();<br>        &#125; <span class="hljs-keyword">else</span> <span class="hljs-title function_">loadSong</span>(i, <span class="hljs-literal">true</span>);<br>      &#125;);<br>    &#125;);<br><br>    <span class="hljs-comment">// 5. audio 事件 -&gt; 同步 UI（封面旋转、进度条、时间）</span><br>    audio.<span class="hljs-title function_">addEventListener</span>(<span class="hljs-string">&#x27;timeupdate&#x27;</span>, <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123;<br>      <span class="hljs-keyword">var</span> pct = audio.<span class="hljs-property">currentTime</span> / audio.<span class="hljs-property">duration</span> * <span class="hljs-number">100</span>;<br>      <span class="hljs-comment">// 更新进度条 fill 的 width</span><br>    &#125;);<br><br>    <span class="hljs-comment">// 6. 进度条拖动 -&gt; seek</span><br>    <span class="hljs-comment">// pointerdown -&gt; pointermove 跟随 -&gt; pointerup 一次性 commit</span><br>  &#125;<br><br>  <span class="hljs-keyword">if</span> (<span class="hljs-variable language_">document</span>.<span class="hljs-property">readyState</span> === <span class="hljs-string">&#x27;loading&#x27;</span>) <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">addEventListener</span>(<span class="hljs-string">&#x27;DOMContentLoaded&#x27;</span>, init);<br>  <span class="hljs-keyword">else</span> <span class="hljs-title function_">init</span>();<br>&#125;)();<br></code></pre></td></tr></table></figure><p>完整版要做的细节有：</p><ul><li><strong>封面旋转动画</strong>：正在播放的卡片加 <code>.is-playing</code>，CSS 里 <code>animation: music-spin 18s linear infinite</code></li><li><strong>脉冲光圈</strong>：<code>::after</code> + <code>box-shadow</code> 扩散，再加 <code>keyframes</code> 淡出</li><li><strong>进度条拖动</strong>：用 <code>pointerdown / pointermove / pointerup</code> 三件套，拖的时候只动 UI，松手才真的 <code>audio.currentTime = ...</code>。不这么做的话会听到拖拽过程中音频不断抖</li><li><strong>键盘可达</strong>：进度条按 ← → 快退&#x2F;快进 5 秒，Home &#x2F; End 跳到头尾</li><li><strong>自动下一首</strong>：<code>audio.addEventListener(&#39;ended&#39;, next)</code></li></ul><h3 id="5-4-样式（节选）"><a href="#5-4-样式（节选）" class="headerlink" title="5.4 样式（节选）"></a>5.4 样式（节选）</h3><p>几个关键 CSS 片段：</p><figure class="highlight css"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br></pre></td><td class="code"><pre><code class="hljs css"><span class="hljs-comment">/* 唱片墙自适应 */</span><br><span class="hljs-selector-class">.music-grid</span> &#123;<br>  <span class="hljs-attribute">display</span>: grid;<br>  <span class="hljs-attribute">grid-template-columns</span>: <span class="hljs-built_in">repeat</span>(auto-fill, <span class="hljs-built_in">minmax</span>(<span class="hljs-number">180px</span>, <span class="hljs-number">1</span>fr));<br>  <span class="hljs-attribute">gap</span>: <span class="hljs-number">1.5rem</span>;<br>&#125;<br><br><span class="hljs-comment">/* 封面旋转 */</span><br><span class="hljs-selector-class">.music-card</span><span class="hljs-selector-class">.is-playing</span> <span class="hljs-selector-class">.music-cover</span> &#123;<br>  <span class="hljs-attribute">animation</span>: music-spin <span class="hljs-number">18s</span> linear infinite;<br>&#125;<br><span class="hljs-keyword">@keyframes</span> music-spin &#123;<br>  <span class="hljs-selector-tag">from</span> &#123; <span class="hljs-attribute">transform</span>: <span class="hljs-built_in">rotate</span>(<span class="hljs-number">0deg</span>); &#125;<br>  <span class="hljs-selector-tag">to</span>   &#123; <span class="hljs-attribute">transform</span>: <span class="hljs-built_in">rotate</span>(<span class="hljs-number">360deg</span>); &#125;<br>&#125;<br><br><span class="hljs-comment">/* 播放中脉冲光圈 */</span><br><span class="hljs-selector-class">.music-card</span><span class="hljs-selector-class">.is-playing</span> <span class="hljs-selector-class">.music-cover-wrap</span><span class="hljs-selector-pseudo">::after</span> &#123;<br>  <span class="hljs-attribute">content</span>: <span class="hljs-string">&quot;&quot;</span>;<br>  <span class="hljs-attribute">position</span>: absolute; <span class="hljs-attribute">inset</span>: <span class="hljs-number">0</span>;<br>  <span class="hljs-attribute">border-radius</span>: inherit;<br>  <span class="hljs-attribute">box-shadow</span>: <span class="hljs-number">0</span> <span class="hljs-number">0</span> <span class="hljs-number">0</span> <span class="hljs-number">0</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">94</span>, <span class="hljs-number">129</span>, <span class="hljs-number">172</span>, <span class="hljs-number">0.45</span>);<br>  <span class="hljs-attribute">animation</span>: music-pulse <span class="hljs-number">2.2s</span> <span class="hljs-built_in">var</span>(--ease-out) infinite;<br>  <span class="hljs-attribute">pointer-events</span>: none;<br>&#125;<br><span class="hljs-keyword">@keyframes</span> music-pulse &#123;<br>  <span class="hljs-number">0%</span>   &#123; <span class="hljs-attribute">box-shadow</span>: <span class="hljs-number">0</span> <span class="hljs-number">0</span> <span class="hljs-number">0</span> <span class="hljs-number">0</span>    <span class="hljs-built_in">rgba</span>(<span class="hljs-number">94</span>, <span class="hljs-number">129</span>, <span class="hljs-number">172</span>, <span class="hljs-number">0.45</span>); &#125;<br>  <span class="hljs-number">70%</span>  &#123; <span class="hljs-attribute">box-shadow</span>: <span class="hljs-number">0</span> <span class="hljs-number">0</span> <span class="hljs-number">0</span> <span class="hljs-number">14px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">94</span>, <span class="hljs-number">129</span>, <span class="hljs-number">172</span>, <span class="hljs-number">0</span>); &#125;<br>  <span class="hljs-number">100%</span> &#123; <span class="hljs-attribute">box-shadow</span>: <span class="hljs-number">0</span> <span class="hljs-number">0</span> <span class="hljs-number">0</span> <span class="hljs-number">0</span>    <span class="hljs-built_in">rgba</span>(<span class="hljs-number">94</span>, <span class="hljs-number">129</span>, <span class="hljs-number">172</span>, <span class="hljs-number">0</span>); &#125;<br>&#125;<br><br><span class="hljs-comment">/* 玻璃态播放器 */</span><br><span class="hljs-selector-class">.music-player</span> &#123;<br>  <span class="hljs-attribute">background</span>: <span class="hljs-built_in">rgba</span>(<span class="hljs-number">255</span>, <span class="hljs-number">255</span>, <span class="hljs-number">255</span>, <span class="hljs-number">0.72</span>);<br>  <span class="hljs-attribute">backdrop-filter</span>: <span class="hljs-built_in">saturate</span>(<span class="hljs-number">140%</span>) <span class="hljs-built_in">blur</span>(<span class="hljs-number">10px</span>);<br>  -webkit-<span class="hljs-attribute">backdrop-filter</span>: <span class="hljs-built_in">saturate</span>(<span class="hljs-number">140%</span>) <span class="hljs-built_in">blur</span>(<span class="hljs-number">10px</span>);<br>  <span class="hljs-attribute">border</span>: <span class="hljs-number">1px</span> solid <span class="hljs-built_in">var</span>(--hairline);<br>  <span class="hljs-attribute">border-radius</span>: <span class="hljs-built_in">var</span>(--radius-lg);<br>  <span class="hljs-attribute">box-shadow</span>: <span class="hljs-built_in">var</span>(--shadow-<span class="hljs-number">2</span>);<br>&#125;<br></code></pre></td></tr></table></figure><p>三个视觉重点：<strong>转的唱片、脉冲光圈、玻璃态播放器</strong>。缺一个就不像了。</p><h3 id="5-5-挂到菜单"><a href="#5-5-挂到菜单" class="headerlink" title="5.5 挂到菜单"></a>5.5 挂到菜单</h3><p>最后一步，让访客能进到这个页面。<code>_config.fluid.yml</code> 导航里加一项：</p><figure class="highlight yaml"><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><code class="hljs yaml"><span class="hljs-attr">navbar:</span><br>  <span class="hljs-attr">menu:</span><br>    <span class="hljs-bullet">-</span> &#123; <span class="hljs-attr">key:</span> <span class="hljs-string">&quot;home&quot;</span>,    <span class="hljs-attr">link:</span> <span class="hljs-string">&quot;/&quot;</span>,          <span class="hljs-attr">icon:</span> <span class="hljs-string">&quot;iconfont icon-home-fill&quot;</span> &#125;<br>    <span class="hljs-bullet">-</span> &#123; <span class="hljs-attr">key:</span> <span class="hljs-string">&quot;archive&quot;</span>, <span class="hljs-attr">link:</span> <span class="hljs-string">&quot;/archives/&quot;</span>, <span class="hljs-attr">icon:</span> <span class="hljs-string">&quot;iconfont icon-archive-fill&quot;</span> &#125;<br>    <span class="hljs-bullet">-</span> &#123; <span class="hljs-attr">name:</span> <span class="hljs-string">&quot;音乐&quot;</span>,   <span class="hljs-attr">link:</span> <span class="hljs-string">&quot;/music/&quot;</span>,    <span class="hljs-attr">icon:</span> <span class="hljs-string">&quot;iconfont icon-music&quot;</span> &#125;<br>    <span class="hljs-bullet">-</span> &#123; <span class="hljs-attr">key:</span> <span class="hljs-string">&quot;about&quot;</span>,   <span class="hljs-attr">link:</span> <span class="hljs-string">&quot;/about/&quot;</span>,    <span class="hljs-attr">icon:</span> <span class="hljs-string">&quot;iconfont icon-user-fill&quot;</span> &#125;<br></code></pre></td></tr></table></figure><h2 id="六、春节灯笼（时间窗口内才出现）"><a href="#六、春节灯笼（时间窗口内才出现）" class="headerlink" title="六、春节灯笼（时间窗口内才出现）"></a>六、春节灯笼（时间窗口内才出现）</h2><p>这是个彩蛋。<strong>只在除夕前 1 天到正月十五期间</strong>自动挂 4 只灯笼，其它时间 0 副作用。</p><p><code>source/js/spring-festival-lanterns.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><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><code class="hljs js">(<span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123;<br>  <span class="hljs-string">&#x27;use strict&#x27;</span>;<br><br>  <span class="hljs-comment">// 2026-2099 年春节公历日期</span><br>  <span class="hljs-keyword">var</span> <span class="hljs-variable constant_">SPRING_FESTIVAL</span> = &#123;<br>    <span class="hljs-number">2026</span>: <span class="hljs-string">&#x27;02-17&#x27;</span>, <span class="hljs-number">2027</span>: <span class="hljs-string">&#x27;02-06&#x27;</span>, <span class="hljs-number">2028</span>: <span class="hljs-string">&#x27;01-26&#x27;</span>, <span class="hljs-number">2029</span>: <span class="hljs-string">&#x27;02-13&#x27;</span>,<br>    <span class="hljs-number">2030</span>: <span class="hljs-string">&#x27;02-03&#x27;</span>,<br>    <span class="hljs-comment">// ... 数据来源：紫金山天文台 / 香港天文台</span><br>  &#125;;<br><br>  <span class="hljs-keyword">function</span> <span class="hljs-title function_">getWindow</span>(<span class="hljs-params">year</span>) &#123;<br>    <span class="hljs-keyword">var</span> mmdd = <span class="hljs-variable constant_">SPRING_FESTIVAL</span>[year];<br>    <span class="hljs-keyword">if</span> (!mmdd) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;<br>    <span class="hljs-keyword">var</span> parts = mmdd.<span class="hljs-title function_">split</span>(<span class="hljs-string">&#x27;-&#x27;</span>);<br>    <span class="hljs-keyword">var</span> spring = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(year, +parts[<span class="hljs-number">0</span>]-<span class="hljs-number">1</span>, +parts[<span class="hljs-number">1</span>]);<br>    <span class="hljs-keyword">var</span> start = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(spring); start.<span class="hljs-title function_">setDate</span>(start.<span class="hljs-title function_">getDate</span>() - <span class="hljs-number">1</span>);  <span class="hljs-comment">// 除夕前一天</span><br>    <span class="hljs-keyword">var</span> end   = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(spring); end.<span class="hljs-title function_">setDate</span>(end.<span class="hljs-title function_">getDate</span>() + <span class="hljs-number">14</span>);     <span class="hljs-comment">// 正月十五</span><br>    start.<span class="hljs-title function_">setHours</span>(<span class="hljs-number">0</span>,<span class="hljs-number">0</span>,<span class="hljs-number">0</span>,<span class="hljs-number">0</span>); end.<span class="hljs-title function_">setHours</span>(<span class="hljs-number">23</span>,<span class="hljs-number">59</span>,<span class="hljs-number">59</span>,<span class="hljs-number">999</span>);<br>    <span class="hljs-keyword">return</span> &#123; <span class="hljs-attr">start</span>: start, <span class="hljs-attr">end</span>: end &#125;;<br>  &#125;<br><br>  <span class="hljs-keyword">function</span> <span class="hljs-title function_">shouldShow</span>(<span class="hljs-params"></span>) &#123;<br>    <span class="hljs-keyword">var</span> now = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>();<br>    <span class="hljs-keyword">var</span> w = <span class="hljs-title function_">getWindow</span>(now.<span class="hljs-title function_">getFullYear</span>());<br>    <span class="hljs-keyword">return</span> w &amp;&amp; now &gt;= w.<span class="hljs-property">start</span> &amp;&amp; now &lt;= w.<span class="hljs-property">end</span>;<br>  &#125;<br><br>  <span class="hljs-keyword">if</span> (!<span class="hljs-title function_">shouldShow</span>()) <span class="hljs-keyword">return</span>; <span class="hljs-comment">// 不在窗口期，直接退出</span><br><br>  <span class="hljs-comment">// 注入 CSS + 4 个 .deng-box</span><br>  <span class="hljs-comment">// 细节略，参考 source/js/spring-festival-lanterns.js</span><br>&#125;)();<br></code></pre></td></tr></table></figure><p>关键设计：</p><ul><li><strong>开头 <code>if (!shouldShow()) return</code></strong>，不在春节窗口直接退出。完全没有 DOM 和 CSS 被插入，其它时间的用户拿到的就是一个空函数</li><li><strong>灯笼 HTML 纯 <code>&lt;div&gt;</code> 拼</strong>，用 CSS 画椭圆和流苏，不用图片</li><li><strong>摆动动画</strong> <code>@keyframes deng-swing</code> 加 <code>prefers-reduced-motion</code> 媒体查询，晕动症用户会自动暂停动画</li></ul><h2 id="七、健身打卡（markdown-当数据库）"><a href="#七、健身打卡（markdown-当数据库）" class="headerlink" title="七、健身打卡（markdown 当数据库）"></a>七、健身打卡（markdown 当数据库）</h2><p>这是我个人最得意的一块。<strong>写作是 markdown，前端是 JSON</strong>——中间有个构建期 generator 把两者打通。</p><h3 id="7-1-数据源"><a href="#7-1-数据源" class="headerlink" title="7.1 数据源"></a>7.1 数据源</h3><p>在 <code>source/_fitness/</code> 下每天一个文件（下划线开头，Hexo 不会把它们渲染成独立页面）：</p><figure class="highlight subunit"><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><code class="hljs subunit">source/_fitness/<br>├── README.md              # 使用说明<br>├── 2026<span class="hljs-string">-05</span><span class="hljs-string">-06</span>.md<br>├── 2026<span class="hljs-string">-05</span><span class="hljs-string">-08</span>.md<br>└── 2026<span class="hljs-string">-05</span><span class="hljs-string">-09</span>.md<br></code></pre></td></tr></table></figure><p>每个文件就是一次打卡：</p><figure class="highlight markdown"><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><code class="hljs markdown">---<br>type: 跑步<br>duration: 35<br><span class="hljs-section">title: 河边晨跑</span><br><span class="hljs-section">---</span><br><br>配速 6&#x27;10&quot;，心率 155。最后 1km 加速冲刺，还能再快。<br></code></pre></td></tr></table></figure><h3 id="7-2-构建期-generator"><a href="#7-2-构建期-generator" class="headerlink" title="7.2 构建期 generator"></a>7.2 构建期 generator</h3><p>Hexo 允许你在 <code>scripts/</code> 下写自定义生成器，在 <code>hexo generate</code> 执行时被自动加载。</p><p>新建 <code>scripts/fitness-data.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><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-meta">&#x27;use strict&#x27;</span>;<br><span class="hljs-keyword">const</span> fs = <span class="hljs-built_in">require</span>(<span class="hljs-string">&#x27;fs&#x27;</span>);<br><span class="hljs-keyword">const</span> path = <span class="hljs-built_in">require</span>(<span class="hljs-string">&#x27;path&#x27;</span>);<br><br><span class="hljs-keyword">function</span> <span class="hljs-title function_">parseFrontMatter</span>(<span class="hljs-params">content</span>) &#123;<br>  <span class="hljs-keyword">const</span> m = content.<span class="hljs-title function_">match</span>(<span class="hljs-regexp">/^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/</span>);<br>  <span class="hljs-keyword">if</span> (!m) <span class="hljs-keyword">return</span> &#123; <span class="hljs-attr">meta</span>: &#123;&#125;, <span class="hljs-attr">body</span>: content.<span class="hljs-title function_">trim</span>() &#125;;<br>  <span class="hljs-keyword">const</span> meta = &#123;&#125;;<br>  m[<span class="hljs-number">1</span>].<span class="hljs-title function_">split</span>(<span class="hljs-regexp">/\r?\n/</span>).<span class="hljs-title function_">forEach</span>(<span class="hljs-function"><span class="hljs-params">line</span> =&gt;</span> &#123;<br>    <span class="hljs-keyword">const</span> mm = line.<span class="hljs-title function_">match</span>(<span class="hljs-regexp">/^\s*([A-Za-z0-9_-]+)\s*:\s*(.*)\s*$/</span>);<br>    <span class="hljs-keyword">if</span> (!mm) <span class="hljs-keyword">return</span>;<br>    <span class="hljs-keyword">let</span> v = mm[<span class="hljs-number">2</span>].<span class="hljs-title function_">trim</span>();<br>    <span class="hljs-keyword">if</span> ((v[<span class="hljs-number">0</span>] === <span class="hljs-string">&#x27;&quot;&#x27;</span> &amp;&amp; v.<span class="hljs-title function_">at</span>(-<span class="hljs-number">1</span>) === <span class="hljs-string">&#x27;&quot;&#x27;</span>) || (v[<span class="hljs-number">0</span>] === <span class="hljs-string">&quot;&#x27;&quot;</span> &amp;&amp; v.<span class="hljs-title function_">at</span>(-<span class="hljs-number">1</span>) === <span class="hljs-string">&quot;&#x27;&quot;</span>)) v = v.<span class="hljs-title function_">slice</span>(<span class="hljs-number">1</span>, -<span class="hljs-number">1</span>);<br>    meta[mm[<span class="hljs-number">1</span>]] = v;<br>  &#125;);<br>  <span class="hljs-keyword">return</span> &#123; meta, <span class="hljs-attr">body</span>: (m[<span class="hljs-number">2</span>] || <span class="hljs-string">&#x27;&#x27;</span>).<span class="hljs-title function_">trim</span>() &#125;;<br>&#125;<br><br><span class="hljs-keyword">function</span> <span class="hljs-title function_">collect</span>(<span class="hljs-params">baseDir</span>) &#123;<br>  <span class="hljs-keyword">const</span> dir = path.<span class="hljs-title function_">join</span>(baseDir, <span class="hljs-string">&#x27;source&#x27;</span>, <span class="hljs-string">&#x27;_fitness&#x27;</span>);<br>  <span class="hljs-keyword">if</span> (!fs.<span class="hljs-title function_">existsSync</span>(dir)) <span class="hljs-keyword">return</span> [];<br>  <span class="hljs-keyword">return</span> fs.<span class="hljs-title function_">readdirSync</span>(dir)<br>    .<span class="hljs-title function_">filter</span>(<span class="hljs-function"><span class="hljs-params">f</span> =&gt;</span> <span class="hljs-regexp">/\.md$/i</span>.<span class="hljs-title function_">test</span>(f) &amp;&amp; !<span class="hljs-regexp">/^readme\.md$/i</span>.<span class="hljs-title function_">test</span>(f))<br>    .<span class="hljs-title function_">map</span>(<span class="hljs-function"><span class="hljs-params">f</span> =&gt;</span> &#123;<br>      <span class="hljs-keyword">const</span> m = f.<span class="hljs-title function_">match</span>(<span class="hljs-regexp">/^(\d&#123;4&#125;-\d&#123;2&#125;-\d&#123;2&#125;)\.md$/i</span>);<br>      <span class="hljs-keyword">if</span> (!m) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;<br>      <span class="hljs-keyword">const</span> &#123; meta, body &#125; = <span class="hljs-title function_">parseFrontMatter</span>(fs.<span class="hljs-title function_">readFileSync</span>(path.<span class="hljs-title function_">join</span>(dir, f), <span class="hljs-string">&#x27;utf8&#x27;</span>));<br>      <span class="hljs-keyword">return</span> &#123;<br>        <span class="hljs-attr">date</span>: m[<span class="hljs-number">1</span>],<br>        <span class="hljs-attr">type</span>: meta.<span class="hljs-property">type</span> || <span class="hljs-string">&#x27;&#x27;</span>,<br>        <span class="hljs-attr">title</span>: meta.<span class="hljs-property">title</span> || <span class="hljs-string">&#x27;&#x27;</span>,<br>        <span class="hljs-attr">duration</span>: meta.<span class="hljs-property">duration</span> &amp;&amp; !<span class="hljs-built_in">isNaN</span>(+meta.<span class="hljs-property">duration</span>) ? +meta.<span class="hljs-property">duration</span> : <span class="hljs-literal">null</span>,<br>        <span class="hljs-attr">note</span>: body || <span class="hljs-string">&#x27;&#x27;</span>,<br>      &#125;;<br>    &#125;)<br>    .<span class="hljs-title function_">filter</span>(<span class="hljs-title class_">Boolean</span>)<br>    .<span class="hljs-title function_">sort</span>(<span class="hljs-function">(<span class="hljs-params">a, b</span>) =&gt;</span> a.<span class="hljs-property">date</span>.<span class="hljs-title function_">localeCompare</span>(b.<span class="hljs-property">date</span>));<br>&#125;<br><br>hexo.<span class="hljs-property">extend</span>.<span class="hljs-property">generator</span>.<span class="hljs-title function_">register</span>(<span class="hljs-string">&#x27;fitness-data&#x27;</span>, <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123;<br>  <span class="hljs-keyword">const</span> items = <span class="hljs-title function_">collect</span>(hexo.<span class="hljs-property">base_dir</span>);<br>  <span class="hljs-keyword">return</span> &#123;<br>    <span class="hljs-attr">path</span>: <span class="hljs-string">&#x27;api/fitness.json&#x27;</span>,<br>    <span class="hljs-attr">data</span>: <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(&#123; <span class="hljs-attr">generatedAt</span>: <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>().<span class="hljs-title function_">toISOString</span>(), <span class="hljs-attr">count</span>: items.<span class="hljs-property">length</span>, items &#125;),<br>  &#125;;<br>&#125;);<br></code></pre></td></tr></table></figure><p>执行 <code>hexo generate</code> 后，<code>public/api/fitness.json</code> 就有了所有打卡数据。</p><h3 id="7-3-前端直接-fetch"><a href="#7-3-前端直接-fetch" class="headerlink" title="7.3 前端直接 fetch"></a>7.3 前端直接 fetch</h3><p>前端页面 <code>source/fitness/index.md</code> 只负责 DOM 骨架：</p><figure class="highlight markdown"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><code class="hljs markdown">---<br>title: 健身打卡<br>layout: page<br>date: 2026-05-09 00:00:00<br><span class="hljs-section">comments: false</span><br><span class="hljs-section">---</span><br><br>&#123;% raw %&#125;<br><span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;fit-archive&quot;</span> <span class="hljs-attr">data-api</span>=<span class="hljs-string">&quot;/api/fitness.json&quot;</span>&gt;</span></span><br>  <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">p</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;h4&quot;</span> <span class="hljs-attr">data-fit-summary</span>&gt;</span></span>正在加载…<span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span></span><br>  <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;fit-tabs&quot;</span> <span class="hljs-attr">data-fit-tabs</span>&gt;</span></span><span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span><br>  <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">hr</span>&gt;</span></span><br>  <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;list-group&quot;</span> <span class="hljs-attr">data-fit-list</span>&gt;</span></span><span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span><br><span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span><br><span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">defer</span> <span class="hljs-attr">src</span>=<span class="hljs-string">&quot;/js/fitness-wall.js&quot;</span>&gt;</span></span><span class="language-xml"><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></span><br>&#123;% endraw %&#125;<br></code></pre></td></tr></table></figure><p>配套的 <code>source/js/fitness-wall.js</code> 拉 JSON，按年份分 Tab、按月分组渲染。代码不贴了，全文在 <code>source/js/fitness-wall.js</code>。</p><p><strong>加一条打卡的完整流程</strong>：</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><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 新建文件</span><br><span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;---`ntype: 跑步`nduration: 30`n---&quot;</span> &gt; <span class="hljs-built_in">source</span>/_fitness/2026-05-10.md<br><br><span class="hljs-comment"># 2. 写感受（可选）</span><br><br><span class="hljs-comment"># 3. 构建部署</span><br>hexo clean &amp;&amp; hexo generate &amp;&amp; hexo deploy<br></code></pre></td></tr></table></figure><h2 id="八、构建期抓远程数据（十年留言墙）"><a href="#八、构建期抓远程数据（十年留言墙）" class="headerlink" title="八、构建期抓远程数据（十年留言墙）"></a>八、构建期抓远程数据（十年留言墙）</h2><p>上面健身打卡读的是本地文件。再进一步：<strong>构建期去远程接口拉数据，落盘成静态 JSON</strong>。</p><p>为什么要这么做？两个问题同时解决：</p><ul><li><strong>CORS 问题消失</strong>：前端只读自己域名下的 <code>/api/xxx.json</code></li><li><strong>源站挂了也不影响</strong>：上次构建的数据还在，只是不更新</li></ul><p><code>scripts/ten-year-wall.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><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-meta">&#x27;use strict&#x27;</span>;<br><br><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">fetchAllMessages</span>(<span class="hljs-params">log</span>) &#123;<br>  <span class="hljs-keyword">const</span> results = [];<br>  <span class="hljs-keyword">let</span> cursor = <span class="hljs-literal">null</span>;<br>  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">20</span>; i++) &#123;<br>    <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-title function_">buildUrl</span>(cursor)).<span class="hljs-title function_">then</span>(<span class="hljs-function"><span class="hljs-params">r</span> =&gt;</span> r.<span class="hljs-title function_">json</span>());<br>    results.<span class="hljs-title function_">push</span>(...data.<span class="hljs-property">comments</span>.<span class="hljs-title function_">map</span>(normalize));<br>    <span class="hljs-keyword">if</span> (!data.<span class="hljs-property">pageInfo</span>.<span class="hljs-property">hasNextPage</span>) <span class="hljs-keyword">break</span>;<br>    cursor = data.<span class="hljs-property">pageInfo</span>.<span class="hljs-property">endCursor</span>;<br>  &#125;<br>  <span class="hljs-keyword">return</span> results;<br>&#125;<br><br>hexo.<span class="hljs-property">extend</span>.<span class="hljs-property">generator</span>.<span class="hljs-title function_">register</span>(<span class="hljs-string">&#x27;ten-year-wall&#x27;</span>, <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123;<br>  <span class="hljs-keyword">const</span> log = hexo.<span class="hljs-property">log</span> || <span class="hljs-variable language_">console</span>;<br>  <span class="hljs-keyword">const</span> outPath = <span class="hljs-string">&#x27;api/ten-year-messages.json&#x27;</span>;<br><br>  <span class="hljs-comment">// 离线构建时跳过</span><br>  <span class="hljs-keyword">if</span> (process.<span class="hljs-property">env</span>.<span class="hljs-property">TEN_YEAR_WALL_SKIP</span> === <span class="hljs-string">&#x27;1&#x27;</span>) &#123;<br>    <span class="hljs-keyword">return</span> &#123; <span class="hljs-attr">path</span>: outPath, <span class="hljs-attr">data</span>: <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(&#123; <span class="hljs-attr">messages</span>: [], <span class="hljs-attr">status</span>: <span class="hljs-string">&#x27;skipped&#x27;</span> &#125;) &#125;;<br>  &#125;<br><br>  <span class="hljs-keyword">try</span> &#123;<br>    <span class="hljs-keyword">const</span> messages = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetchAllMessages</span>(log);<br>    log.<span class="hljs-title function_">info</span>(<span class="hljs-string">`[ten-year-wall] fetched <span class="hljs-subst">$&#123;messages.length&#125;</span> messages`</span>);<br>    <span class="hljs-keyword">return</span> &#123; <span class="hljs-attr">path</span>: outPath, <span class="hljs-attr">data</span>: <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(&#123; messages, <span class="hljs-attr">status</span>: <span class="hljs-string">&#x27;ok&#x27;</span> &#125;) &#125;;<br>  &#125; <span class="hljs-keyword">catch</span> (err) &#123;<br>    log.<span class="hljs-title function_">warn</span>(<span class="hljs-string">`[ten-year-wall] fetch failed: <span class="hljs-subst">$&#123;err.message&#125;</span>`</span>);<br>    <span class="hljs-keyword">return</span> &#123; <span class="hljs-attr">path</span>: outPath, <span class="hljs-attr">data</span>: <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(&#123; <span class="hljs-attr">messages</span>: [], <span class="hljs-attr">status</span>: <span class="hljs-string">&#x27;error&#x27;</span> &#125;) &#125;;<br>  &#125;<br>&#125;);<br></code></pre></td></tr></table></figure><p>三个细节：</p><ul><li><strong>环境变量跳过开关</strong> <code>TEN_YEAR_WALL_SKIP=1</code>：离线构建、网络抽风时能兜底</li><li><strong>try&#x2F;catch 包到最外层</strong>：失败不能让整个 <code>hexo generate</code> 挂掉</li><li><strong>空负载也要写文件</strong>：<code>fetch</code> 失败就写空数组，前端只看到”数据为空”，不会 404</li></ul><p>这个套路非常通用，任何”博客里想嵌外部数据但又怕依赖”的场景都能套。</p><h2 id="九、几个小但很爽的增强"><a href="#九、几个小但很爽的增强" class="headerlink" title="九、几个小但很爽的增强"></a>九、几个小但很爽的增强</h2><p>这些都是一行 &#x2F; 几行的事，不值得单独列，但加起来很提气。</p><h3 id="iciba-每日一句当-slogan"><a href="#iciba-每日一句当-slogan" class="headerlink" title="iciba 每日一句当 slogan"></a>iciba 每日一句当 slogan</h3><p>首页那句副标题别写死，拉英语趣配音的每日一句 API，每天换。<code>source/js/iciba-slogan.js</code> 里 fetch 一下，拿到中英文扔给 Fluid 的 <code>typing</code> 插件打字机。</p><h3 id="站点运行时长"><a href="#站点运行时长" class="headerlink" title="站点运行时长"></a>站点运行时长</h3><p>页脚加个”本站已运行 N 天”，不蒜子做不到（它只有 PV&#x2F;UV）。自己写：</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><code class="hljs js"><span class="hljs-keyword">var</span> since = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(<span class="hljs-variable language_">document</span>.<span class="hljs-title function_">getElementById</span>(<span class="hljs-string">&#x27;site-uptime&#x27;</span>).<span class="hljs-property">dataset</span>.<span class="hljs-property">since</span>);<br><span class="hljs-keyword">var</span> days  = <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">floor</span>((<span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>() - since) / <span class="hljs-number">86400000</span>);<br><span class="hljs-comment">// 拼 &quot;本站已运行 42 天 3 小时 12 分&quot;</span><br></code></pre></td></tr></table></figure><h3 id="外链-nofollow"><a href="#外链-nofollow" class="headerlink" title="外链 nofollow"></a>外链 nofollow</h3><p>避免 SEO 权重被别人的站吸走。<code>_config.yml</code>：</p><figure class="highlight yaml"><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><code class="hljs yaml"><span class="hljs-attr">nofollow:</span><br>  <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span><br>  <span class="hljs-attr">field:</span> <span class="hljs-string">site</span><br>  <span class="hljs-attr">exclude:</span><br>    <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;github.com&#x27;</span><br></code></pre></td></tr></table></figure><p>依赖 <code>hexo-filter-nofollow</code>。</p><h3 id="产物压缩"><a href="#产物压缩" class="headerlink" title="产物压缩"></a>产物压缩</h3><p>HTML &#x2F; CSS &#x2F; JS 构建期压缩一次，线上省流量：</p><figure class="highlight yaml"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">minify:</span><br>  <span class="hljs-attr">js:</span>  &#123; <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span> &#125;<br>  <span class="hljs-attr">css:</span> &#123; <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span> &#125;<br>  <span class="hljs-attr">html:</span><br>    <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span><br>    <span class="hljs-attr">options:</span><br>      <span class="hljs-attr">collapseWhitespace:</span> <span class="hljs-literal">true</span><br>      <span class="hljs-attr">removeComments:</span> <span class="hljs-literal">true</span><br>      <span class="hljs-attr">minifyJS:</span> <span class="hljs-literal">true</span><br>      <span class="hljs-attr">minifyCSS:</span> <span class="hljs-literal">true</span><br></code></pre></td></tr></table></figure><p>依赖 <code>hexo-minify</code>。</p><h2 id="十、最终的目录长这样"><a href="#十、最终的目录长这样" class="headerlink" title="十、最终的目录长这样"></a>十、最终的目录长这样</h2><p>走到这里，整个博客的结构应该是：</p><figure class="highlight nix"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><code class="hljs nix">blog<span class="hljs-symbol">/</span><br>├── _config.yml                 <span class="hljs-comment"># Hexo 主配</span><br>├── _config.fluid.yml           <span class="hljs-comment"># Fluid 覆盖配置 + custom_js / custom_css</span><br>├── scripts<span class="hljs-symbol">/</span>                    <span class="hljs-comment"># 构建期 generator</span><br>│   ├── ten-year-wall.js        <span class="hljs-comment"># 抓远程数据</span><br>│   └── fitness-data.js         <span class="hljs-comment"># 扫本地 _fitness</span><br>├── source<span class="hljs-symbol">/</span><br>│   ├── _posts<span class="hljs-symbol">/</span>                 <span class="hljs-comment"># 文章</span><br>│   ├── _fitness<span class="hljs-symbol">/</span>               <span class="hljs-comment"># 打卡原始数据（不渲染成页面）</span><br>│   ├── music<span class="hljs-symbol">/</span>                  <span class="hljs-comment"># 音乐页（含 audio / covers）</span><br>│   ├── fitness<span class="hljs-symbol">/</span>                <span class="hljs-comment"># 健身打卡页</span><br>│   ├── about<span class="hljs-symbol">/</span>                  <span class="hljs-comment"># 关于页</span><br>│   ├── css<span class="hljs-symbol">/custom.css</span>          <span class="hljs-comment"># 设计令牌 + 所有自定义样式</span><br>│   ├── js<span class="hljs-symbol">/</span><br>│   │   ├── bing-banner.js<br>│   │   ├── iciba-slogan.js<br>│   │   ├── site-uptime.js<br>│   │   ├── spring-festival-lanterns.js<br>│   │   ├── music-wall.js<br>│   │   └── fitness-wall.js<br>│   ├── img<span class="hljs-symbol">/</span><br>│   └── favicon.svg<br>└── package.json<br></code></pre></td></tr></table></figure><p><strong>新增一个自定义页面的标准流程</strong>：</p><ol><li><code>source/xxx/index.md</code> 写 HTML 骨架，挂一个 <code>&lt;script src=&quot;/js/xxx.js&quot;&gt;</code></li><li><code>source/js/xxx.js</code> 写交互</li><li><code>source/css/custom.css</code> 里追加样式</li><li>数据复杂的话在 <code>scripts/</code> 下写一个 generator 吐 JSON 到 <code>public/api/xxx.json</code></li><li><code>_config.fluid.yml</code> 菜单里加入口</li></ol><p>跟着这个模板做，加一个新模块的时间大概是几个小时到半天。</p><h2 id="最后"><a href="#最后" class="headerlink" title="最后"></a>最后</h2><p>这些东西单看都不难，但加起来是一个 “属于你自己” 的博客。别人用 Fluid 的站和你用 Fluid 的站，一眼就能看出区别——区别不在主题，在这些散落在角落的小心思。</p><p>我知道很多人搭完博客就再也不更新了。这挺正常。博客的乐趣一半是写，一半是折腾。哪个都能让你高兴一阵。</p><hr><p><em>本站所有这些改造的源代码都在 <a href="https://github.com/7788dev/7788dev.github.io">GitHub 仓库</a> 里公开（源码在另一个仓库，链接在 about 页）。欢迎 fork、抄、改。</em></p>]]>
    </content>
    <id>https://7788dev.github.io/2026/05/10/hexo-fluid-customization/</id>
    <link href="https://7788dev.github.io/2026/05/10/hexo-fluid-customization/"/>
    <published>2026-05-09T19:30:00.000Z</published>
    <summary>上一篇把 Hexo 博客从零搭到上线。这篇接着讲本站具体做了哪些魔改——统一的设计令牌、Nord 配色、Bing 每日壁纸、音乐墙、春节灯笼、健身打卡。每一块都有完整代码、放文件的位置、接线方式，能跟着抄出来。</summary>
    <title>本站是怎么魔改出来的</title>
    <updated>2026-05-10T09:13:35.674Z</updated>
  </entry>
  <entry>
    <author>
      <name>Looks</name>
    </author>
    <category term="教程" scheme="https://7788dev.github.io/categories/%E6%95%99%E7%A8%8B/"/>
    <category term="Hexo" scheme="https://7788dev.github.io/tags/Hexo/"/>
    <category term="博客" scheme="https://7788dev.github.io/tags/%E5%8D%9A%E5%AE%A2/"/>
    <category term="新手教程" scheme="https://7788dev.github.io/tags/%E6%96%B0%E6%89%8B%E6%95%99%E7%A8%8B/"/>
    <category term="GitHub Pages" scheme="https://7788dev.github.io/tags/GitHub-Pages/"/>
    <content>
      <![CDATA[<blockquote><p>这篇写给<strong>完全没接触过博客搭建的人</strong>。</p><p>只要你能复制粘贴命令、会打开浏览器，就能跟着做下来。遇到生词我都会在旁边解释。</p><p>跟完这篇文章，你会得到：一个挂在自己 GitHub 账号下的博客（像 <code>https://你的用户名.github.io</code>）、可以随时改、完全免费、还能用自定义域名。</p></blockquote><h2 id="这篇会用到的东西一览"><a href="#这篇会用到的东西一览" class="headerlink" title="这篇会用到的东西一览"></a>这篇会用到的东西一览</h2><p>开始前先说清楚我们会用到什么，不理解没关系，后面会一个个教。</p><table><thead><tr><th>工具</th><th>作用</th><th>要花钱吗</th></tr></thead><tbody><tr><td><strong>Node.js</strong></td><td>运行 Hexo 需要的环境</td><td>免费</td></tr><tr><td><strong>Git</strong></td><td>把博客文件传到网上</td><td>免费</td></tr><tr><td><strong>Hexo</strong></td><td>把你写的 markdown 变成网页</td><td>免费</td></tr><tr><td><strong>Fluid 主题</strong></td><td>决定博客长什么样</td><td>免费</td></tr><tr><td><strong>GitHub</strong></td><td>白嫖服务器，帮你免费把博客挂上网</td><td>免费</td></tr><tr><td><strong>VS Code</strong>（推荐）</td><td>写文章的编辑器</td><td>免费</td></tr></tbody></table><p><strong>总花费：0 元</strong>。如果你想要一个自定义域名（比如 <code>zhangsan.com</code>）大概一年 50 块钱，不是必需的。</p><h2 id="第一步：装-Node-js"><a href="#第一步：装-Node-js" class="headerlink" title="第一步：装 Node.js"></a>第一步：装 Node.js</h2><p>Hexo 是用 JavaScript 写的，得先装能跑 JavaScript 的环境，叫 Node.js。</p><p><strong>Windows 和 macOS 通用做法</strong>：</p><ol><li>打开 <a href="https://nodejs.org/zh-cn">nodejs.org</a></li><li>下载带 <strong>LTS</strong> 标记的版本（LTS &#x3D; 长期支持版，更稳）</li><li>双击安装包，一路下一步</li></ol><p>装完之后验证一下。打开终端（Windows 搜”PowerShell”，macOS 按 <code>Cmd+Space</code> 搜”终端”），输入：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">node -v<br></code></pre></td></tr></table></figure><p>如果看到类似 <code>v20.11.0</code> 这样的版本号，就装好了。看到”命令找不到”之类的错误，把电脑重启一下再试。</p><h2 id="第二步：装-Git"><a href="#第二步：装-Git" class="headerlink" title="第二步：装 Git"></a>第二步：装 Git</h2><p>Git 是帮你把文件传到 GitHub 的工具。</p><ul><li><strong>Windows</strong>：去 <a href="https://git-scm.com/">git-scm.com</a> 下载安装，安装时一路默认即可</li><li><strong>macOS</strong>：打开终端输入 <code>git --version</code>，会自动提示安装</li></ul><p>验证：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">git --version<br></code></pre></td></tr></table></figure><p>看到版本号就行。</p><h2 id="第三步：注册-GitHub-账号"><a href="#第三步：注册-GitHub-账号" class="headerlink" title="第三步：注册 GitHub 账号"></a>第三步：注册 GitHub 账号</h2><p>浏览器打开 <a href="https://github.com/">github.com</a>，注册一个账号。</p><p><strong>用户名要想好</strong>，因为它会出现在你博客的网址里。我的用户名是 <code>7788dev</code>，所以我的博客网址就是 <code>https://7788dev.github.io</code>。建议用英文小写，不要用特殊字符。</p><p>注册完之后，新建一个仓库（Repository）：</p><ol><li>右上角点 <strong>+</strong> → <strong>New repository</strong></li><li>仓库名字<strong>必须是</strong>：<code>你的用户名.github.io</code>（比如我的就是 <code>7788dev.github.io</code>）</li><li>选 Public（公开）</li><li>不用勾 README</li><li>点 <strong>Create repository</strong></li></ol><p>这一步完成之后先放着，后面会用。</p><h2 id="第四步：装-Hexo"><a href="#第四步：装-Hexo" class="headerlink" title="第四步：装 Hexo"></a>第四步：装 Hexo</h2><p>回到终端，输入：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">npm install -g hexo-cli<br></code></pre></td></tr></table></figure><p><code>-g</code> 的意思是”装到全局”，这样任何地方都能用 <code>hexo</code> 这个命令。</p><p>验证：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">hexo -v<br></code></pre></td></tr></table></figure><p>看到一堆版本信息就是装好了。</p><blockquote><p>如果卡住不动，可能是 npm 默认的源在国外太慢。换个国内镜像再试：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">npm config <span class="hljs-built_in">set</span> registry https://registry.npmmirror.com<br></code></pre></td></tr></table></figure></blockquote><h2 id="第五步：初始化你的博客"><a href="#第五步：初始化你的博客" class="headerlink" title="第五步：初始化你的博客"></a>第五步：初始化你的博客</h2><p>在电脑上找一个你想放博客的文件夹，比如桌面。在那个文件夹<strong>右键 → 在终端中打开</strong>（或者 cd 过去），然后：</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></pre></td><td class="code"><pre><code class="hljs bash">hexo init blog<br><span class="hljs-built_in">cd</span> blog<br>npm install<br></code></pre></td></tr></table></figure><p>解释一下每条命令：</p><ul><li><code>hexo init blog</code>：在当前位置创建一个叫 <code>blog</code> 的文件夹，把博客的初始文件都放里面</li><li><code>cd blog</code>：进入这个文件夹</li><li><code>npm install</code>：把博客需要的依赖装上（这一步会下载很多东西，慢一点正常）</li></ul><p>装完之后，跑一下本地预览：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">hexo server<br></code></pre></td></tr></table></figure><p>看到：</p><figure class="highlight xl"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs xl">INFO  Hexo <span class="hljs-keyword">is</span> running <span class="hljs-built_in">at</span> http:<span class="hljs-comment">//localhost:4000/</span><br></code></pre></td></tr></table></figure><p><strong>浏览器打开 <a href="http://localhost:4000/">http://localhost:4000</a></strong> —— 恭喜，你已经有一个本地博客了。</p><p>想停掉服务器，回到终端按 <code>Ctrl+C</code>。</p><h2 id="第六步：写第一篇文章"><a href="#第六步：写第一篇文章" class="headerlink" title="第六步：写第一篇文章"></a>第六步：写第一篇文章</h2><p>新开一个终端（保持预览服务器不关），在 <code>blog</code> 文件夹里：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">hexo new <span class="hljs-string">&quot;我的第一篇文章&quot;</span><br></code></pre></td></tr></table></figure><p>它会告诉你一个路径，类似 <code>source/_posts/我的第一篇文章.md</code>。用 VS Code 或任何文本编辑器打开这个文件，你会看到：</p><figure class="highlight markdown"><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><code class="hljs markdown">---<br>title: 我的第一篇文章<br>date: 2026-05-10 15:00:00<br><span class="hljs-section">tags:</span><br><span class="hljs-section">---</span><br></code></pre></td></tr></table></figure><p>上面这几行叫 <strong>front-matter</strong>，是文章的元信息。下面就可以开写了：</p><figure class="highlight markdown"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><code class="hljs markdown">---<br>title: 我的第一篇文章<br>date: 2026-05-10 15:00:00<br>tags:<br><span class="hljs-bullet">  -</span> 日常<br>categories:<br><span class="hljs-section">  - 随笔</span><br><span class="hljs-section">---</span><br><br>大家好，这是我的第一篇博客。<br><br><span class="hljs-section">## 我在干嘛</span><br><br>搭博客。<br><br><span class="hljs-section">## 我为什么要搭博客</span><br><br>因为酷。<br></code></pre></td></tr></table></figure><p>保存之后，回到浏览器刷新 <a href="http://localhost:4000，新文章就出现了。">http://localhost:4000，新文章就出现了。</a></p><p><strong>这就是日常写作的流程</strong>：</p><ol><li><code>hexo new &quot;文章标题&quot;</code> 生成文件</li><li>用编辑器改 markdown</li><li>浏览器刷新看效果</li></ol><h2 id="第七步：换个好看的主题"><a href="#第七步：换个好看的主题" class="headerlink" title="第七步：换个好看的主题"></a>第七步：换个好看的主题</h2><p>默认主题叫 Landscape，能用但不算好看。我推荐 <strong>Fluid</strong>（就是本站用的这个）。</p><p>在 <code>blog</code> 文件夹下，终端输入：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">npm install --save hexo-theme-fluid<br></code></pre></td></tr></table></figure><p>然后找到 <code>blog</code> 文件夹里的 <code>_config.yml</code> 文件，打开，找到这一行：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">theme:</span> <span class="hljs-string">landscape</span><br></code></pre></td></tr></table></figure><p>改成：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">theme:</span> <span class="hljs-string">fluid</span><br></code></pre></td></tr></table></figure><p>再新建一个文件叫 <code>_config.fluid.yml</code>（和 <code>_config.yml</code> 放同一层），这是 Fluid 主题的配置。先放一个最小内容：</p><figure class="highlight yaml"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-comment"># 站点标题</span><br><span class="hljs-attr">navbar:</span><br>  <span class="hljs-attr">blog_title:</span> <span class="hljs-string">我的博客</span><br><br><span class="hljs-comment"># 关于页</span><br><span class="hljs-attr">about:</span><br>  <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span><br>  <span class="hljs-attr">name:</span> <span class="hljs-string">你的名字</span><br>  <span class="hljs-attr">intro:</span> <span class="hljs-string">一句简短的自我介绍</span><br><br><span class="hljs-comment"># 导航菜单</span><br><span class="hljs-attr">navbar:</span><br>  <span class="hljs-attr">menu:</span><br>    <span class="hljs-bullet">-</span> &#123; <span class="hljs-attr">key:</span> <span class="hljs-string">&quot;home&quot;</span>, <span class="hljs-attr">link:</span> <span class="hljs-string">&quot;/&quot;</span>, <span class="hljs-attr">icon:</span> <span class="hljs-string">&quot;iconfont icon-home-fill&quot;</span> &#125;<br>    <span class="hljs-bullet">-</span> &#123; <span class="hljs-attr">key:</span> <span class="hljs-string">&quot;archive&quot;</span>, <span class="hljs-attr">link:</span> <span class="hljs-string">&quot;/archives/&quot;</span>, <span class="hljs-attr">icon:</span> <span class="hljs-string">&quot;iconfont icon-archive-fill&quot;</span> &#125;<br>    <span class="hljs-bullet">-</span> &#123; <span class="hljs-attr">key:</span> <span class="hljs-string">&quot;about&quot;</span>, <span class="hljs-attr">link:</span> <span class="hljs-string">&quot;/about/&quot;</span>, <span class="hljs-attr">icon:</span> <span class="hljs-string">&quot;iconfont icon-user-fill&quot;</span> &#125;<br></code></pre></td></tr></table></figure><p>停掉正在跑的 <code>hexo server</code>（按 <code>Ctrl+C</code>），重新跑：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">hexo server<br></code></pre></td></tr></table></figure><p>刷新浏览器，主题就换了。想调细节比如颜色、字体、动画，去看 <a href="https://hexo.fluid-dev.com/docs/">Fluid 官方文档</a>，它写得很详细。</p><h2 id="第八步：加一个”关于”页面"><a href="#第八步：加一个”关于”页面" class="headerlink" title="第八步：加一个”关于”页面"></a>第八步：加一个”关于”页面</h2><p>主题换好了，但点导航栏的”关于”会 404，因为我们还没有这个页面。终端里：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">hexo new page about<br></code></pre></td></tr></table></figure><p>它会生成 <code>source/about/index.md</code>。打开改成：</p><figure class="highlight markdown"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs markdown">---<br>title: 关于<br>layout: about<br><span class="hljs-section">date: 2026-05-10 15:00:00</span><br><span class="hljs-section">---</span><br><br><span class="hljs-section">## 你好</span><br><br>我是 XXX，一个写代码的人（或者你是啥都行）。<br><br>这里是我的博客，记录一些想法和笔记。<br></code></pre></td></tr></table></figure><p>刷新浏览器，”关于”页就出来了。</p><h2 id="第九步：上传到-GitHub"><a href="#第九步：上传到-GitHub" class="headerlink" title="第九步：上传到 GitHub"></a>第九步：上传到 GitHub</h2><p>本地能看了，但只有你自己能看。要让所有人都能访问，得把它推到 GitHub Pages。</p><h3 id="9-1-装部署插件"><a href="#9-1-装部署插件" class="headerlink" title="9.1 装部署插件"></a>9.1 装部署插件</h3><p>终端在 <code>blog</code> 文件夹下：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">npm install --save hexo-deployer-git<br></code></pre></td></tr></table></figure><h3 id="9-2-配置部署地址"><a href="#9-2-配置部署地址" class="headerlink" title="9.2 配置部署地址"></a>9.2 配置部署地址</h3><p>打开 <code>_config.yml</code>，找到文件底部的 <code>deploy</code>，改成：</p><figure class="highlight yaml"><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><code class="hljs yaml"><span class="hljs-attr">deploy:</span><br>  <span class="hljs-attr">type:</span> <span class="hljs-string">git</span><br>  <span class="hljs-attr">repo:</span> <span class="hljs-string">https://github.com/你的用户名/你的用户名.github.io.git</span><br>  <span class="hljs-attr">branch:</span> <span class="hljs-string">gh-pages</span><br></code></pre></td></tr></table></figure><p>把”你的用户名”替换成你的 GitHub 用户名。</p><p>还有一个特别容易漏的地方，找到 <code>url</code>：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">url:</span> <span class="hljs-string">https://你的用户名.github.io</span><br></code></pre></td></tr></table></figure><p>这行也要改成你的地址。</p><h3 id="9-3-部署"><a href="#9-3-部署" class="headerlink" title="9.3 部署"></a>9.3 部署</h3><p>三条命令一起走：</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></pre></td><td class="code"><pre><code class="hljs bash">hexo clean<br>hexo generate<br>hexo deploy<br></code></pre></td></tr></table></figure><ul><li><code>hexo clean</code>：清掉上一次的构建结果</li><li><code>hexo generate</code>：把你的 markdown 变成真正的网页（会生成在 <code>public/</code> 文件夹）</li><li><code>hexo deploy</code>：把 <code>public/</code> 里的东西推到 GitHub</li></ul><p>第一次会让你输入 GitHub 的用户名和密码（或者 Token，下面讲）。</p><h3 id="9-4-GitHub-密码的坑"><a href="#9-4-GitHub-密码的坑" class="headerlink" title="9.4 GitHub 密码的坑"></a>9.4 GitHub 密码的坑</h3><p>GitHub 从 2021 年起不让用密码推代码了，要用 <strong>Personal Access Token</strong>。怎么生成：</p><ol><li>GitHub 右上角头像 → <strong>Settings</strong></li><li>左侧拉到底 → <strong>Developer settings</strong></li><li><strong>Personal access tokens</strong> → <strong>Tokens (classic)</strong> → <strong>Generate new token (classic)</strong></li><li>Note 随便写，Expiration 选 “No expiration”</li><li>勾 <strong>repo</strong> 这一整块</li><li>点 Generate，<strong>立刻复制</strong>那一串以 <code>ghp_</code> 开头的字符（关了就看不到了）</li></ol><p>部署时需要输入密码的时候，把这串 Token 粘上去就行。</p><p>更方便的做法是让 Git 记住它，一次性配置：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">git config --global credential.helper store<br></code></pre></td></tr></table></figure><p>然后做一次成功的 push，以后就不用再输了。</p><h3 id="9-5-开启-GitHub-Pages"><a href="#9-5-开启-GitHub-Pages" class="headerlink" title="9.5 开启 GitHub Pages"></a>9.5 开启 GitHub Pages</h3><p>推完之后去 GitHub 那个仓库：</p><ol><li>点 <strong>Settings</strong> 标签页</li><li>左侧 <strong>Pages</strong></li><li><strong>Source</strong> 选 <strong>Deploy from a branch</strong></li><li><strong>Branch</strong> 选 <strong>gh-pages</strong>，文件夹选 <code>/ (root)</code></li><li>保存</li></ol><p><strong>等 1~3 分钟</strong>，浏览器打开 <code>https://你的用户名.github.io</code>，你的博客上线了。</p><h2 id="第十步：写作的日常流程"><a href="#第十步：写作的日常流程" class="headerlink" title="第十步：写作的日常流程"></a>第十步：写作的日常流程</h2><p>从此之后，写一篇新文章的完整流程只需要这五条命令：</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><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 新建文章</span><br>hexo new <span class="hljs-string">&quot;今天学了啥&quot;</span><br><br><span class="hljs-comment"># 2. 用编辑器改 source/_posts/今天学了啥.md</span><br><br><span class="hljs-comment"># 3. 本地预览（可选，想看效果就跑）</span><br>hexo server<br><br><span class="hljs-comment"># 4. 发布</span><br>hexo clean<br>hexo generate<br>hexo deploy<br></code></pre></td></tr></table></figure><p>第 4 步的三条命令可以合成一条，在 <code>package.json</code> 的 <code>scripts</code> 里加一行：</p><figure class="highlight json"><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><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;scripts&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>    <span class="hljs-attr">&quot;publish&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;hexo clean &amp;&amp; hexo generate &amp;&amp; hexo deploy&quot;</span><br>  <span class="hljs-punctuation">&#125;</span><br><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><p>以后发文章就是：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">npm run publish<br></code></pre></td></tr></table></figure><h2 id="常见问题"><a href="#常见问题" class="headerlink" title="常见问题"></a>常见问题</h2><p><strong>Q：改了主题配置，但页面没变化？</strong><br>A：停掉 <code>hexo server</code>（<code>Ctrl+C</code>），重新跑一次。主题配置不会热更新。</p><p><strong>Q：中文标题的文章，URL 有一堆 <code>%e6%80%9d%e8%80%83</code> 怎么办？</strong><br>A：文件名改成英文。比如文件叫 <code>hello-world.md</code>，front-matter 里的 <code>title:</code> 写”你好世界”也没关系，URL 会用文件名。</p><p><strong>Q：部署完访问 404？</strong><br>A：三个可能：</p><ul><li>等 3 分钟再试（GitHub Pages 有缓存）</li><li>检查 <code>_config.yml</code> 里 <code>url</code> 是不是改对了</li><li>去 GitHub 仓库的 <strong>Actions</strong> 标签页看部署有没有报错</li></ul><p><strong>Q：想改文章里的图片，怎么放图？</strong><br>A：把图片放到 <code>source/img/</code> 文件夹，文章里用 <code>![](/img/你的图片.png)</code> 引用。</p><p><strong>Q：怎么加评论？</strong><br>A：推荐用 <a href="https://giscus.app/zh-CN">giscus</a>，它用 GitHub Discussions 存评论，不用买服务器。在 Fluid 主题里配置很简单，跟着官网向导走就行。</p><p><strong>Q：怎么绑定自己的域名？</strong><br>A：买一个域名，在 <code>source/</code> 文件夹里新建一个叫 <code>CNAME</code> 的文件（没有后缀名），文件内容只写你的域名，比如 <code>myblog.com</code>。然后去你买域名的地方把 DNS 指向 <code>你的用户名.github.io</code>。等一会 GitHub 那边 Pages 设置里会出现 “Enforce HTTPS” 勾上就好。</p><h2 id="下一步"><a href="#下一步" class="headerlink" title="下一步"></a>下一步</h2><p>基础博客已经搭完了。想再进一步，可以试试：</p><ul><li><strong>调 Fluid 主题的颜色和字体</strong>：<a href="https://hexo.fluid-dev.com/docs/guide/#%E4%B8%BB%E9%A2%98%E9%A2%9C%E8%89%B2">Fluid 文档的主题配色</a></li><li><strong>加访问统计</strong>：<a href="https://busuanzi.ibruce.info/">不蒜子</a>，零配置</li><li><strong>加搜索</strong>：Fluid 内置 local-search，在 <code>_config.fluid.yml</code> 里一行配置就能开</li><li><strong>备份你的博客源文件</strong>：把整个 <code>blog</code> 文件夹也推到 GitHub（新建一个仓库，和那个 <code>.github.io</code> 仓库是两个）。这样换电脑也不会丢文章</li></ul><p>想看更深入的玩法（比如用 Hexo 自定义脚本做数据归档、嵌入音乐墙这种），可以看我另一篇偏硬核的<a href="/">《本站是怎么魔改出来的》</a>（之后会写）。</p><hr><p><em>搭博客花的时间往往比写博客多。但每一行属于你自己的 CSS、每一个你自己加的页面，都是你能在任何地方炫的”我做的”。慢慢玩，别赶。</em></p>]]>
    </content>
    <id>https://7788dev.github.io/2026/05/10/build-blog-with-hexo/</id>
    <link href="https://7788dev.github.io/2026/05/10/build-blog-with-hexo/"/>
    <published>2026-05-09T18:30:00.000Z</published>
    <summary>从零开始、手把手教你搭一个和本站一样的博客。不需要会写代码，跟着一步步敲命令就能上线。涵盖环境安装、Hexo 初始化、Fluid 主题、写第一篇文章、免费部署到 GitHub Pages。读完大约 30 分钟，照做大约 1 小时就有自己的博客。</summary>
    <title>用 Hexo 搭建属于自己的 Blog</title>
    <updated>2026-05-10T09:13:41.416Z</updated>
  </entry>
  <entry>
    <author>
      <name>Looks</name>
    </author>
    <category term="思考" scheme="https://7788dev.github.io/categories/%E6%80%9D%E8%80%83/"/>
    <category term="思考" scheme="https://7788dev.github.io/tags/%E6%80%9D%E8%80%83/"/>
    <category term="生活" scheme="https://7788dev.github.io/tags/%E7%94%9F%E6%B4%BB/"/>
    <category term="自我观察" scheme="https://7788dev.github.io/tags/%E8%87%AA%E6%88%91%E8%A7%82%E5%AF%9F/"/>
    <content>
      <![CDATA[<p>凌晨一点写这个标题的时候，我已经在床上躺了四十分钟，手机亮了十二次。我知道它亮了十二次，是因为我每次都数了。</p><p>我已经很久没有在晚上十二点之前睡着过了。不是偶尔，是几乎每天。</p><h2 id="先排除”是不是病了”"><a href="#先排除”是不是病了”" class="headerlink" title="先排除”是不是病了”"></a>先排除”是不是病了”</h2><p>这是最省事的假设，也最不情愿承认。</p><p>如果是生理性的——比如褪黑素分泌紊乱、甲状腺亢进、咖啡因半衰期太长——那它至少是一个技术问题，技术问题有技术解法：抽血、验指标、吃药、调光。心里会踏实一点，因为”不是我的错，是我的身体出了问题”。</p><p>但这个解释不太站得住。</p><ul><li>白天不困。真的不困，不是硬撑的那种不困。</li><li>早睡的那几晚（出差倒时差、发烧），照样能十一点睡着，说明生理机器本身没坏。</li><li>咖啡戒了两周，没有任何变化。</li><li>拉窗帘、戴眼罩、开白噪音、物理静音手机——做过。有用大概两天，然后复发。</li></ul><p>所以它不太像一个身体开关卡住了的故事。它更像是——<strong>我每天都在选择不去睡</strong>。而”选择”这个词一旦出现，就绕不开下一个问题。</p><h2 id="在逃避什么"><a href="#在逃避什么" class="headerlink" title="在逃避什么"></a>在逃避什么</h2><p>英文里有个挺准的词：<strong>Revenge Bedtime Procrastination</strong>，直译是”报复性睡眠拖延”。韩语里也有个类似的说法。核心意思是：一个人在白天里没有真正属于自己的时间，于是在夜里用不睡觉的方式把那段时间”夺回来”。</p><p>这个解释让我有点坐立不安，因为它太合身了。</p><p>白天里我在处理别人推过来的事情——工单、会议、群消息、文档评论。那些事情没有一件是”我想做的”，它们只是”需要被做的”。等到十一点钟一切都停下来，我才第一次在这一天里听到一点只属于自己的声音。然后我舍不得关灯。</p><p>我舍不得关灯不是因为有什么非做不可的事。我是真的在浪费时间——刷短视频、看没什么营养的贴吧、重看一集已经看过的剧、在购物车里反复添加又删除。我清楚地知道这些活动没什么价值，但它们有一个共同的属性：<strong>它们不要求我为明天做准备</strong>。</p><p>一睡着，明天就开始了。一睁眼就是下一个工单、下一场会议、下一次”请尽快回复”。夜里不睡，等于把明天无限期地推后。</p><h2 id="熬夜的不是身体，是对”结束今天”的抗拒"><a href="#熬夜的不是身体，是对”结束今天”的抗拒" class="headerlink" title="熬夜的不是身体，是对”结束今天”的抗拒"></a>熬夜的不是身体，是对”结束今天”的抗拒</h2><p>我慢慢看清了一件事：熬夜对我来说不是生理行为，是心理行为。</p><p>睡觉这个动作本身不费力，费力的是承认”今天就这样了”。今天该做没做的，今天想说没说的，今天答应自己的——到了要关灯的那一刻都得按原样打包存档。第二天再想打开，就是另一天的事情了。</p><p>而我显然不太愿意结算今天。一天的账目对不上，我就把结算时间往后推，推到两点、三点、四点。推到身体实在撑不住，才勉强承认这一天结束了。</p><p><strong>推迟入睡，就是推迟承认今天不如我想象的那样过。</strong></p><h2 id="那么，是病吗"><a href="#那么，是病吗" class="headerlink" title="那么，是病吗"></a>那么，是病吗</h2><p>把这两件事合在一起看，我倾向于这样描述：</p><ul><li>它<strong>不是一个纯粹的生理疾病</strong>。如果让我做多导睡眠监测，大概率指标正常。</li><li>它<strong>也不是简单的”意志力不够”</strong>。我确实每晚都在”再看五分钟”和”现在就关灯”之间失败。但这种失败每天都发生，频率太高，不像是意志力问题，更像是某种<strong>系统失衡的外显</strong>。</li><li>它更像一个<strong>信号</strong>：白天被挤压得太厉害了，夜晚在以一种低效、伤身、但确实有效的方式代偿。像发烧——发烧不是病本身，是身体告诉你”这里出问题了”。</li></ul><p>从这个角度看，熬夜是一个<strong>诚实的指标</strong>。它在持续告诉我：你对现在的白天不满意。</p><h2 id="我能做点什么"><a href="#我能做点什么" class="headerlink" title="我能做点什么"></a>我能做点什么</h2><p>我不太信”把手机放到客厅”这种技巧性建议。不是说它没用，是它解决的是<strong>症状</strong>，没解决<strong>原因</strong>。一个白天过得让自己憋屈的人，把手机锁到保险柜里，也会在床上睁眼到三点。</p><p>我能想到的、真的可能有效的事，大概是这些：</p><ul><li><strong>每天留一段白天的”自己的时间”</strong>。不是挤出来的那种，是预先占掉的。哪怕只有四十分钟。让白天不再是完全的代管状态，夜里就不需要那么激烈地补偿。</li><li><strong>把”今天”的范围缩小</strong>。不再指望一天做完三件大事。做完一件小的，就承认这一天有过价值。降低对”今天”的预期，就不那么不愿意结束它。</li><li><strong>区分累和困</strong>。累是生理的，困是神经的。我经常把”累”当成”该去睡”，结果躺下以后神经还在高速运转。累就先停下来，不等于去睡觉。</li><li><strong>写一句话收尾</strong>。睡前在手机备忘录里写一行字，任意内容，比如”今天和 A 吵架了我没错”、”明天记得交电费”、”我不想上班”。写了就关灯。这个动作对我比任何技巧都管用——它像一个按钮，告诉大脑”账记上了，可以歇了”。</li></ul><p>这些事情我还没都做到。前两条其实是结构性的，改起来远比记一行字难。但至少可以先从第四条开始。</p><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>如果让我现在就给标题一个答案，我大概会这样写：</p><blockquote><p>不是病。但它确实在告诉我，有些东西被我搁置得太久了。</p></blockquote><p>凌晨一点四十六分，我要去写那句话，然后关灯了。</p><p>（如果你看到这篇文章的时间戳也是两点之后，那我们可能需要互相道个晚安。）</p>]]>
    </content>
    <id>https://7788dev.github.io/2026/05/10/why-cant-sleep-early/</id>
    <link href="https://7788dev.github.io/2026/05/10/why-cant-sleep-early/"/>
    <published>2026-05-09T17:00:00.000Z</published>
    <summary>已经连着几个月凌晨两点之后才睡。写给自己看的一篇随笔，尝试把&quot;熬夜&quot;这个症状拆开——到底哪部分是身体的问题，哪部分是心理上的不愿意结束今天。</summary>
    <title>好久没有早睡了，可能是我病了，还是我在逃避什么</title>
    <updated>2026-05-10T09:13:49.867Z</updated>
  </entry>
  <entry>
    <author>
      <name>Looks</name>
    </author>
    <category term="思考" scheme="https://7788dev.github.io/categories/%E6%80%9D%E8%80%83/"/>
    <category term="社会观察" scheme="https://7788dev.github.io/tags/%E7%A4%BE%E4%BC%9A%E8%A7%82%E5%AF%9F/"/>
    <category term="科技" scheme="https://7788dev.github.io/tags/%E7%A7%91%E6%8A%80/"/>
    <category term="思考" scheme="https://7788dev.github.io/tags/%E6%80%9D%E8%80%83/"/>
    <content>
      <![CDATA[<p>这个问题不是我的原创。它在英文学术界被讨论了至少二十年，在中文舆论场也断断续续地被提过很多次。我只是一个技术背景的读者，这几年零散读了一些相关的书和论文，想把它们在自己脑子里的排列方式写下来。下面所有非常识性的判断都挂了出处，有兴趣可以顺着往下挖。</p><h2 id="一、先把”压抑”和”内向”分开"><a href="#一、先把”压抑”和”内向”分开" class="headerlink" title="一、先把”压抑”和”内向”分开"></a>一、先把”压抑”和”内向”分开</h2><p>这两个词经常被混用，但它们不是一回事。</p><p>“内向”是气质层面的——Jung 提出的人格维度，指一个人在安静里充电、在人群里耗电。这东西有相当大的遗传成分，和科技无关。</p><p>“社会性压抑”指的是另一种状态：一个人<strong>想连接而不能</strong>，在群体中感到紧绷、在表达前要反复掂量、在公共场合本能地防御。Sherry Turkle 在《Alone Together》<sup id="fnref:1" class="footnote-ref"><a href="#fn:1" rel="footnote"><span class="hint--top hint--rounded" aria-label="Turkle, S. (2011). *Alone Together: Why We Expect More from Technology and Less from Each Other*. Basic Books.">[1]</span></a></sup> 和《Reclaiming Conversation》<sup id="fnref:2" class="footnote-ref"><a href="#fn:2" rel="footnote"><span class="hint--top hint--rounded" aria-label="Turkle, S. (2015). *Reclaiming Conversation: The Power of Talk in a Digital Age*. Penguin Press.">[2]</span></a></sup> 里连着用两本书讨论这件事，她的核心观察是：我们不是变得更不需要别人，而是失去了和别人相处所需要的”慢”。这是两种完全不一样的状态。本文谈的都是后者。</p><h2 id="二、有没有可证伪的证据？"><a href="#二、有没有可证伪的证据？" class="headerlink" title="二、有没有可证伪的证据？"></a>二、有没有可证伪的证据？</h2><p>这类大议题最容易写成空洞的文化评论，所以要先看看有没有硬一点的经验研究。</p><p><strong>Jean Twenge 及其团队</strong> 在《iGen》<sup id="fnref:3" class="footnote-ref"><a href="#fn:3" rel="footnote"><span class="hint--top hint--rounded" aria-label="Twenge, J. M. (2017). *iGen: Why Today&#39;s Super-Connected Kids Are Growing Up Less Rebellious, More Tolerant, Less Happy*. Atria Books.">[3]</span></a></sup> 以及发表在 <em>Clinical Psychological Science</em> 上的一系列论文里，比对了美国大规模青少年心理健康调查（Monitoring the Future、YRBSS 等）前后十几年的数据，发现 2012 年前后是一个明显的拐点：重度孤独、抑郁症状、睡眠问题的自陈率同时抬头，而同一时期美国青少年智能手机持有率首次超过 50%。她后来在《The Atlantic》上那篇《Have Smartphones Destroyed a Generation?》<sup id="fnref:4" class="footnote-ref"><a href="#fn:4" rel="footnote"><span class="hint--top hint--rounded" aria-label="Twenge, J. M. (2017). &quot;Have Smartphones Destroyed a Generation?&quot; *The Atlantic*, September 2017.">[4]</span></a></sup> 是这组研究的通俗版。</p><p><strong>Jonathan Haidt</strong> 2024 年的《The Anxious Generation》<sup id="fnref:5" class="footnote-ref"><a href="#fn:5" rel="footnote"><span class="hint--top hint--rounded" aria-label="Haidt, J. (2024). *The Anxious Generation: How the Great Rewiring of Childhood Is Causing an Epidemic of Mental Illness*. Penguin Press.">[5]</span></a></sup> 把这个话题推到了英美主流讨论里。他提出”童年大重构”（Great Rewiring）假说：2010–2015 年前后，基于移动互联网 + 前摄像头 + 算法推荐的社交媒体进入青少年生活，从而改变了他们经历青春期的底层环境。Haidt 和 Twenge 的观点受到过严肃反驳，比如 Candice Odgers 在 <em>Nature</em> 上评论他们”把相关当成了因果”<sup id="fnref:6" class="footnote-ref"><a href="#fn:6" rel="footnote"><span class="hint--top hint--rounded" aria-label="Odgers, C. L. (2024). &quot;The great rewiring: is social media really behind an epidemic of teenage mental illness?&quot; *Nature*, 628, 29–30.">[6]</span></a></sup>，但至少在”数据在变”这一点上，双方没有分歧。</p><p><strong>Melissa Hunt 等（宾夕法尼亚大学）</strong> 在 <em>Journal of Social and Clinical Psychology</em> 2018 年的一项干预研究 <sup id="fnref:7" class="footnote-ref"><a href="#fn:7" rel="footnote"><span class="hint--top hint--rounded" aria-label="Hunt, M. G., Marx, R., Lipson, C., &amp; Young, J. (2018). &quot;No More FOMO: Limiting Social Media Decreases Loneliness and Depression.&quot; *Journal of Social and Clinical Psychology*, 37(10), 751–768.">[7]</span></a></sup> 里，要求实验组把 Facebook、Instagram、Snapchat 的每日使用限制在 10 分钟，为期三周。结果是实验组的抑郁和孤独自评显著下降。这是该领域为数不多的真·实验研究（不是纯观察性数据），量小但方向清晰。</p><p>这些研究都指向同一个方向：<strong>屏幕时长和心理体验之间存在可测的相关性</strong>。至于它到底是”让情况变差”还是”让已经变差的人使用更多”，学界没有共识。</p><h2 id="三、概念一：注意力经济"><a href="#三、概念一：注意力经济" class="headerlink" title="三、概念一：注意力经济"></a>三、概念一：注意力经济</h2><p>把这件事从心理学往上提一层，就绕不开”注意力经济”。</p><p>这个词起源于 Herbert Simon 1971 年的一句话：在信息丰裕的世界里，信息消耗的是注意力，因此稀缺的是注意力，而不是信息 <sup id="fnref:8" class="footnote-ref"><a href="#fn:8" rel="footnote"><span class="hint--top hint--rounded" aria-label="Simon, H. A. (1971). &quot;Designing Organizations for an Information-Rich World.&quot; In Greenberger, M. (ed.), *Computers, Communication, and the Public Interest*, Johns Hopkins Press, 40–41.">[8]</span></a></sup>。Tim Wu 在《The Attention Merchants》<sup id="fnref:9" class="footnote-ref"><a href="#fn:9" rel="footnote"><span class="hint--top hint--rounded" aria-label="Wu, T. (2016). *The Attention Merchants: The Epic Scramble to Get Inside Our Heads*. Knopf.">[9]</span></a></sup> 里把这个逻辑一路追溯到 19 世纪的廉价小报，再到广播、电视、社交平台，论点是：任何免费媒介最终都会把”获取用户注意力”变成它的核心生意。</p><p>这一生意的技术化是在硅谷完成的。斯坦福的 BJ Fogg 搭了行为设计实验室，Nir Eyal 在《Hooked》<sup id="fnref:10" class="footnote-ref"><a href="#fn:10" rel="footnote"><span class="hint--top hint--rounded" aria-label="Eyal, N. (2014). *Hooked: How to Build Habit-Forming Products*. Portfolio.">[10]</span></a></sup> 里把这套方法写成可操作的产品模型（Trigger → Action → Reward → Investment）。Tristan Harris（前 Google 设计伦理师）后来在 Center for Humane Technology 的演讲里反复提到：这套方法学的目的不是”给你价值”，而是”让你下次再来”<sup id="fnref:11" class="footnote-ref"><a href="#fn:11" rel="footnote"><span class="hint--top hint--rounded" aria-label="Harris, T. Center for Humane Technology, 公开演讲与文章集合，见 humanetech.com。">[11]</span></a></sup>。</p><p>注意力被产品化之后的代价，Nicholas Carr 在《The Shallows》<sup id="fnref:12" class="footnote-ref"><a href="#fn:12" rel="footnote"><span class="hint--top hint--rounded" aria-label="Carr, N. (2010). *The Shallows: What the Internet Is Doing to Our Brains*. W. W. Norton.">[12]</span></a></sup> 里做过神经认知层面的描述：长期浸泡在碎片化、刺激密集的信息流里，大脑会在神经可塑性意义上重塑自己，变得更擅长浅层扫描、不擅长长时段集中。这部分有严肃的神经科学争论，但至少主观体验上和很多人的自述是吻合的。</p><p>对”社会性”的影响因此可以这样表述：当一个人每天处理几千条被动推送后，他对”无刺激时刻”的耐受度会显著下降。那些本应该是人际化学反应温床的日常空白（排队、通勤、等菜上桌），被屏幕填满了。社交能力和肌肉一样，用进废退。</p><h2 id="四、概念二：原子化与社会资本的衰退"><a href="#四、概念二：原子化与社会资本的衰退" class="headerlink" title="四、概念二：原子化与社会资本的衰退"></a>四、概念二：原子化与社会资本的衰退</h2><p>第二个维度是社会结构层面的。</p><p>Robert Putnam 在《Bowling Alone》<sup id="fnref:13" class="footnote-ref"><a href="#fn:13" rel="footnote"><span class="hint--top hint--rounded" aria-label="Putnam, R. D. (2000). *Bowling Alone: The Collapse and Revival of American Community*. Simon &amp; Schuster.">[13]</span></a></sup> 里提出了一个经典指标：美国人参加教会、工会、业余俱乐部、保龄球联赛这些”第三场所”活动的频率，从 1960 年代开始持续下跌。他把这种下跌对应到一个概念——<strong>社会资本</strong>（social capital）：那些把陌生人编织进共同体的弱关系网络。Putnam 的主要解释变量里，电视排在首位，互联网当时还没起来。把他的框架套到今天，社交媒体是下一层更彻底的替代品。</p><p>在中文语境里，更适合引用的是 <strong>项飙</strong> 关于”<strong>附近的消失</strong>“的提法。他在《把自己作为方法》<sup id="fnref:14" class="footnote-ref"><a href="#fn:14" rel="footnote"><span class="hint--top hint--rounded" aria-label="项飙, 吴琦 (2020). 《把自己作为方法：与项飙谈话》. 上海文艺出版社. 其中&quot;附近的消失&quot;为项飙近年多次访谈中的核心提法。">[14]</span></a></sup> 里提到：外卖、网约车、算法推荐这些基础设施把人对”附近”（小区里的小饭馆、楼下理发店老板、同一趟公交上的乘客）的感知系统性地削弱了。人们越来越直接地跳到一个抽象的”世界”——我关注的是纽约的新闻、硅谷的趋势、B 站的 UP 主——却对脚下三公里内正在发生什么一无所知。原子化在他这里不是一个结果，而是一套基础设施施加的持续过程。</p><p>Zygmunt Bauman 的《Liquid Modernity》<sup id="fnref:15" class="footnote-ref"><a href="#fn:15" rel="footnote"><span class="hint--top hint--rounded" aria-label="Bauman, Z. (2000). *Liquid Modernity*. Polity Press.">[15]</span></a></sup> 提供了一个更抽象的理论外壳：后现代社会的纽带都是液态的，婚姻、工作、邻里都处于一种随时可以”解约”的状态，于是个体承担的选择成本和焦虑都前所未有地高。Bauman 本人对科技着墨不多，但他描述的那种状态，和上一节里的注意力经济结合起来，就是我们今天多数人熟悉的日常。</p><h2 id="五、所以科技是原因吗"><a href="#五、所以科技是原因吗" class="headerlink" title="五、所以科技是原因吗"></a>五、所以科技是原因吗</h2><p>不是。但它是一个加速器。</p><p><strong>科技本身是中性的，它放大了人性里本来就脆弱的部分。</strong></p><p>我们本来就有社交焦虑、表达恐惧、对确定性的贪恋。在前互联网时代，这些脆弱被日常必要的物理摩擦稀释掉了——你必须跟邻居打招呼，你必须跟售货员聊两句，你必须走到朋友家门口。现在这些摩擦大部分都被消除了。好处是效率提升，坏处是那些能顺带锻炼社交能力、顺带稀释焦虑的机会一并没了。</p><p>Shoshana Zuboff 在《The Age of Surveillance Capitalism》<sup id="fnref:16" class="footnote-ref"><a href="#fn:16" rel="footnote"><span class="hint--top hint--rounded" aria-label="Zuboff, S. (2019). *The Age of Surveillance Capitalism: The Fight for a Human Future at the New Frontier of Power*. PublicAffairs.">[16]</span></a></sup> 里说得更激进一点：当你的注意力、行为、情绪都被作为数据资产来挖掘，你就不再是”用户”，而是”原材料”。这套结构对应的心理代价不是偶然的——它是商业模式的必然外部性。</p><p>把这三组文献摞在一起——Twenge&#x2F;Haidt 的临床数据、Putnam&#x2F; 项飙 的结构观察、Zuboff&#x2F;Wu 的政治经济学——就能看出”当代社会性压抑和科技发展是否有联系”这个问题的回答大致是：</p><blockquote><p>有联系，但不是单向因果。科技是一套加速人类某些内在倾向的基础设施；它本身不生产压抑，但它持续地消除那些本来可以阻止压抑蔓延的缓冲带。</p></blockquote><h2 id="六、可能的应对"><a href="#六、可能的应对" class="headerlink" title="六、可能的应对"></a>六、可能的应对</h2><p>这块我本来最想写，但读下来发现大部分作者的建议都很像，且都承认个人层面的作用有限。</p><ul><li>Cal Newport《Digital Minimalism》<sup id="fnref:17" class="footnote-ref"><a href="#fn:17" rel="footnote"><span class="hint--top hint--rounded" aria-label="Newport, C. (2019). *Digital Minimalism: Choosing a Focused Life in a Noisy World*. Portfolio.">[17]</span></a></sup> 提出”数字极简”：先做 30 天数字断舍离，再按刚需原则把 App 放回来。他承认这对纪律性差的人基本没用</li><li>Sherry Turkle 的建议更朴素：<strong>为对话留出没有手机的时间和空间</strong>，哪怕只是晚饭一小时</li><li>Haidt 给出的是结构性建议：14 岁以下不给智能手机，16 岁以下不开社交媒体账号，学校手机禁令</li><li>项飙 的”重建附近”更像一种生活态度：留意你每天经过的那三公里，把它从背景板变回可感知的地方</li></ul><p>没人能给一个普适方案。这件事可能和工业革命初期讨论”机器让人异化”一样，要一代人的时间才能长出新的共存方式。</p><h2 id="七、一点自己的态度"><a href="#七、一点自己的态度" class="headerlink" title="七、一点自己的态度"></a>七、一点自己的态度</h2><p>我不是这个领域的研究者，写这篇东西主要是自己把读过的东西拆开再拼一遍，看看能不能拼出一张连贯的图。拼完的感觉是：</p><ul><li>“科技导致了这一切”是偷懒的说法</li><li>“跟科技无关，是人心的问题”是更偷懒的说法</li><li>靠谱一点的描述是——<strong>我们是第一代被放在实验台上的人，科技放大了我们本就有的脆弱，而我们还没来得及长出相应的防御机制</strong></li></ul><p>下一代或许会好一点。他们可能会发展出新的礼仪（比如”餐桌不放手机”变成默认而非提议），或者监管会走到实处（比如欧盟已经开始强制推送”非算法时间线”选项 <sup id="fnref:18" class="footnote-ref"><a href="#fn:18" rel="footnote"><span class="hint--top hint--rounded" aria-label="欧盟《数字服务法》（Digital Services Act, DSA）自 2024 年起对超大在线平台（VLOP）施加了推荐系统透明度与&quot;非画像时间线&quot;选项要求，详见 eur-lex.europa.eu 的 Regulation (EU) 2022/2065。">[18]</span></a></sup>），或者技术本身会转向（某些 Slow Tech 运动已经在做这件事）。但这些演进的时间尺度不是一年两年。</p><p>眼下能做的，大概就是多读点，少转发点，偶尔把手机放下来听面前的人把话说完。</p><hr><h2 id="参考文献"><a href="#参考文献" class="headerlink" title="参考文献"></a>参考文献</h2><section class="footnotes"><div class="footnote-list"><ol><li><span id="fn:1" class="footnote-text"><span>Turkle, S. (2011). <em>Alone Together: Why We Expect More from Technology and Less from Each Other</em>. Basic Books.<a href="#fnref:1" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:2" class="footnote-text"><span>Turkle, S. (2015). <em>Reclaiming Conversation: The Power of Talk in a Digital Age</em>. Penguin Press.<a href="#fnref:2" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:3" class="footnote-text"><span>Twenge, J. M. (2017). <em>iGen: Why Today’s Super-Connected Kids Are Growing Up Less Rebellious, More Tolerant, Less Happy</em>. Atria Books.<a href="#fnref:3" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:4" class="footnote-text"><span>Twenge, J. M. (2017). “Have Smartphones Destroyed a Generation?” <em>The Atlantic</em>, September 2017.<a href="#fnref:4" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:5" class="footnote-text"><span>Haidt, J. (2024). <em>The Anxious Generation: How the Great Rewiring of Childhood Is Causing an Epidemic of Mental Illness</em>. Penguin Press.<a href="#fnref:5" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:6" class="footnote-text"><span>Odgers, C. L. (2024). “The great rewiring: is social media really behind an epidemic of teenage mental illness?” <em>Nature</em>, 628, 29–30.<a href="#fnref:6" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:7" class="footnote-text"><span>Hunt, M. G., Marx, R., Lipson, C., &amp; Young, J. (2018). “No More FOMO: Limiting Social Media Decreases Loneliness and Depression.” <em>Journal of Social and Clinical Psychology</em>, 37(10), 751–768.<a href="#fnref:7" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:8" class="footnote-text"><span>Simon, H. A. (1971). “Designing Organizations for an Information-Rich World.” In Greenberger, M. (ed.), <em>Computers, Communication, and the Public Interest</em>, Johns Hopkins Press, 40–41.<a href="#fnref:8" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:9" class="footnote-text"><span>Wu, T. (2016). <em>The Attention Merchants: The Epic Scramble to Get Inside Our Heads</em>. Knopf.<a href="#fnref:9" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:10" class="footnote-text"><span>Eyal, N. (2014). <em>Hooked: How to Build Habit-Forming Products</em>. Portfolio.<a href="#fnref:10" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:11" class="footnote-text"><span>Harris, T. Center for Humane Technology, 公开演讲与文章集合，见 humanetech.com。<a href="#fnref:11" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:12" class="footnote-text"><span>Carr, N. (2010). <em>The Shallows: What the Internet Is Doing to Our Brains</em>. W. W. Norton.<a href="#fnref:12" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:13" class="footnote-text"><span>Putnam, R. D. (2000). <em>Bowling Alone: The Collapse and Revival of American Community</em>. Simon &amp; Schuster.<a href="#fnref:13" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:14" class="footnote-text"><span>项飙, 吴琦 (2020). 《把自己作为方法：与项飙谈话》. 上海文艺出版社. 其中”附近的消失”为项飙近年多次访谈中的核心提法。<a href="#fnref:14" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:15" class="footnote-text"><span>Bauman, Z. (2000). <em>Liquid Modernity</em>. Polity Press.<a href="#fnref:15" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:16" class="footnote-text"><span>Zuboff, S. (2019). <em>The Age of Surveillance Capitalism: The Fight for a Human Future at the New Frontier of Power</em>. PublicAffairs.<a href="#fnref:16" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:17" class="footnote-text"><span>Newport, C. (2019). <em>Digital Minimalism: Choosing a Focused Life in a Noisy World</em>. Portfolio.<a href="#fnref:17" rev="footnote" class="footnote-backref"> ↩</a></span></span></li><li><span id="fn:18" class="footnote-text"><span>欧盟《数字服务法》（Digital Services Act, DSA）自 2024 年起对超大在线平台（VLOP）施加了推荐系统透明度与”非画像时间线”选项要求，详见 eur-lex.europa.eu 的 Regulation (EU) 2022&#x2F;2065。<a href="#fnref:18" rev="footnote" class="footnote-backref"> ↩</a></span></span></li></ol></div></section>]]>
    </content>
    <id>https://7788dev.github.io/2026/05/08/social-repression-and-tech/</id>
    <link href="https://7788dev.github.io/2026/05/08/social-repression-and-tech/"/>
    <published>2026-05-08T15:00:00.000Z</published>
    <summary>一个技术背景的外行人，对&quot;科技越进步，人越拘谨&quot;这件事读了一些书之后的整理。不给结论，只把别人看过的研究和观点重新排列一下。</summary>
    <title>当代社会性压抑和科技发展是否有联系？</title>
    <updated>2026-05-10T09:13:54.701Z</updated>
  </entry>
  <entry>
    <author>
      <name>Looks</name>
    </author>
    <category term="逆向" scheme="https://7788dev.github.io/categories/%E9%80%86%E5%90%91/"/>
    <category term="逆向" scheme="https://7788dev.github.io/tags/%E9%80%86%E5%90%91/"/>
    <category term="Cloudflare" scheme="https://7788dev.github.io/tags/Cloudflare/"/>
    <category term="Node.js" scheme="https://7788dev.github.io/tags/Node-js/"/>
    <content>
      <![CDATA[<blockquote><p>这篇只做记录</p></blockquote><h2 id="TL-DR"><a href="#TL-DR" class="headerlink" title="TL;DR"></a>TL;DR</h2><p>在 Node 里用 <code>node:vm</code> + <code>node-tls-client</code> 走完了 Cloudflare Managed Challenge 的前两步：</p><ol><li>GET 原站拿到 interstitial HTML + <code>_cf_chl_opt</code></li><li>GET orchestrate 脚本，在 VM 里执行</li><li>等 orchestrate 自己发出 flow 的 POST（VM 内部搞定）</li><li><strong>卡在 Turnstile widget 不回 token</strong>  没 token 就没法 POST <code>/cv/result</code>，拿不到 <code>cf_clearance</code></li></ol><h2 id="链路结构"><a href="#链路结构" class="headerlink" title="链路结构"></a>链路结构</h2><p>真实链路就这四步：</p><figure class="highlight dust"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><code class="hljs dust"><span class="language-xml">GET  /<span class="hljs-tag">&lt;<span class="hljs-name">target</span>&gt;</span></span><br><span class="language-xml">       返回 403 + interstitial HTML + window._cf_chl_opt</span><br><span class="language-xml">         + 动态插入 <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">&quot;.../orchestrate/chl_page/v1?ray=...&quot;</span>&gt;</span> </span><br><span class="language-xml"></span><br><span class="language-xml">GET  /cdn-cgi/challenge-platform/h/</span><span class="hljs-template-variable">&#123;tier&#125;</span><span class="language-xml">/orchestrate/chl_page/v1?ray=</span><span class="hljs-template-variable">&#123;ray&#125;</span><span class="language-xml"></span><br><span class="language-xml">       一段重度混淆、带 VMP 字节码的 challenge 主脚本</span><br><span class="language-xml">         在 vm.runInContext 里执行</span><br><span class="language-xml"></span><br><span class="language-xml">POST /cdn-cgi/challenge-platform/h/</span><span class="hljs-template-variable">&#123;tier&#125;</span><span class="language-xml">/flow/ov1/.../</span><span class="hljs-template-variable">&#123;ray&#125;</span><span class="language-xml">/</span><span class="hljs-template-variable">&#123;chlVersion&#125;</span><span class="language-xml"></span><br><span class="language-xml">       orchestrate 执行期间自己发出</span><br><span class="language-xml">         body 是 plain text（content-type: text/plain;charset=UTF-8）</span><br><span class="language-xml">         这一步我不做 body 构造，完全由 VM 内的 orchestrate 代码负责</span><br><span class="language-xml"></span><br><span class="language-xml">POST /cdn-cgi/challenge-platform/h/</span><span class="hljs-template-variable">&#123;tier&#125;</span><span class="language-xml">/cv/result/</span><span class="hljs-template-variable">&#123;ray&#125;</span><span class="language-xml">/</span><span class="hljs-template-variable">&#123;hash&#125;</span><span class="language-xml"><span class="language-handlebars"><span class="language-xml"></span></span></span><br><span class="language-xml"><span class="language-handlebars"><span class="language-xml">       拿 Turnstile token 换 cf_clearance</span></span></span><br><span class="language-xml"><span class="language-handlebars"><span class="language-xml">         form body：wp=<span class="hljs-tag">&lt;<span class="hljs-name">token</span>&gt;</span>&amp;cf-turnstile-response=<span class="hljs-tag">&lt;<span class="hljs-name">token</span>&gt;</span>&amp;h=...&amp;gv=...&amp;cv=...</span></span></span><br><span class="language-xml"><span class="language-handlebars"><span class="language-xml">         响应里 Set-Cookie: cf_clearance=...; HttpOnly</span></span></span><br></code></pre></td></tr></table></figure><p>tier 从 <code>_cf_chl_opt.cFPWv</code>（或 <code>OwLPw9</code>）读，一般是 <code>g</code>；ray 从 <code>_cf_chl_opt.cRay</code>（或 <code>XVCKH0</code>）读；hash 是 orchestrate URL 路径最后一段。</p><p>flow 的 body 到底是什么格式我没有分析出来，也不打算分析  <strong>因为 VM 内的 orchestrate 能自己把这条请求发出去</strong>，我只需要把环境补到让它不报错即可。</p><h2 id="仓库结构"><a href="#仓库结构" class="headerlink" title="仓库结构"></a>仓库结构</h2><p>打包后就这么几个文件：</p><figure class="highlight nix"><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><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs nix">cloudflare-env-replay.mjs      主脚本，一个文件跑完整个链路<br>lib<span class="hljs-symbol">/tls-fetch.mjs</span>              node-tls-client 封装，走 chrome_131 profile<br>lib<span class="hljs-symbol">/event-supplement.mjs</span>       VM 里补 Event<span class="hljs-symbol">/MouseEvent/PointerEvent/KeyboardEvent</span> 等子类<br>lib<span class="hljs-symbol">/turnstile-detect.mjs</span>       从 HTML<span class="hljs-symbol">/flow</span> 响应里嗅出 Turnstile sitekey<br>lib<span class="hljs-symbol">/turnstile-flow.mjs</span>         VM 内装 turnstile shim、加载 api.js、派发轨迹、等 token<br>lib<span class="hljs-symbol">/track-generator.mjs</span>        Bezier <span class="hljs-operator">+</span> mulberry32 人机轨迹<br>lib<span class="hljs-symbol">/cv-result.mjs</span>              把 token POST 到 <span class="hljs-symbol">/cv/result</span> 并抓 Set-Cookie<br>package.json                   唯一运行时依赖：node-tls-client<br></code></pre></td></tr></table></figure><p>主脚本三千多行，主要在做”给 VM 装一个够逼真的 window&#x2F;document&#x2F;navigator&#x2F;Event”这件事。</p><h2 id="踩过的坑（这些坑踩完了）"><a href="#踩过的坑（这些坑踩完了）" class="headerlink" title="踩过的坑（这些坑踩完了）"></a>踩过的坑（这些坑踩完了）</h2><h3 id="1-Node-fetch-的-TLS-指纹发不出-flow"><a href="#1-Node-fetch-的-TLS-指纹发不出-flow" class="headerlink" title="1. Node fetch 的 TLS 指纹发不出 &#x2F;flow"></a>1. Node fetch 的 TLS 指纹发不出 &#x2F;flow</h3><p>Node 自带 <code>fetch</code>（undici）或 <code>https</code> 模块的 ClientHello 跟 Chrome 完全不一样。Cloudflare 的 <code>/cdn-cgi/challenge-platform/h/g/flow/ov*</code> 对 JA4+ 做了校验，Node 默认指纹直接拒。</p><p>解：用 <code>node-tls-client</code>（bogdanfinn 那套的 Node binding），选 <code>ClientIdentifier.chrome_131</code> profile，配上固定的 HTTP&#x2F;2 header order：</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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">import</span> &#123; <span class="hljs-title class_">Session</span>, <span class="hljs-title class_">ClientIdentifier</span> &#125; <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;node-tls-client&#x27;</span>;<br><br><span class="hljs-keyword">const</span> <span class="hljs-variable constant_">CHROME_XHR_HEADER_ORDER</span> = [<br>  <span class="hljs-string">&#x27;:method&#x27;</span>, <span class="hljs-string">&#x27;:authority&#x27;</span>, <span class="hljs-string">&#x27;:scheme&#x27;</span>, <span class="hljs-string">&#x27;:path&#x27;</span>,<br>  <span class="hljs-string">&#x27;content-length&#x27;</span>, <span class="hljs-string">&#x27;accept&#x27;</span>, <span class="hljs-string">&#x27;sec-ch-ua&#x27;</span>, <span class="hljs-comment">/* ... */</span><br>];<br><br><span class="hljs-keyword">const</span> session = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Session</span>(&#123;<br>  <span class="hljs-attr">clientIdentifier</span>: <span class="hljs-title class_">ClientIdentifier</span>.<span class="hljs-property">chrome_131</span>,<br>  <span class="hljs-attr">timeout</span>: <span class="hljs-number">30000</span>,<br>  <span class="hljs-attr">headerOrder</span>: <span class="hljs-variable constant_">CHROME_XHR_HEADER_ORDER</span>,<br>&#125;);<br></code></pre></td></tr></table></figure><p>Chrome 142 在当前 Cloudflare 规则下 <code>chrome_131</code> 够用，更接近的 profile 可能需要自己 fork tls-client 库加。</p><h3 id="2-Node-没有-PointerEvent-MouseEvent"><a href="#2-Node-没有-PointerEvent-MouseEvent" class="headerlink" title="2. Node 没有 PointerEvent&#x2F;MouseEvent"></a>2. Node 没有 PointerEvent&#x2F;MouseEvent</h3><p>VM 里的 orchestrate 和 Turnstile SDK 都要 <code>new PointerEvent(...)</code>。Node 原生 Event 只到 Event 基类，往下全缺。</p><p>解：手写一套 <code>EnvEventBase / EnvUIEvent / EnvMouseEvent / EnvPointerEvent / EnvTouchEvent / EnvKeyboardEvent / EnvFocusEvent / EnvCustomEvent</code>，每个类完整实现字段和 <code>preventDefault / stopPropagation / composedPath</code>。<code>isTrusted</code> 默认给 <code>true</code>。</p><p>这一步没技术含量但很费劲，每发现一个字段报错就补一个。</p><h3 id="3-screenX-clientX-的-chrome-offset"><a href="#3-screenX-clientX-的-chrome-offset" class="headerlink" title="3. screenX &#x2F; clientX 的 chrome offset"></a>3. screenX &#x2F; clientX 的 chrome offset</h3><p>CDP 派发合成事件时有个经典 bug：<code>screenX = clientX</code>，真浏览器里 <code>screenX</code> 应该比 <code>clientX</code> 多一段浏览器 chrome 的偏移（标题栏+tab条），Chrome 上约 80~140px。orchestrate 拿到 <code>screenX - clientX === 0</code> 就能判定”合成事件”。</p><p>解：Pointer&#x2F;Mouse 事件构造器里强制加偏移：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-variable language_">this</span>.<span class="hljs-property">screenX</span> = <span class="hljs-title class_">Number</span>(init.<span class="hljs-property">screenX</span> ?? (init.<span class="hljs-property">clientX</span> + chromeOffset.<span class="hljs-property">x</span>));<br><span class="hljs-variable language_">this</span>.<span class="hljs-property">screenY</span> = <span class="hljs-title class_">Number</span>(init.<span class="hljs-property">screenY</span> ?? (init.<span class="hljs-property">clientY</span> + chromeOffset.<span class="hljs-property">y</span>));<br></code></pre></td></tr></table></figure><p>chromeOffset 用 <code>{ x: 0, y: 87 }</code>（典型 Chrome on Win10）。</p><h3 id="4-Universal-callable-proxy"><a href="#4-Universal-callable-proxy" class="headerlink" title="4. Universal callable proxy"></a>4. Universal callable proxy</h3><p>orchestrate 里大量动态探测：<code>mY[someMinifiedKey].apply(mY, args)</code>。随机生成 key，我不可能穷举补齐。直接 <code>mY[key] === undefined</code> 就 throw。</p><p>解：给宿主对象挂一层 Proxy，未知 key 返回一个 callable proxy  可以当函数调也可以继续取属性，apply&#x2F;construct 都返回自己：</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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">function</span> <span class="hljs-title function_">makeUniversalCallable</span>(<span class="hljs-params"></span>) &#123;<br>  <span class="hljs-keyword">const</span> target = <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123; <span class="hljs-keyword">return</span> proxy; &#125;;<br>  <span class="hljs-keyword">let</span> proxy;<br>  proxy = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Proxy</span>(target, &#123;<br>    <span class="hljs-title function_">get</span>(<span class="hljs-params">obj, prop</span>) &#123;<br>      <span class="hljs-keyword">if</span> (prop === <span class="hljs-string">&#x27;then&#x27;</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">undefined</span>;<br>      <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> prop === <span class="hljs-string">&#x27;symbol&#x27;</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">undefined</span>;<br>      <span class="hljs-keyword">return</span> proxy;<br>    &#125;,<br>    <span class="hljs-title function_">apply</span>(<span class="hljs-params"></span>) &#123; <span class="hljs-keyword">return</span> proxy; &#125;,<br>    <span class="hljs-title function_">construct</span>(<span class="hljs-params"></span>) &#123; <span class="hljs-keyword">return</span> proxy; &#125;,<br>    <span class="hljs-title function_">has</span>(<span class="hljs-params"></span>) &#123; <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>; &#125;,<br>  &#125;);<br>  <span class="hljs-keyword">return</span> proxy;<br>&#125;<br></code></pre></td></tr></table></figure><p>这个 trick 救了我一堆 “X is not a function” 错误。但它也是个双刃剑  把真实错误也屏蔽掉了，后面排查要反复 toggle。</p><h3 id="5-Bezier-人机轨迹-可复现-PRNG"><a href="#5-Bezier-人机轨迹-可复现-PRNG" class="headerlink" title="5. Bezier 人机轨迹 + 可复现 PRNG"></a>5. Bezier 人机轨迹 + 可复现 PRNG</h3><p>Turnstile 阶段需要向 widget 容器派发一连串指针事件。直线移动太假，用 Bezier + 抖动：</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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">function</span> <span class="hljs-title function_">mulberry32</span>(<span class="hljs-params">seed</span>) &#123;<br>  <span class="hljs-keyword">let</span> s = seed | <span class="hljs-number">0</span>;<br>  <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> &#123;<br>    s = (s + <span class="hljs-number">0x6D2B79F5</span>) | <span class="hljs-number">0</span>;<br>    <span class="hljs-keyword">let</span> t = s;<br>    t = <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">imul</span>(t ^ (t &gt;&gt;&gt; <span class="hljs-number">15</span>), t | <span class="hljs-number">1</span>);<br>    t ^= t + <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">imul</span>(t ^ (t &gt;&gt;&gt; <span class="hljs-number">7</span>), t | <span class="hljs-number">61</span>);<br>    <span class="hljs-keyword">return</span> ((t ^ (t &gt;&gt;&gt; <span class="hljs-number">14</span>)) &gt;&gt;&gt; <span class="hljs-number">0</span>) / <span class="hljs-number">4294967296</span>;<br>  &#125;;<br>&#125;<br></code></pre></td></tr></table></figure><p>每次 run 记录 seed，出问题时拿同一个 seed 复现。28~42 个中间点、ease-in-out 的时间分布、pointerover  pointerenter  pointermove  pointerdown  pointerup  click 的完整序列。这一套跟真浏览器的宏观形状差不多。</p><h3 id="6-Turnstile-api-js-的-currentScript-校验"><a href="#6-Turnstile-api-js-的-currentScript-校验" class="headerlink" title="6. Turnstile api.js 的 currentScript 校验"></a>6. Turnstile api.js 的 currentScript 校验</h3><p>Turnstile SDK 执行时会做：</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></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">if</span> (!(<span class="hljs-variable language_">document</span>.<span class="hljs-property">currentScript</span> <span class="hljs-keyword">instanceof</span> <span class="hljs-title class_">HTMLScriptElement</span>)<br> || !<span class="hljs-regexp">/api\.js/</span>.<span class="hljs-title function_">test</span>(<span class="hljs-variable language_">document</span>.<span class="hljs-property">currentScript</span>.<span class="hljs-property">src</span>)) &#123;<br>  <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">&#x27;Could not find Turnstile valid script tag&#x27;</span>);<br>&#125;<br></code></pre></td></tr></table></figure><p>我的 document.createElement 返回的不是真的 HTMLScriptElement 实例，会死在这。</p><p>解：执行 api.js 前临时重写 <code>HTMLScriptElement[Symbol.hasInstance]</code> 让它认 <code>tagName === &#39;SCRIPT&#39;</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><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-title class_">Object</span>.<span class="hljs-title function_">defineProperty</span>(<span class="hljs-title class_">HTMLScriptElement</span>, <span class="hljs-title class_">Symbol</span>.<span class="hljs-property">hasInstance</span>, &#123;<br>  <span class="hljs-attr">value</span>: <span class="hljs-function">(<span class="hljs-params">obj</span>) =&gt;</span> obj?.<span class="hljs-property">tagName</span>?.<span class="hljs-title function_">toUpperCase</span>() === <span class="hljs-string">&#x27;SCRIPT&#x27;</span>,<br>  <span class="hljs-attr">configurable</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">writable</span>: <span class="hljs-literal">true</span>,<br>&#125;);<br><span class="hljs-keyword">try</span> &#123; vm.<span class="hljs-title function_">runInContext</span>(apiJsText, ctx, &#123; <span class="hljs-attr">filename</span>: <span class="hljs-string">&#x27;turnstile-api.js&#x27;</span> &#125;); &#125;<br><span class="hljs-keyword">finally</span> &#123; <span class="hljs-comment">/* 还原原来的 hasInstance */</span> &#125;<br></code></pre></td></tr></table></figure><h3 id="7-cv-result-的-body-格式"><a href="#7-cv-result-的-body-格式" class="headerlink" title="7. cv&#x2F;result 的 body 格式"></a>7. cv&#x2F;result 的 body 格式</h3><p>社区有帖子说 body 是 JSON，有人说 form-urlencoded。我实测 form-urlencoded 能走（至少服务器接受请求进到下一步判断），JSON 直接 400。字段 <code>wp</code> 和 <code>cf-turnstile-response</code> 同时塞同一个 token 最稳：</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><code class="hljs js"><span class="hljs-keyword">const</span> params = <span class="hljs-keyword">new</span> <span class="hljs-title class_">URLSearchParams</span>();<br>params.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;wp&#x27;</span>, token);<br>params.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;cf-turnstile-response&#x27;</span>, token);<br><span class="hljs-comment">// 可选附加字段（从 _cf_chl_opt 读）</span><br>params.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;h&#x27;</span>,  opt.<span class="hljs-property">cH</span> || opt.<span class="hljs-property">bLtO6</span> || <span class="hljs-string">&#x27;&#x27;</span>);<br>params.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;gv&#x27;</span>, opt.<span class="hljs-property">cFPWv</span> || opt.<span class="hljs-property">OwLPw9</span> || <span class="hljs-string">&#x27;&#x27;</span>);<br>params.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;cv&#x27;</span>, opt.<span class="hljs-property">cType</span> || opt.<span class="hljs-property">qklD0</span> || <span class="hljs-string">&#x27;&#x27;</span>);<br></code></pre></td></tr></table></figure><p>返回 200 + <code>Set-Cookie: cf_clearance=...</code> 才算真的过了。<strong>前提是 token 必须是真的。</strong></p><h2 id="卡死的地方：Turnstile-不出-token"><a href="#卡死的地方：Turnstile-不出-token" class="headerlink" title="卡死的地方：Turnstile 不出 token"></a>卡死的地方：Turnstile 不出 token</h2><p>前面六个坑填完，在真实 run 里看到的最终状态：</p><figure class="highlight json"><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><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;turnstile&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>    <span class="hljs-attr">&quot;detection&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>      <span class="hljs-attr">&quot;source&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;managed-implicit&quot;</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;siteKey&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;0x00e9d3dca1328a49ad...&quot;</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;apiJsUrl&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;https://challenges.cloudflare.com/turnstile/v0/api.js&quot;</span><br>    <span class="hljs-punctuation">&#125;</span><span class="hljs-punctuation">,</span><br>    <span class="hljs-attr">&quot;attempted&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">true</span></span><span class="hljs-punctuation">,</span><br>    <span class="hljs-attr">&quot;stage&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>      <span class="hljs-attr">&quot;ok&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">false</span></span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;tokenLength&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">0</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;reason&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;no-token&quot;</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;log&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><br>        <span class="hljs-string">&quot;detected source=managed-implicit sitekey=0x00e9d3dca...&quot;</span><span class="hljs-punctuation">,</span><br>        <span class="hljs-string">&quot;api.js fetched length=61556&quot;</span><span class="hljs-punctuation">,</span><br>        <span class="hljs-string">&quot;turnstile.render() captured widget cf-chl-widget-xxx&quot;</span><span class="hljs-punctuation">,</span><br>        <span class="hljs-string">&quot;explicit render() returned widgetId=cf-chl-widget-xxx&quot;</span><span class="hljs-punctuation">,</span><br>        <span class="hljs-string">&quot;dispatched 91 synthetic events&quot;</span><span class="hljs-punctuation">,</span><br>        <span class="hljs-string">&quot;no token surfaced before timeout&quot;</span><br>      <span class="hljs-punctuation">]</span><br>    <span class="hljs-punctuation">&#125;</span><br>  <span class="hljs-punctuation">&#125;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;error&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span> <span class="hljs-attr">&quot;code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;RISK_CONTROL&quot;</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">&quot;httpStatus&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">403</span> <span class="hljs-punctuation">&#125;</span><br><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><p>分开看：</p><ul><li><strong>api.js 成功拉下来</strong>（61KB 左右，Turnstile 公共 SDK）</li><li><strong>SDK 在 VM 里跑完了</strong>，调用了我的 turnstile shim 的 <code>render()</code>，我抓到了 widget</li><li><strong>合成事件全派发完</strong>（91 个 pointer&#x2F;mouse 事件，seed 可复现）</li><li><strong>widget 的 callback 从来没回过 token</strong></li></ul><p>到这一步，VM 本身的环境”看上去”是让 api.js 跑通了的。但 Turnstile 判定”你不是真浏览器”这件事，是在 SDK 内部和 challenges.cloudflare.com 之间的黑盒里完成的。它可能做了但不限于：</p><ul><li><strong>WASM 模块里采样 GPU&#x2F;layout 时序</strong>：纯 Node VM 没有 GPU、没有 layout，这里大概率直接失败</li><li><strong>AudioContext 指纹</strong>：我的 AudioContext 是空 stub，分析音频指纹的路径一看就是假的</li><li><strong>WebGL 指纹</strong>：同上，<code>document.createElement(&#39;canvas&#39;).getContext(&#39;webgl&#39;)</code> 我只返回了个 noop</li><li><strong>性能指纹</strong>：<code>performance.now()</code> 的分布、Promise&#x2F;microtask 队列的时序，跟 V8 in Chrome 不是一个抖动特征</li><li><strong>真浏览器主动信号</strong>：像 <code>user-activation</code>、<code>isInputPending()</code>、Permission API 的 state，都是 SDK 可以读到但合成事件不会触发的东西</li></ul><p>上面这些<strong>我没有单独验证过是哪一条在拦</strong>，只是根据 Turnstile 公开资料 + 常见反自动化检测套路的合理怀疑。</p><p>这一步就是我过不去的。</p><h2 id="为什么补不全"><a href="#为什么补不全" class="headerlink" title="为什么补不全"></a>为什么补不全</h2><p>前面六个坑都是 “缺什么补什么” 的力气活，能做到 100% 过检吗？不能。原因：</p><h3 id="orchestrate-本身还在偶发报错"><a href="#orchestrate-本身还在偶发报错" class="headerlink" title="orchestrate 本身还在偶发报错"></a>orchestrate 本身还在偶发报错</h3><p>即使 universal callable proxy 兜底了一大堆 “X is not a function”，实际跑完还是会看到类似：</p><figure class="highlight vbnet"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs vbnet"><span class="hljs-symbol">TypeError:</span> ml[uA(...)] <span class="hljs-built_in">is</span> <span class="hljs-built_in">not</span> a <span class="hljs-keyword">function</span><br></code></pre></td></tr></table></figure><p>这说明混淆 dispatcher 里还有某个槽位探测到 native 方法缺失。换 ray 换版本后错误信息会换一个函数名，但”探测到缺失”这件事是稳定的。</p><h3 id="missingProps-日志里常年有残留"><a href="#missingProps-日志里常年有残留" class="headerlink" title="missingProps 日志里常年有残留"></a><code>missingProps</code> 日志里常年有残留</h3><p>跑完一次，capture 对象里的 <code>missingProps</code> 数组里总有：</p><figure class="highlight ada"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs ada">&#123; scope: <span class="hljs-symbol">&#x27;xhr</span>&#x27;, prop: <span class="hljs-symbol">&#x27;content</span>-<span class="hljs-keyword">type</span>&#x27;, ... &#125;<br></code></pre></td></tr></table></figure><p>以及一些 tagName 为空、prop 是 Symbol 的访问。这些 probe 不致命，但每一个都会留下案底</p><h2 id="为什么不上真浏览器"><a href="#为什么不上真浏览器" class="headerlink" title="为什么不上真浏览器"></a>为什么不上真浏览器</h2><p>这是自我约束的选择：</p><ul><li><strong>目标本来就是想摆脱浏览器依赖</strong>。如果上 Playwright，整个项目没意义，直接 <code>context.storageState()</code> 把 cookie 序列化就完事了。</li><li><strong>想理解这玩意是怎么工作的</strong>。黑盒调真浏览器学不到东西。</li><li><strong>想看看纯 JS 到底能走到哪一步</strong>。现在答案是：能走到 Turnstile 门口，但推不开门。</li></ul><p>仓库没开源，不提供现成的”过墙”实现。这篇文章的目的是复盘，不是发布绕过工具。</p><h2 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h2><ul><li><a href="https://github.com/bogdanfinn/tls-client">bogdanfinn&#x2F;tls-client</a>  JA4+ 指纹的核心</li><li><a href="https://www.npmjs.com/package/node-tls-client">node-tls-client</a>  上面那个的 Node binding</li><li>Cloudflare 官方 Turnstile 文档（只写了接入，没写内部）</li></ul><hr><blockquote><p>本文仅记录方法论和接口形态，不提供任何可直接运行的绕过实现。具体字段的加密算法、VM 字节码映射、符号逻辑一律略过。<br>技术点适用于合法场景：E2E 测试、SDK 审计、自家站点监控、前端混淆代码的逆向分析。</p></blockquote>]]>
    </content>
    <id>https://7788dev.github.io/2026/05/08/turnstile-failed-recap/</id>
    <link href="https://7788dev.github.io/2026/05/08/turnstile-failed-recap/"/>
    <published>2026-05-08T13:45:00.000Z</published>
    <summary>纯 Node 里补 Cloudflare Managed Challenge（俗称 5s 盾）的运行环境</summary>
    <title>逆向 Cloudflare 5s 盾</title>
    <updated>2026-05-10T09:13:59.556Z</updated>
  </entry>
  <entry>
    <author>
      <name>Looks</name>
    </author>
    <content>
      <![CDATA[<p>Welcome to <a href="https://hexo.io/">Hexo</a>! This is your very first post. Check <a href="https://hexo.io/docs/">documentation</a> for more info. If you get any problems when using Hexo, you can find the answer in <a href="https://hexo.io/docs/troubleshooting.html">troubleshooting</a> or you can ask me on <a href="https://github.com/hexojs/hexo/issues">GitHub</a>.</p><h2 id="Quick-Start"><a href="#Quick-Start" class="headerlink" title="Quick Start"></a>Quick Start</h2><h3 id="Create-a-new-post"><a href="#Create-a-new-post" class="headerlink" title="Create a new post"></a>Create a new post</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">$ hexo new <span class="hljs-string">&quot;My New Post&quot;</span><br></code></pre></td></tr></table></figure><p>More info: <a href="https://hexo.io/docs/writing.html">Writing</a></p><h3 id="Run-server"><a href="#Run-server" class="headerlink" title="Run server"></a>Run server</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">$ hexo server<br></code></pre></td></tr></table></figure><p>More info: <a href="https://hexo.io/docs/server.html">Server</a></p><h3 id="Generate-static-files"><a href="#Generate-static-files" class="headerlink" title="Generate static files"></a>Generate static files</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">$ hexo generate<br></code></pre></td></tr></table></figure><p>More info: <a href="https://hexo.io/docs/generating.html">Generating</a></p><h3 id="Deploy-to-remote-sites"><a href="#Deploy-to-remote-sites" class="headerlink" title="Deploy to remote sites"></a>Deploy to remote sites</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">$ hexo deploy<br></code></pre></td></tr></table></figure><p>More info: <a href="https://hexo.io/docs/one-command-deployment.html">Deployment</a></p>]]>
    </content>
    <id>https://7788dev.github.io/2026/05/08/hello-world/</id>
    <link href="https://7788dev.github.io/2026/05/08/hello-world/"/>
    <published>2026-05-08T13:33:51.050Z</published>
    <summary>
      <![CDATA[<p>Welcome to <a href="https://hexo.io/">Hexo</a>! This is your very first post. Check <a href="https://hexo.io/docs/">documentation</a>]]>
    </summary>
    <title>Hello World</title>
    <updated>2026-05-08T13:33:51.050Z</updated>
  </entry>
</feed>
