<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>翰林的小站</title>
  
  <subtitle>博览乐学，敢于探索。</subtitle>
  <link href="https://blog.hanlin.press/atom.xml" rel="self"/>
  
  <link href="https://blog.hanlin.press/"/>
  <updated>2026-02-05T09:02:20.000Z</updated>
  <id>https://blog.hanlin.press/</id>
  
  <author>
    <name>abc1763613206</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>Bilibili 自建视频云生态观察</title>
    <link href="https://blog.hanlin.press/2026/02/Bilibili-Self-hosted-CDN-Overview/"/>
    <id>https://blog.hanlin.press/2026/02/Bilibili-Self-hosted-CDN-Overview/</id>
    <published>2026-02-05T05:34:20.000Z</published>
    <updated>2026-02-05T09:02:20.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="概要"><a href="#概要" class="headerlink" title="概要"></a>概要</h2><p>本项目是对 B 站录播姬 <a href="https://rec.danmuji.org/dev/cdn-info/">CDN 节点概览</a>页面的致敬与精神延续。采用公开 DNS 扫描的方式，力求尽可能全面地揭示目前 B 站视频云自建节点的网络分布情况。 </p><p>本项目旨在深入观察 B 站自建节点的架构分布，因此不包含以下内容：</p><ul><li>已知位于公有云的节点（如 <code>gotcha</code> 类），此类信息请直接参阅录播姬的原文档。</li><li>任何形式的如 PCDN&#x2F;MCDN 的边缘节点，鉴于此类节点分布规律的高度不确定性，暂不纳入统计范围。</li></ul><p>本页面内容仅供技术学习与交流使用，旨在为 CDN 建设与规划提供参考。请勿将本项目数据用于任何非法用途。</p><h2 id="更新日志"><a href="#更新日志" class="headerlink" title="更新日志"></a>更新日志</h2><ul><li><code>2026-02-05</code>：因为发现节点有较多变动，重新从头扫描，本轮共收 1483 个节点，将面板从 <code>lab.hanlin.press</code> 移动到这里并添加对比功能。</li><li><code>2025-10-08</code>：基于第三方解析数据，新增 <code>twsx</code>、<code>office</code>、<code>bn</code> 三个数据源，目前共收录 1456 个节点。</li><li><code>2025-10-07</code>：项目数据正式上线，首批收录 1415 个节点。</li></ul><h2 id="数据展示"><a href="#数据展示" class="headerlink" title="数据展示"></a>数据展示</h2><p>💡<strong>提示：</strong> 为提升检索效率，下方采用了数据异步加载的嵌入式页面。<strong>如遇内容渲染异常，请尝试刷新页面重试。</strong></p><p>当前页面支持的功能如下：</p><ul><li><strong>浏览层级：</strong> 首页按“服务商&#x2F;来源”汇总；点击进入后按“地区”汇总；再点击进入可查看具体“域名节点”。</li><li><strong>搜索：</strong> 支持按“域名 &#x2F; IP &#x2F; 地区 &#x2F; 服务商”全文检索（输入后自动筛选）。</li><li><strong>详情展开：</strong> 点击域名行可展开详细信息。<ul><li>IP：点击 IP 可复制；如存在更多来源信息，可展开查看。</li><li>ALT：如存在别名解析（ALT），会按 IP 分组展示，并支持一键复制别名域名。</li></ul></li><li><strong>版本切换：</strong> 右侧“版本”下拉框可切换不同数据版本。</li><li><strong>版本对比：</strong> 点击“对比”展开对比面板，选择 A&#x2F;B 两个版本后点击“开始对比”进入差异视图。<ul><li>请注意，所谓的新增和删除差异是<strong>按 B 版本作为新版本判定</strong>。</li><li>差异类型包含：域名新增&#x2F;移除、IP 增删、来源&#x2F;别名（ALT）变化（来源&#x2F;别名仅统计同一 IP 在两个版本中的差异）。</li></ul></li></ul><style>:root {  --bcdn-gap: 1rem;  --bcdn-row-h: auto;  --bcdn-accent: var(--theme-highlight, #2196f3);  --bcdn-control-h: 40px;  --font-family-sans: "HarmonyOS Sans SC", MiSans, MiSans VF, system-ui, -apple-system, sans-serif;  --font-family-mono: Menlo, Monaco, Consolas, "Courier New", monospace, sans-serif;}.bcdn-app {  margin: 1.5rem 0;  font-family: var(--font-family-sans, sans-serif);  color: var(--text-p0);}.bcdn-toolbar {  position: sticky;  top: 60px;  z-index: 20;  background: var(--card);  backdrop-filter: blur(12px);  -webkit-backdrop-filter: blur(12px);  padding: 1rem;  border-radius: var(--border-card, 12px);  border: 1px solid var(--card-border);  box-shadow: var(--boxshadow-card);  display: flex;  flex-direction: column;   gap: 1rem;  transition: all 0.2s ease;}@media (max-width: 768px) {  .bcdn-toolbar {    top: 10px;    gap: 0.75rem;    padding: 0.75rem;  }  .bcdn-tools-row {    gap: 0.75rem;  }}.bcdn-tools-row {  display: flex;  flex-wrap: wrap;  gap: 1rem;  align-items: center;  width: 100%;}.bcdn-search-wrap {  flex: 1;  position: relative;  min-width: 240px;}.bcdn-search {  width: 100%;  height: var(--bcdn-control-h);  padding: 0 1rem 0 2.4rem;  border-radius: 999px;  background: var(--block);  border: 1px solid var(--block-border);  color: var(--text-p0);  outline: none;  transition: all 0.2s;  box-sizing: border-box;}.bcdn-search:focus {  border-color: var(--bcdn-accent);  box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.15);}.bcdn-search-icon {  position: absolute;  left: 0.8rem;  top: 50%;  transform: translateY(-50%);  opacity: 0.5;  pointer-events: none;}.bcdn-breadcrumbs {  display: flex;  align-items: center;  gap: 0.5rem;  font-size: 0.9rem;  color: var(--text-p2);  padding-bottom: 0.5rem;  border-bottom: 1px solid var(--block-border);  width: 100%;}.bcdn-bc-item {  cursor: pointer;  padding: 0.2rem 0.5rem;  border-radius: 6px;  transition: background 0.2s;}.bcdn-bc-item:hover {  background: var(--block-hover);  color: var(--text-p0);}.bcdn-bc-item.active {  font-weight: 600;  color: var(--text-p0);  cursor: default;  pointer-events: none;}.bcdn-bc-sep {  opacity: 0.4;  font-size: 0.8rem;}.bcdn-stats {  display: flex;  gap: 0.5rem;  flex-wrap: wrap;  align-items: center;}.bcdn-stat {  font-size: 0.85rem;  padding: 0 0.8rem;  border-radius: 8px;  background: var(--block);  border: 1px solid var(--block-border);  color: var(--text-p2);  display: inline-flex;  align-items: center;  height: var(--bcdn-control-h);  box-sizing: border-box;  line-height: 1;}.bcdn-stat b { color: var(--text-p0); margin-right: 4px; }.bcdn-header {  display: grid;  grid-template-columns: 2.5fr 1.5fr 1fr;  padding: 0.75rem 1.5rem;  margin-top: 1rem;  font-size: 0.85rem;  font-weight: 600;  color: var(--text-p3);  letter-spacing: 0.05em;  text-transform: uppercase;  border-bottom: 2px solid var(--block-border);}@media (max-width: 768px) {  .bcdn-header { display: none; }}.bcdn-list {  display: flex;  flex-direction: column;  gap: 0.5rem;  margin-top: 0.5rem;}.bcdn-row {  display: grid;  grid-template-columns: 2.5fr 1.5fr 1fr;  padding: 1rem 1.5rem;  background: var(--card);  border: 1px solid var(--card-border);  border-radius: var(--border-card, 8px);  align-items: center;  gap: 0.5rem;  cursor: pointer;  transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;}.bcdn-row:hover {  transform: translateY(-2px);  box-shadow: var(--boxshadow-card-hover);  background: var(--block);  z-index: 2;}@media (max-width: 768px) {  .bcdn-row {    grid-template-columns: 1fr;    gap: 0.75rem;    padding: 1.25rem;  }}.bcdn-icon {  font-size: 1.2rem;  margin-right: 0.5rem;  vertical-align: sub;  opacity: 0.8;}.bcdn-col-main {  display: flex;  align-items: center;  gap: 0.5rem;}.bcdn-main-text {  font-weight: 600;  font-size: 1rem;  color: var(--text-p0);  word-break: break-all;}.bcdn-domain {  font-family: var(--font-family-mono, monospace);}.bcdn-alias-tag {  display: inline-flex;  font-size: 0.75rem;  padding: 0.1rem 0.4rem;  border-radius: 4px;  background: rgba(255, 171, 0, 0.15);  color: #ffab00;  border: 1px solid rgba(255, 171, 0, 0.3);}.bcdn-copy-btn {  display: inline-flex;  align-items: center;  justify-content: center;  width: 28px;  height: 28px;  border-radius: 8px;  background: var(--block);  border: 1px solid var(--block-border);  color: var(--text-p2);  cursor: pointer;  user-select: none;  flex: 0 0 auto;}.bcdn-copy-btn:hover {  background: var(--block-hover);  color: var(--text-p0);}.bcdn-copy-btn:active {  transform: translateY(1px);}.bcdn-detail-head {  display: flex;  align-items: center;  justify-content: space-between;  gap: 0.75rem;  padding: 0.75rem 0;}.bcdn-detail-domain {  font-family: var(--font-family-mono, monospace);  font-size: 0.95rem;  color: var(--text-p0);  word-break: break-all;}.bcdn-section-title {  margin: 0.75rem 0 0.5rem;  font-size: 0.85rem;  color: var(--text-p2);  letter-spacing: 0.02em;}.bcdn-alias-groups {  display: flex;  flex-direction: column;  gap: 0.75rem;}.bcdn-alias-group {  padding: 0.75rem;  border-radius: 10px;  background: var(--site-bg);  border: 1px solid var(--block-border);}.bcdn-alias-group-title {  display: flex;  align-items: center;  justify-content: space-between;  gap: 0.5rem;  margin-bottom: 0.5rem;  font-size: 0.85rem;  color: var(--text-p2);}.bcdn-alias-group-title .ip {  font-family: var(--font-family-mono, monospace);}.bcdn-alias-list {  display: flex;  flex-direction: column;  gap: 0.35rem;}.bcdn-alias-item {  display: flex;  align-items: center;  justify-content: space-between;  gap: 0.75rem;  padding: 0.35rem 0.5rem;  border-radius: 8px;  background: var(--card);  border: 1px solid var(--card-border);}.bcdn-alias-domain {  font-family: var(--font-family-mono, monospace);  font-size: 0.85rem;  color: var(--text-p0);  word-break: break-all;}.bcdn-col-meta {  display: flex;  flex-wrap: wrap;  gap: 0.4rem;  font-size: 0.9rem;}.bcdn-tag {  padding: 0.2rem 0.6rem;  border-radius: 6px;  background: var(--block);  border: 1px solid var(--block-border);  color: var(--text-p2);  font-size: 0.8rem;}.bcdn-col-info {  display: flex;  justify-content: flex-end;  gap: 0.4rem;}@media (max-width: 768px) {  .bcdn-col-info { justify-content: flex-start; }}.bcdn-badge {  padding: 0.2rem 0.6rem;  border-radius: 999px;  font-size: 0.75rem;  font-weight: 600;  background: rgba(33, 150, 243, 0.1);  color: var(--bcdn-accent);}.bcdn-badge.is-gray {  background: var(--block);  color: var(--text-p2);}.bcdn-badge.is-v6 {  background: rgba(103, 58, 183, 0.1);  color: #673ab7;}.bcdn-details {  grid-column: 1 / -1;  margin-top: 1rem;  padding-top: 1rem;  border-top: 1px dashed var(--block-border);  display: none;  animation: slide-down 0.2s ease;}.bcdn-row.is-expanded .bcdn-details { display: block; }@keyframes slide-down {  from { opacity: 0; transform: translateY(-5px); }  to { opacity: 1; transform: translateY(0); }}.bcdn-ip-list {  display: flex;  flex-wrap: wrap;  gap: 0.5rem;}.bcdn-ip-item {  font-family: var(--font-family-mono, monospace);  font-size: 0.85rem;  padding: 0.3rem 0.6rem;  border-radius: 6px;  background: var(--site-bg);  border: 1px solid var(--block-border);  cursor: pointer;  transition: background 0.2s;}.bcdn-ip-top {  display: flex;  align-items: center;  gap: 0.5rem;  flex-wrap: wrap;}.bcdn-ip-geo {  font-size: 0.75rem;  color: var(--text-p3);}.bcdn-ip-more-btn {  font-size: 0.75rem;  padding: 0;  border: 0;  background: none;  color: var(--bcdn-accent);  cursor: pointer;}.bcdn-ip-more-btn:hover {  text-decoration: underline;}.bcdn-ip-sources {  display: none;  margin-top: 0.35rem;  padding-top: 0.35rem;  border-top: 1px dashed var(--block-border);  color: var(--text-p2);  font-size: 0.75rem;  line-height: 1.35;  cursor: text;}.bcdn-ip-item.is-open .bcdn-ip-sources { display: block; }.bcdn-ip-source {  display: flex;  gap: 0.4rem;  word-break: break-all;}.bcdn-ip-source .k {  color: var(--text-p3);  white-space: nowrap;}.bcdn-ip-item:hover {  background: var(--block-hover);  color: var(--bcdn-accent);  border-color: var(--bcdn-accent);}.bcdn-status-msg {  padding: 2rem;  text-align: center;  color: var(--text-p3);  font-style: italic;}.bcdn-status-msg.is-loading {  display: flex;  align-items: center;  justify-content: center;  gap: 10px;}.bcdn-spinner {  width: 20px;  height: 20px;  display: inline-flex;  flex: 0 0 auto;}.bcdn-spinner svg {  width: 20px;  height: 20px;  display: block;}.bcdn-version-wrap {  display: flex;  align-items: center;  gap: 8px;  position: relative;  padding: 0 28px 0 10px;  border: 1px solid var(--block-border);  background: var(--block);  border-radius: 10px;  height: var(--bcdn-control-h);  box-sizing: border-box;}.bcdn-version-wrap:hover {  background: var(--block-hover);}.bcdn-version-wrap:focus-within {  border-color: var(--bcdn-accent);  box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.15);}.bcdn-version-wrap::after {  content: '▾';  position: absolute;  right: 10px;  top: 50%;  transform: translateY(-50%);  color: var(--text-p2);  font-size: 12px;  line-height: 1;  pointer-events: none;}.bcdn-version-label {  font-size: 12px;  color: var(--text-p2);  line-height: var(--bcdn-control-h);  user-select: none;  display: block;  height: var(--bcdn-control-h);  white-space: nowrap;}.bcdn-version {  background: transparent;  border: none;  color: var(--text-p0);  outline: none;  font-size: 14px;  line-height: var(--bcdn-control-h);  cursor: pointer;  padding: 0;  height: var(--bcdn-control-h);  flex: 1 1 auto;  min-width: 7ch;  -webkit-appearance: none;  appearance: none;}.bcdn-version::-ms-expand {  display: none;}.bcdn-btn {  display: inline-flex;  align-items: center;  justify-content: center;  gap: 6px;  padding: 0 10px;  border: 1px solid var(--block-border);  background: var(--block);  color: var(--text-p0);  border-radius: 10px;  cursor: pointer;  user-select: none;  font-size: 14px;  line-height: 1.2;  height: var(--bcdn-control-h);  box-sizing: border-box;}.bcdn-btn:hover {  background: var(--block-hover);}.bcdn-btn:active {  transform: translateY(1px);}.bcdn-btn.is-active {  border-color: var(--bcdn-accent);  color: var(--bcdn-accent);}.bcdn-btn.is-ghost {  background: transparent;}.bcdn-btn:disabled {  opacity: 0.6;  cursor: not-allowed;}.bcdn-compare-panel {  display: inline-flex;  flex-wrap: wrap;  gap: 0.75rem;  align-items: center;  align-self: flex-start;  width: fit-content;  max-width: 100%;  padding: 0.5rem;  border: 1px solid var(--block-border);  border-radius: var(--border-card, 12px);  background: var(--site-bg);  box-sizing: border-box;}@media (max-width: 768px) {  .bcdn-compare-panel {    display: flex;    align-self: stretch;    width: 100%;    flex-direction: column;    align-items: stretch;  }  .bcdn-compare-panel .bcdn-version-wrap {    width: 100%;    justify-content: space-between;  }}.bcdn-actions {  display: inline-flex;  align-items: center;  gap: 0.75rem;}@media (max-width: 768px) {  .bcdn-actions {    width: 100%;    justify-content: flex-end;  }}.bcdn-ip-sources.is-always {  display: block;}.st-toast {    position: fixed;    bottom: 24px;    left: 50%;    transform: translate(-50%, 100%);    background: var(--card);    border: 1px solid var(--theme-highlight);    color: var(--text-p0);    padding: 10px 16px;    border-radius: 40px;    box-shadow: 0 8px 20px rgba(0,0,0,0.2);    opacity: 0;    transition: all 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28);    z-index: 9999;    font-size: 14px;    pointer-events: none;}.st-toast.show {    transform: translate(-50%, 0);    opacity: 1;}</style><div id="bcdn-app" class="bcdn-app"><div class="bcdn-toolbar"><div class="bcdn-tools-row">  <div class="bcdn-search-wrap">    <span class="bcdn-search-icon">🔍</span>    <input type="text" id="bcdn-input" class="bcdn-search" placeholder="搜索域名 / IP / 地区..." autocomplete="off">  </div>  <div id="bcdn-summary" class="bcdn-stats" aria-label="概览"></div></div><div class="bcdn-tools-row">  <div id="bcdn-updated" class="bcdn-stats" aria-label="更新时间"></div>  <div class="bcdn-actions" aria-label="版本与对比">    <div class="bcdn-version-wrap" title="切换数据版本">      <label class="bcdn-version-label" for="bcdn-version">版本</label>      <select id="bcdn-version" class="bcdn-version" aria-label="Bilibili 自建视频云数据版本"></select>    </div>    <button id="bcdn-compare-toggle" class="bcdn-btn" type="button" title="对比两个数据版本">对比</button>  </div></div><div id="bcdn-compare-panel" class="bcdn-compare-panel" style="display:none" aria-label="版本对比">  <div class="bcdn-version-wrap" title="基准版本 (A)">    <label class="bcdn-version-label" for="bcdn-compare-base">A</label>    <select id="bcdn-compare-base" class="bcdn-version" aria-label="对比基准版本"></select>  </div>  <div class="bcdn-version-wrap" title="对比版本 (B)">    <label class="bcdn-version-label" for="bcdn-compare-target">B</label>    <select id="bcdn-compare-target" class="bcdn-version" aria-label="对比目标版本"></select>  </div>  <button id="bcdn-compare-run" class="bcdn-btn" type="button" title="开始对比">开始对比</button>  <button id="bcdn-compare-exit" class="bcdn-btn is-ghost" type="button" title="退出对比">退出</button></div><div id="bcdn-breadcrumbs" class="bcdn-breadcrumbs" style="display:none"></div></div><div class="bcdn-header"><div id="bcdn-col-1">名称 / 域名</div><div id="bcdn-col-2">节点数 / 运营商</div><div id="bcdn-col-3" style="text-align:right">更多信息</div></div><div id="bcdn-list" class="bcdn-list"></div><div id="bcdn-sentinel" style="height: 20px; margin-top: 1rem;"></div><div class="st-toast" id="st-toast">已复制</div></div><script>(() => {  // CONFIG: 在此处添加新数据版本号 (由于这是纯静态页面，需手动维护)  const versionList = ['20251009', '20260205'];  const URL_PARAM_VERSION = 'bcdnv';  const DATA_DIR = '/uploads/Bilibili-Self-hosted-CDN-Overview';  const buildApiUrl = (version) => `${DATA_DIR}/${version}/bcdn_out.json`;  const BATCH_SIZE = 50;  const versionDataCache = new Map();  const isNumericVersion = (v) => typeof v === 'string' && /^\d+$/.test(v);  const safeVersionList = versionList.filter(isNumericVersion);  const compareNumericStrings = (a, b) => {    if (a.length !== b.length) return a.length - b.length;    return a.localeCompare(b);  };  const getOrderedVersionsDesc = () => [...safeVersionList].sort((a, b) => compareNumericStrings(b, a));  let currentVersion = null;  let isDataLoading = false;    let rawData = null;  const providerMap = new Map();  let allProviders = [];  let allNodes = [];    let searchIndex = null;    const currentState = {    view: 'home',    providerKey: null,    regionKey: null,    searchQuery: '',    visibleCount: 0,    listData: [],    pendingRender: false,    compareOpen: false,    isDiffActive: false,    diffBaseVersion: null,    diffTargetVersion: null,    diffAll: [],    diffIndex: null,    diffStats: null,    diffGeneratedAt: null  };    const el = {    list: null, input: null, summary: null, updated: null, breadcrumbs: null,    sentinel: null, toast: null, header1: null, header2: null, header3: null,    version: null,    compareToggle: null,    comparePanel: null,    compareBase: null,    compareTarget: null,    compareRun: null,    compareExit: null  };    const templates = {    providerRow: (p) => `<div class="bcdn-row" data-type="provider" data-key="${p.key}">      <div class="bcdn-col-main"><span class="bcdn-icon">🏢</span><span class="bcdn-main-text">${p.name}</span></div>      <div class="bcdn-col-meta"><span class="bcdn-badge is-gray">${p.nodeCount} 节点</span><span class="bcdn-tag">${p.regionCount} 地区</span></div>      <div class="bcdn-col-info"><span class="bcdn-badge">点击查看 ></span></div>    </div>`,        regionRow: (r) => `<div class="bcdn-row" data-type="region" data-provider="${r.providerKey}" data-key="${r.key}">      <div class="bcdn-col-main"><span class="bcdn-icon">📍</span><span class="bcdn-main-text">${r.name}</span></div>      <div class="bcdn-col-meta"><span class="bcdn-badge is-gray">${r.nodeCount} 节点</span></div>      <div class="bcdn-col-info"><span class="bcdn-badge">点击查看 ></span></div>    </div>`,        nodeRow: (node, q) => {      const domain = q ? highlightText(node.domain, q) : node.domain;      const alias = node.aliasCount ? `<span class="bcdn-alias-tag" title="${node.aliasCount} 个别名解析">ALT:${node.aliasCount}</span>` : '';      return `<div class="bcdn-row" data-type="node" data-domain="${node.domain}">        <div class="bcdn-col-main"><span class="bcdn-icon">🌐</span><div class="bcdn-main-text bcdn-domain">${domain} ${alias}</div></div>        <div class="bcdn-col-meta"><span class="bcdn-tag">${node.provider}</span><span class="bcdn-tag">${node.region}</span></div>        <div class="bcdn-col-info">${node.v4c ? `<span class="bcdn-badge">IPv4: ${node.v4c}</span>` : ''}${node.v6c ? `<span class="bcdn-badge is-v6">IPv6: ${node.v6c}</span>` : ''}</div>        <div class="bcdn-details"></div>      </div>`;    },    diffRow: (d, q) => {      const domain = q ? highlightText(d.domain, q) : d.domain;      const icon = d.kind === 'added' ? '🆕' : (d.kind === 'removed' ? '🗑️' : '🔁');      let metaHtml = '';      if (d.kind === 'added') {        metaHtml = `<span class="bcdn-tag">B: ${escapeHtml(d.to.provider)}</span><span class="bcdn-tag">${escapeHtml(d.to.region)}</span>`;      } else if (d.kind === 'removed') {        metaHtml = `<span class="bcdn-tag">A: ${escapeHtml(d.from.provider)}</span><span class="bcdn-tag">${escapeHtml(d.from.region)}</span>`;      } else {        metaHtml = `<span class="bcdn-tag">A: ${escapeHtml(d.from.provider)}/${escapeHtml(d.from.region)}</span><span class="bcdn-tag">B: ${escapeHtml(d.to.provider)}/${escapeHtml(d.to.region)}</span>`;      }      const chips = [];      if (d.kind === 'added') {        if (d.to.v4c) chips.push(`<span class="bcdn-badge">IPv4: ${d.to.v4c}</span>`);        if (d.to.v6c) chips.push(`<span class="bcdn-badge is-v6">IPv6: ${d.to.v6c}</span>`);        chips.push(`<span class="bcdn-badge">新增</span>`);      } else if (d.kind === 'removed') {        if (d.from.v4c) chips.push(`<span class="bcdn-badge">IPv4: ${d.from.v4c}</span>`);        if (d.from.v6c) chips.push(`<span class="bcdn-badge is-v6">IPv6: ${d.from.v6c}</span>`);        chips.push(`<span class="bcdn-badge is-gray">移除</span>`);      } else {        if (d.ipDiff?.v4?.add?.length) chips.push(`<span class="bcdn-badge">+v4 ${d.ipDiff.v4.add.length}</span>`);        if (d.ipDiff?.v4?.del?.length) chips.push(`<span class="bcdn-badge is-gray">-v4 ${d.ipDiff.v4.del.length}</span>`);        if (d.ipDiff?.v6?.add?.length) chips.push(`<span class="bcdn-badge is-v6">+v6 ${d.ipDiff.v6.add.length}</span>`);        if (d.ipDiff?.v6?.del?.length) chips.push(`<span class="bcdn-badge is-gray">-v6 ${d.ipDiff.v6.del.length}</span>`);        if (d.metaChangedCount) chips.push(`<span class="bcdn-badge">meta ${d.metaChangedCount}</span>`);        chips.push(`<span class="bcdn-badge">变更</span>`);      }      return `<div class="bcdn-row" data-type="diff" data-domain="${d.domain}">        <div class="bcdn-col-main"><span class="bcdn-icon">${icon}</span><div class="bcdn-main-text bcdn-domain">${domain}</div></div>        <div class="bcdn-col-meta">${metaHtml}</div>        <div class="bcdn-col-info">${chips.join('')}</div>        <div class="bcdn-details"></div>      </div>`;    }  };  function escapeHtml(s) {    if (s == null) return '';    return String(s)      .replace(/&/g, '&amp;')      .replace(/</g, '&lt;')      .replace(/>/g, '&gt;')      .replace(/"/g, '&quot;')      .replace(/'/g, '&#39;');  }  function getVersionFromUrl() {    const v = new URLSearchParams(window.location.search).get(URL_PARAM_VERSION);    if (!v) return null;    if (!isNumericVersion(v)) return null;    return safeVersionList.includes(v) ? v : null;  }  function setVersionToUrl(version) {    try {      const url = new URL(window.location.href);      if (version) url.searchParams.set(URL_PARAM_VERSION, version);      else url.searchParams.delete(URL_PARAM_VERSION);      history.replaceState(null, '', url.toString());    } catch (e) {}  }  function setVersionSelectValue(version) {    if (!el.version) return;    if (el.version.value !== version) el.version.value = version;  }  function initVersionSelector(initialVersion) {    if (!el.version) return;    const ordered = getOrderedVersionsDesc();    el.version.innerHTML = ordered      .map(v => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`)      .join('');    const safeInitial = isNumericVersion(initialVersion) && safeVersionList.includes(initialVersion)      ? initialVersion      : ordered[0];    setVersionSelectValue(safeInitial);    el.version.addEventListener('change', handleVersionChange);  }  function snapshotUiState() {    return {      view: currentState.view,      providerKey: currentState.providerKey,      regionKey: currentState.regionKey,      query: el.input ? el.input.value : '',      compareOpen: currentState.compareOpen,      isDiffActive: currentState.isDiffActive,      diffBaseVersion: currentState.diffBaseVersion,      diffTargetVersion: currentState.diffTargetVersion    };  }  function restoreUiState(snapshot) {    if (typeof snapshot?.compareOpen === 'boolean') {      setCompareOpen(snapshot.compareOpen);    }    if (snapshot?.diffBaseVersion && el.compareBase) el.compareBase.value = snapshot.diffBaseVersion;    if (snapshot?.diffTargetVersion && el.compareTarget) el.compareTarget.value = snapshot.diffTargetVersion;    if (snapshot?.isDiffActive && currentState.isDiffActive && currentState.diffAll.length) {      resetView('diff');      if ((snapshot?.query || '').trim()) {        el.input.value = (snapshot.query || '').trim();        el.input.dispatchEvent(new Event('input'));      }      return;    }    const q = (snapshot?.query || '').trim();    if (q) {      el.input.value = q;      el.input.dispatchEvent(new Event('input'));      return;    }    if (snapshot?.view === 'region' && snapshot.providerKey && snapshot.regionKey) {      const p = providerMap.get(snapshot.providerKey);      const r = p?.regionMap?.get(snapshot.regionKey);      if (r) {        window.enterRegion(snapshot.providerKey, snapshot.regionKey);        return;      }    }    if (snapshot?.view === 'provider' && snapshot.providerKey) {      if (providerMap.has(snapshot.providerKey)) {        window.enterProvider(snapshot.providerKey);        return;      }    }    window.enterHome();  }  async function fetchVersionData(version) {    const resp = await fetch(buildApiUrl(version));    if (!resp.ok) throw new Error('数据加载失败');    return await resp.json();  }  async function fetchVersionDataCached(version) {    if (versionDataCache.has(version)) return await versionDataCache.get(version);    const p = fetchVersionData(version);    versionDataCache.set(version, p);    return await p;  }  async function loadVersion(version, { updateUrl = true } = {}) {    if (isDataLoading) return;    isDataLoading = true;    if (el.version) el.version.disabled = true;    renderStatus(`正在加载节点数据 (${version})...`, false, true);    try {      const data = await fetchVersionData(version);      rawData = data;      processData(rawData);      currentVersion = version;      setVersionSelectValue(version);      if (updateUrl) setVersionToUrl(version);    } finally {      isDataLoading = false;      if (el.version) el.version.disabled = false;    }  }  async function loadInitialVersion(preferredVersion) {    const latestFirst = getOrderedVersionsDesc();    const candidates = [];    if (isNumericVersion(preferredVersion) && safeVersionList.includes(preferredVersion)) candidates.push(preferredVersion);    for (const v of latestFirst) {      if (!candidates.includes(v)) candidates.push(v);    }    let lastErr = null;    for (const v of candidates) {      try {        await loadVersion(v, { updateUrl: false });        setVersionToUrl(v);        return;      } catch (e) {        lastErr = e;      }    }    throw lastErr || new Error('无可用数据版本');  }  function initElements() {    el.list = document.getElementById('bcdn-list');    el.input = document.getElementById('bcdn-input');    el.summary = document.getElementById('bcdn-summary');    el.updated = document.getElementById('bcdn-updated');    el.breadcrumbs = document.getElementById('bcdn-breadcrumbs');    el.sentinel = document.getElementById('bcdn-sentinel');    el.toast = document.getElementById('st-toast');    el.version = document.getElementById('bcdn-version');    el.header1 = document.getElementById('bcdn-col-1');    el.header2 = document.getElementById('bcdn-col-2');    el.header3 = document.getElementById('bcdn-col-3');    el.compareToggle = document.getElementById('bcdn-compare-toggle');    el.comparePanel = document.getElementById('bcdn-compare-panel');    el.compareBase = document.getElementById('bcdn-compare-base');    el.compareTarget = document.getElementById('bcdn-compare-target');    el.compareRun = document.getElementById('bcdn-compare-run');    el.compareExit = document.getElementById('bcdn-compare-exit');  }  function setCompareOpen(open) {    currentState.compareOpen = Boolean(open);    if (el.comparePanel) el.comparePanel.style.display = currentState.compareOpen ? 'flex' : 'none';    if (el.compareToggle) el.compareToggle.classList.toggle('is-active', currentState.compareOpen);  }  function initCompareControls() {    if (!el.compareToggle || !el.comparePanel || !el.compareBase || !el.compareTarget || !el.compareRun || !el.compareExit) return;    const ordered = getOrderedVersionsDesc();    const optionHtml = ordered.map(v => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join('');    el.compareBase.innerHTML = optionHtml;    el.compareTarget.innerHTML = optionHtml;    const baseDefault = ordered[0];    const targetDefault = ordered.length > 1 ? ordered[1] : ordered[0];    el.compareBase.value = baseDefault;    el.compareTarget.value = targetDefault;    const ensureDistinct = () => {      if (el.compareBase.value === el.compareTarget.value) {        const other = ordered.find(v => v !== el.compareBase.value);        if (other) el.compareTarget.value = other;      }    };    el.compareBase.addEventListener('change', ensureDistinct);    el.compareTarget.addEventListener('change', ensureDistinct);    el.compareToggle.addEventListener('click', () => {      setCompareOpen(!currentState.compareOpen);    });    el.compareExit.addEventListener('click', () => {      resetDiffState();      setCompareOpen(false);      window.enterHome();    });    el.compareRun.addEventListener('click', () => {      runCompare();    });    setCompareOpen(false);  }  async function init() {    initElements();    if (safeVersionList.length === 0) {      renderStatus('加载出错: versionList 无可用的纯数字版本', true);      return;    }    const preferred = getVersionFromUrl() || getOrderedVersionsDesc()[0];    initVersionSelector(preferred);    initCompareControls();    renderStatus('正在加载节点数据...', false, true);        try {      await loadInitialVersion(preferred);            resetView('home');      setupEventDelegation();      setupSearch();      setupInfiniteScroll();    } catch (err) {      renderStatus('加载出错: ' + err.message, true);    }  }  async function handleVersionChange() {    if (!el.version) return;    const selected = el.version.value;    if (!isNumericVersion(selected) || !safeVersionList.includes(selected)) {      setVersionSelectValue(currentVersion || getOrderedVersionsDesc()[0]);      return;    }    if (!selected || selected === currentVersion) return;    const prevVersion = currentVersion;    const snapshot = snapshotUiState();    try {      await loadVersion(selected, { updateUrl: true });      restoreUiState(snapshot);    } catch (err) {      showToast(`版本 ${selected} 加载失败: ${err.message}`);      if (prevVersion) {        setVersionSelectValue(prevVersion);        setVersionToUrl(prevVersion);      }      restoreUiState(snapshot);    }  }  function processData(data) {    providerMap.clear();    allProviders = [];    allNodes = [];        const results = data.results || {};    const entries = Object.entries(results);        for (const [pKey, pVal] of entries) {      const pName = pVal.name || pKey;      const regionsData = pVal.regions || {};      const regionMap = new Map();      const pRegions = [];      let pNodeCount = 0;            for (const [rKey, rVal] of Object.entries(regionsData)) {        const rName = rVal.name || rKey;        const rNodes = [];        const rawNodes = rVal.nodes || [];                for (const node of rawNodes) {          const v4Entries = Object.entries(node.ips?.v4 || {});          const v6Entries = Object.entries(node.ips?.v6 || {});                    const v4 = v4Entries.map(([ip, meta]) => ({ ip, meta, type: 'v4' }));          const v6 = v6Entries.map(([ip, meta]) => ({ ip, meta, type: 'v6' }));          const ips = [...v4, ...v6];                    let aliasCount = 0;          for (const ipObj of ips) {            aliasCount += ipObj.meta?.alias?.length || 0;          }                    const nodeObj = {            domain: node.domain,            providerKey: pKey,            provider: pName,            regionKey: rKey,            region: rName,            ips,            v4c: v4.length,            v6c: v6.length,            aliasCount,            searchStr: `${node.domain} ${pName} ${rName} ${ips.map(i => i.ip).join(' ')}`.toLowerCase()          };                    rNodes.push(nodeObj);          allNodes.push(nodeObj);        }                if (rNodes.length > 0) {          const regionObj = {            key: rKey,            name: rName,            providerKey: pKey,            providerName: pName,            nodeCount: rNodes.length,            nodes: rNodes          };          pRegions.push(regionObj);          regionMap.set(rKey, regionObj);          pNodeCount += rNodes.length;        }      }            pRegions.sort((a, b) => b.nodeCount - a.nodeCount);            if (pNodeCount > 0) {        const providerObj = {          key: pKey,          name: pName,          nodeCount: pNodeCount,          regionCount: pRegions.length,          regions: pRegions,          regionMap        };        allProviders.push(providerObj);        providerMap.set(pKey, providerObj);      }    }        allProviders.sort((a, b) => b.nodeCount - a.nodeCount);  }  function setupEventDelegation() {    el.list.addEventListener('click', (e) => {      const row = e.target.closest('.bcdn-row');      if (!row) return;      const copyBtn = e.target.closest('.bcdn-copy-btn');      if (copyBtn) {        e.stopPropagation();        const text = copyBtn.dataset.copy;        if (text) copyText(text);        return;      }            const type = row.dataset.type;      const moreBtn = e.target.closest('.bcdn-ip-more-btn');      if (moreBtn) {        e.stopPropagation();        const item = moreBtn.closest('.bcdn-ip-item');        if (item) {          const isOpen = item.classList.toggle('is-open');          moreBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');        }        return;      }      if (e.target.closest('.bcdn-ip-sources')) {        e.stopPropagation();        return;      }            if (e.target.closest('.bcdn-ip-item')) {        e.stopPropagation();        copyText(e.target.closest('.bcdn-ip-item').dataset.ip);        return;      }            if (e.target.closest('.bcdn-details')) {        e.stopPropagation();        return;      }            if (type === 'provider') {        enterProvider(row.dataset.key);      } else if (type === 'region') {        enterRegion(row.dataset.provider, row.dataset.key);      } else if (type === 'node') {        toggleNodeExpand(row);      } else if (type === 'diff') {        toggleDiffExpand(row);      }    });  }  function renderNodeDetails(node) {    const domain = escapeHtml(node.domain);    const ipHtml = `<div class="bcdn-section-title">IP 地址（点击复制）</div>      <div class="bcdn-ip-list">${node.ips.map(ip => {        const ipText = escapeHtml(ip.ip);        const zxinc = ip.meta?.zxinc ? escapeHtml(ip.meta.zxinc) : '';        const bilibili = ip.meta?.bilibili ? escapeHtml(ip.meta.bilibili) : '';        const qifu = ip.meta?.qifu ? escapeHtml(ip.meta.qifu) : '';        const hasExtra = Boolean(bilibili || qifu);        const sourcesHtml = hasExtra ? `          <div class="bcdn-ip-sources">            ${bilibili ? `<div class=\"bcdn-ip-source\"><span class=\"k\">bilibili</span><span>${bilibili}</span></div>` : ''}            ${qifu ? `<div class=\"bcdn-ip-source\"><span class=\"k\">qifu</span><span>${qifu}</span></div>` : ''}          </div>` : '';        const geoHtml = zxinc ? `<span class="bcdn-ip-geo">${zxinc}</span>` : '';        const moreBtnHtml = hasExtra          ? `<button class="bcdn-ip-more-btn" type="button" aria-expanded="false">更多来源</button>`          : '';        return `<div class="bcdn-ip-item" data-ip="${ipText}" title="点击复制">          <div class="bcdn-ip-top">            <span class="bcdn-ip-text">${ipText}</span>            ${geoHtml}            ${moreBtnHtml}          </div>          ${sourcesHtml}        </div>`;      }).join('')}</div>`;    const aliasGroups = node.ips      .map(ip => {        const aliases = Array.isArray(ip.meta?.alias) ? ip.meta.alias : [];        if (!aliases.length) return '';        return `<div class="bcdn-alias-group">          <div class="bcdn-alias-group-title"><span class="ip">${escapeHtml(ip.ip)}</span><span>${aliases.length} ALT</span></div>          <div class="bcdn-alias-list">${aliases.map(a => {            const aliasDomain = escapeHtml(a);            return `<div class="bcdn-alias-item">              <span class="bcdn-alias-domain">${aliasDomain}</span>              <span class="bcdn-copy-btn" role="button" tabindex="0" title="复制别名域名" data-copy="${aliasDomain}">📋</span>            </div>`;          }).join('')}</div>        </div>`;      })      .filter(Boolean)      .join('');    const aliasHtml = aliasGroups      ? `<div class="bcdn-section-title">别名解析（ALT）</div><div class="bcdn-alias-groups">${aliasGroups}</div>`      : '';    return `      <div class="bcdn-detail-head">        <div class="bcdn-detail-domain">${domain}</div>        <span class="bcdn-copy-btn" role="button" tabindex="0" title="复制域名" data-copy="${domain}">📋</span>      </div>      ${ipHtml}      ${aliasHtml}    `;  }    function toggleNodeExpand(row) {    const isExpanded = row.classList.toggle('is-expanded');        if (isExpanded) {      const details = row.querySelector('.bcdn-details');      if (details && !details.dataset.rendered) {        const domain = row.dataset.domain;        const node = allNodes.find(n => n.domain === domain);        if (node) {          details.innerHTML = renderNodeDetails(node);          details.dataset.rendered = '1';        }      }    }  }  function resetView(mode, context = null) {    currentState.view = mode;    currentState.visibleCount = 0;    el.list.innerHTML = '';        if (mode === 'home') {      currentState.providerKey = null;      currentState.regionKey = null;      currentState.listData = allProviders;      updateHeader('provider');      updateBreadcrumbs([]);    } else if (mode === 'provider') {      const p = providerMap.get(context);      currentState.providerKey = context;      currentState.regionKey = null;      currentState.listData = p ? p.regions : [];      updateHeader('region');      updateBreadcrumbs([{ label: p?.name || context, action: null }]);    } else if (mode === 'region') {      const p = providerMap.get(context.providerKey);      const r = p?.regionMap.get(context.regionKey);      currentState.providerKey = context.providerKey;      currentState.regionKey = context.regionKey;      currentState.listData = r ? r.nodes : [];      updateHeader('node');      updateBreadcrumbs([        { label: p?.name || context.providerKey, action: `window.enterProvider('${context.providerKey}')` },        { label: r?.name || context.regionKey, action: null }      ]);    } else if (mode === 'search') {      currentState.providerKey = null;      currentState.regionKey = null;      updateHeader('node');      updateBreadcrumbs([{ label: '搜索结果', action: null }]);    } else if (mode === 'diff') {      currentState.providerKey = null;      currentState.regionKey = null;      currentState.view = 'diff';      currentState.listData = currentState.diffAll || [];      updateHeader('diff');      const base = currentState.diffBaseVersion || 'A';      const target = currentState.diffTargetVersion || 'B';      updateBreadcrumbs([{ label: `对比 ${base} → ${target}`, action: null }]);    }        scheduleRender();    updateStats();  }  function updateHeader(type) {    const headers = {      provider: ['服务商 / 来源', '节点规模 / 覆盖区域', '数据详情'],      region: ['地区 / 区域', '节点数量', '详情'],      node: ['域名 / 节点', '地区 / 运营商', 'IP 地址'],      diff: ['变化域名', '归属 (A / B)', '变化概览']    };    const h = headers[type] || headers.node;    el.header1.textContent = h[0];    el.header2.textContent = h[1];    el.header3.textContent = h[2];  }    function updateBreadcrumbs(path) {    if (currentState.view === 'home' && !el.input.value) {      el.breadcrumbs.style.display = 'none';      return;    }        el.breadcrumbs.style.display = 'flex';    const parts = [`<span class="bcdn-bc-item" onclick="window.enterHome()">首页</span>`];        for (const item of path) {      parts.push(`<span class="bcdn-bc-sep">/</span>`);      parts.push(item.action         ? `<span class="bcdn-bc-item" onclick="${item.action}">${item.label}</span>`        : `<span class="bcdn-bc-item active">${item.label}</span>`      );    }        el.breadcrumbs.innerHTML = parts.join('');  }  function scheduleRender() {    if (currentState.pendingRender) return;    currentState.pendingRender = true;    requestAnimationFrame(() => {      renderBatch();      currentState.pendingRender = false;    });  }  function renderBatch() {    const list = currentState.listData;    const start = currentState.visibleCount;    if (start >= list.length) return;        const view = currentState.view;    const end = Math.min(start + BATCH_SIZE, list.length);    const batch = list.slice(start, end);    const q = currentState.searchQuery;        let html = '';    if (view === 'home') {      for (const p of batch) html += templates.providerRow(p);    } else if (view === 'provider') {      for (const r of batch) html += templates.regionRow(r);    } else if (view === 'diff') {      for (const d of batch) html += templates.diffRow(d, q);    } else {      for (const n of batch) html += templates.nodeRow(n, q);    }        el.list.insertAdjacentHTML('beforeend', html);    currentState.visibleCount = end;  }  function scrollToListTop() {    const header = document.querySelector('.bcdn-header');    const headerVisible = !!(header && header.getClientRects().length);    const target = headerVisible      ? header      : (el.list || document.getElementById('bcdn-list')) || document.querySelector('.bcdn-toolbar') || document.getElementById('bcdn-app');    if (!target) return;    const prefersReducedMotion = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);    const behavior = prefersReducedMotion ? 'auto' : 'smooth';    const toolbar = document.querySelector('.bcdn-toolbar');    const toolbarStyle = toolbar ? getComputedStyle(toolbar) : null;    const stickyTop = toolbarStyle ? (parseFloat(toolbarStyle.top) || 0) : 0;    const toolbarHeight = toolbar ? toolbar.getBoundingClientRect().height : 0;    const topOffset = Math.max(24, stickyTop + toolbarHeight + 16);    const rect = target.getBoundingClientRect();    const top = Math.max(0, window.scrollY + rect.top - topOffset);    window.scrollTo({ top, behavior });  }  window.enterHome = () => {    el.input.value = '';    resetDiffState();    resetView('home');    scrollToListTop();  };    const enterProvider = window.enterProvider = (key) => {    el.input.value = '';    resetView('provider', key);    scrollToListTop();  };    const enterRegion = window.enterRegion = (providerKey, regionKey) => {    el.input.value = '';    resetView('region', { providerKey, regionKey });    scrollToListTop();  };  function setupSearch() {    let timeout;    el.input.addEventListener('input', (e) => {      clearTimeout(timeout);      timeout = setTimeout(() => {        const q = e.target.value.trim().toLowerCase();        currentState.searchQuery = q;                if (!q) {          if (currentState.isDiffActive) {            resetView('diff');          } else if (currentState.regionKey && currentState.providerKey) {            resetView('region', { providerKey: currentState.providerKey, regionKey: currentState.regionKey });          } else if (currentState.providerKey) {            resetView('provider', currentState.providerKey);          } else {            resetView('home');          }        } else {          if (currentState.isDiffActive) {            currentState.view = 'diff';            currentState.listData = (currentState.diffAll || []).filter(d => d.searchStr.includes(q));            currentState.visibleCount = 0;            el.list.innerHTML = '';            updateHeader('diff');            const base = currentState.diffBaseVersion || 'A';            const target = currentState.diffTargetVersion || 'B';            updateBreadcrumbs([{ label: `对比 ${base} → ${target}`, action: null }]);            scheduleRender();            updateStats();            if (currentState.listData.length === 0) {              renderStatus('未找到匹配的变化项', false);            }          } else {            currentState.view = 'search';            currentState.listData = allNodes.filter(n => n.searchStr.includes(q));            currentState.visibleCount = 0;            el.list.innerHTML = '';                        updateHeader('node');            updateBreadcrumbs([{ label: '搜索结果', action: null }]);            scheduleRender();            updateStats();                        if (currentState.listData.length === 0) {              renderStatus('未找到匹配的节点', false);            }          }        }      }, 300);    });  }  function setupInfiniteScroll() {    const observer = new IntersectionObserver((entries) => {      if (entries[0].isIntersecting && currentState.visibleCount < currentState.listData.length) {        scheduleRender();      }    }, { rootMargin: '300px' });    observer.observe(el.sentinel);  }  function renderStatus(msg, isError, isLoading = false) {    const safeMsg = escapeHtml(msg);    const spinner = isLoading      ? `<span class="bcdn-spinner" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path stroke-dasharray="60" stroke-dashoffset="60" stroke-opacity=".3" d="M12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="1.3s" values="60;0"/></path><path stroke-dasharray="15" stroke-dashoffset="15" d="M12 3C16.9706 3 21 7.02944 21 12"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.3s" values="15;0"/><animateTransform attributeName="transform" dur="1.5s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></g></svg></span>`      : '';    el.list.innerHTML = `<div class="bcdn-status-msg${isLoading ? ' is-loading' : ''}"${isError ? ' style="color:red"' : ''}>${spinner}${safeMsg}</div>`;  }    function updateStats() {    const count = currentState.listData.length;    if (currentState.isDiffActive) {      const s = currentState.diffStats || { total: 0, added: 0, removed: 0, changed: 0 };      const ga = currentState.diffGeneratedAt || {};      const base = currentState.diffBaseVersion || 'A';      const target = currentState.diffTargetVersion || 'B';      const baseDate = ga.base?.split(' ')[0] || '-';      const targetDate = ga.target?.split(' ')[0] || '-';      if (el.summary) {        el.summary.innerHTML = `          <span class="bcdn-stat"><b>${s.total}</b> 变化项</span>          <span class="bcdn-stat"><b>${s.added}</b> 新增</span>          <span class="bcdn-stat"><b>${s.removed}</b> 移除</span>          <span class="bcdn-stat"><b>${s.changed}</b> 变更</span>        `;      }      if (el.updated) {        el.updated.innerHTML = `          <span class="bcdn-stat">A ${escapeHtml(base)} 更新于 ${escapeHtml(baseDate)}</span>          <span class="bcdn-stat">B ${escapeHtml(target)} 更新于 ${escapeHtml(targetDate)}</span>          <span class="bcdn-stat"><b>${count}</b> 当前列表</span>        `;      }      return;    }    if (!rawData) return;    if (el.summary) {      el.summary.innerHTML = `        <span class="bcdn-stat"><b>${rawData.count}</b> 总节点</span>        <span class="bcdn-stat"><b>${allProviders.length}</b> 服务商</span>      `;    }    if (el.updated) {      el.updated.innerHTML = `        <span class="bcdn-stat">更新于 ${rawData.generated_at?.split(' ')[0] || '-'}</span>      `;    }  }  function normalizeAliasList(alias) {    const arr = Array.isArray(alias) ? alias : [];    const uniq = [...new Set(arr.filter(Boolean).map(String))];    uniq.sort();    return uniq;  }  function simplifyIpMeta(meta) {    return {      zxinc: meta?.zxinc ? String(meta.zxinc) : '',      bilibili: meta?.bilibili ? String(meta.bilibili) : '',      qifu: meta?.qifu ? String(meta.qifu) : '',      alias: normalizeAliasList(meta?.alias)    };  }  function buildSnapshot(data) {    const map = new Map();    const results = data?.results || {};    for (const [pKey, pVal] of Object.entries(results)) {      const pName = pVal?.name || pKey;      const regionsData = pVal?.regions || {};      for (const [rKey, rVal] of Object.entries(regionsData)) {        const rName = rVal?.name || rKey;        const rawNodes = rVal?.nodes || [];        for (const node of rawNodes) {          const domain = node?.domain;          if (!domain) continue;          const v4Entries = Object.entries(node.ips?.v4 || {});          const v6Entries = Object.entries(node.ips?.v6 || {});          const ips = [];          const v4Set = new Set();          const v6Set = new Set();          const ipMeta = new Map();          for (const [ip, meta] of v4Entries) {            v4Set.add(ip);            const m = simplifyIpMeta(meta);            ipMeta.set(ip, m);            ips.push({ ip, meta: m, type: 'v4' });          }          for (const [ip, meta] of v6Entries) {            v6Set.add(ip);            const m = simplifyIpMeta(meta);            ipMeta.set(ip, m);            ips.push({ ip, meta: m, type: 'v6' });          }          let aliasCount = 0;          for (const ipObj of ips) aliasCount += ipObj.meta?.alias?.length || 0;          const snap = {            domain,            providerKey: pKey,            provider: pName,            regionKey: rKey,            region: rName,            ips,            v4c: v4Entries.length,            v6c: v6Entries.length,            aliasCount,            v4Set,            v6Set,            ipMeta,            searchStr: `${domain} ${pName} ${rName} ${[...v4Set, ...v6Set].join(' ')}`.toLowerCase()          };          map.set(domain, snap);        }      }    }    return map;  }  function diffStringList(a, b) {    const aSet = new Set(a || []);    const bSet = new Set(b || []);    const add = [];    const del = [];    for (const x of bSet) if (!aSet.has(x)) add.push(x);    for (const x of aSet) if (!bSet.has(x)) del.push(x);    add.sort();    del.sort();    return { add, del };  }  function diffSet(aSet, bSet) {    const add = [];    const del = [];    for (const x of bSet) if (!aSet.has(x)) add.push(x);    for (const x of aSet) if (!bSet.has(x)) del.push(x);    add.sort();    del.sort();    return { add, del };  }  function diffSnapshots(baseMap, targetMap) {    const allDomains = new Set([...baseMap.keys(), ...targetMap.keys()]);    const list = [];    let added = 0;    let removed = 0;    let changed = 0;    for (const domain of allDomains) {      const from = baseMap.get(domain) || null;      const to = targetMap.get(domain) || null;      if (!from && to) {        added++;        list.push({          kind: 'added',          domain,          from: null,          to,          ipDiff: null,          metaDiff: [],          metaChangedCount: 0,          searchStr: to.searchStr        });        continue;      }      if (from && !to) {        removed++;        list.push({          kind: 'removed',          domain,          from,          to: null,          ipDiff: null,          metaDiff: [],          metaChangedCount: 0,          searchStr: from.searchStr        });        continue;      }      if (!from || !to) continue;      const ipDiff = {        v4: diffSet(from.v4Set, to.v4Set),        v6: diffSet(from.v6Set, to.v6Set)      };      const metaDiff = [];      const metaIps = new Set([...from.ipMeta.keys(), ...to.ipMeta.keys()]);      for (const ip of metaIps) {        const a = from.ipMeta.get(ip);        const b = to.ipMeta.get(ip);        if (!a || !b) continue;        const changes = {};        if ((a.bilibili || '') !== (b.bilibili || '')) changes.bilibili = { from: a.bilibili || '', to: b.bilibili || '' };        if ((a.qifu || '') !== (b.qifu || '')) changes.qifu = { from: a.qifu || '', to: b.qifu || '' };        const aliasDiff = diffStringList(a.alias, b.alias);        if (aliasDiff.add.length || aliasDiff.del.length) changes.alias = aliasDiff;        if (Object.keys(changes).length) {          metaDiff.push({ ip, changes });        }      }      const hasIpChange = ipDiff.v4.add.length || ipDiff.v4.del.length || ipDiff.v6.add.length || ipDiff.v6.del.length;      const hasMetaChange = metaDiff.length > 0;      if (!hasIpChange && !hasMetaChange) continue;      changed++;      list.push({        kind: 'changed',        domain,        from,        to,        ipDiff,        metaDiff,        metaChangedCount: metaDiff.length,        searchStr: `${from.searchStr} ${to.searchStr}`.toLowerCase()      });    }    const kindOrder = { added: 0, removed: 1, changed: 2 };    list.sort((a, b) => {      const ka = kindOrder[a.kind] ?? 9;      const kb = kindOrder[b.kind] ?? 9;      if (ka !== kb) return ka - kb;      return a.domain.localeCompare(b.domain);    });    return {      list,      stats: { total: list.length, added, removed, changed }    };  }  function resetDiffState() {    currentState.isDiffActive = false;    currentState.diffBaseVersion = null;    currentState.diffTargetVersion = null;    currentState.diffAll = [];    currentState.diffIndex = null;    currentState.diffStats = null;    currentState.diffGeneratedAt = null;  }  async function runCompare() {    if (!el.compareBase || !el.compareTarget) return;    const baseVersion = el.compareBase.value;    const targetVersion = el.compareTarget.value;    if (!isNumericVersion(baseVersion) || !safeVersionList.includes(baseVersion)) {      showToast('请选择有效的基准版本 (A)');      return;    }    if (!isNumericVersion(targetVersion) || !safeVersionList.includes(targetVersion)) {      showToast('请选择有效的对比版本 (B)');      return;    }    if (baseVersion === targetVersion) {      showToast('A / B 不能选择同一个版本');      return;    }    const snapshot = snapshotUiState();    if (el.compareRun) el.compareRun.disabled = true;    if (el.compareBase) el.compareBase.disabled = true;    if (el.compareTarget) el.compareTarget.disabled = true;    renderStatus(`正在计算差异 (${baseVersion} → ${targetVersion})...`, false, true);    try {      const [baseData, targetData] = await Promise.all([        fetchVersionDataCached(baseVersion),        fetchVersionDataCached(targetVersion)      ]);      const baseSnap = buildSnapshot(baseData);      const targetSnap = buildSnapshot(targetData);      const diffRes = diffSnapshots(baseSnap, targetSnap);      currentState.isDiffActive = true;      currentState.diffBaseVersion = baseVersion;      currentState.diffTargetVersion = targetVersion;      currentState.diffAll = diffRes.list;      currentState.diffStats = diffRes.stats;      currentState.diffGeneratedAt = {        base: baseData?.generated_at || '',        target: targetData?.generated_at || ''      };      currentState.diffIndex = new Map(currentState.diffAll.map(d => [d.domain, d]));      el.input.value = '';      resetView('diff');      scrollToListTop();      updateStats();      if (!currentState.diffAll.length) {        renderStatus('两个版本之间没有检测到差异', false);      }    } catch (err) {      showToast(`对比失败: ${err.message || err}`);      restoreUiState(snapshot);    } finally {      if (el.compareRun) el.compareRun.disabled = false;      if (el.compareBase) el.compareBase.disabled = false;      if (el.compareTarget) el.compareTarget.disabled = false;    }  }  function renderIpChipList(ips) {    if (!ips.length) return '<span class="bcdn-tag">无</span>';    return `<div class="bcdn-ip-list">${ips.map(ip => {      const ipText = escapeHtml(ip);      return `<div class="bcdn-ip-item" data-ip="${ipText}" title="点击复制">        <div class="bcdn-ip-top"><span class="bcdn-ip-text">${ipText}</span></div>      </div>`;    }).join('')}</div>`;  }  function renderMetaDiffList(metaDiff) {    if (!metaDiff.length) return '<div class="bcdn-status-msg">无</div>';    return `<div class="bcdn-alias-groups">${metaDiff.map(item => {      const ip = escapeHtml(item.ip);      const c = item.changes || {};      const bilibili = c.bilibili ? `<div class="bcdn-ip-source"><span class="k">bilibili</span><span>${escapeHtml(c.bilibili.from)} → ${escapeHtml(c.bilibili.to)}</span></div>` : '';      const qifu = c.qifu ? `<div class="bcdn-ip-source"><span class="k">qifu</span><span>${escapeHtml(c.qifu.from)} → ${escapeHtml(c.qifu.to)}</span></div>` : '';      let aliasHtml = '';      if (c.alias) {        const add = c.alias.add || [];        const del = c.alias.del || [];        const addHtml = add.length          ? `<div class="bcdn-section-title">新增 ALT</div><div class="bcdn-alias-list">${add.map(a => {              const ad = escapeHtml(a);              return `<div class="bcdn-alias-item"><span class="bcdn-alias-domain">+ ${ad}</span><span class="bcdn-copy-btn" role="button" tabindex="0" title="复制别名域名" data-copy="${ad}">📋</span></div>`;            }).join('')}</div>`          : '';        const delHtml = del.length          ? `<div class="bcdn-section-title">移除 ALT</div><div class="bcdn-alias-list">${del.map(a => {              const ad = escapeHtml(a);              return `<div class="bcdn-alias-item"><span class="bcdn-alias-domain">- ${ad}</span><span class="bcdn-copy-btn" role="button" tabindex="0" title="复制别名域名" data-copy="${ad}">📋</span></div>`;            }).join('')}</div>`          : '';        aliasHtml = addHtml + delHtml;      }      return `<div class="bcdn-alias-group">        <div class="bcdn-alias-group-title"><span class="ip">${ip}</span><span>变化</span></div>        <div class="bcdn-ip-sources is-always">${bilibili}${qifu}</div>        ${aliasHtml}      </div>`;    }).join('')}</div>`;  }  function renderDiffDetails(diffItem) {    const domain = escapeHtml(diffItem.domain);    const base = escapeHtml(currentState.diffBaseVersion || 'A');    const target = escapeHtml(currentState.diffTargetVersion || 'B');    if (diffItem.kind === 'added') {      const nodeLike = diffItem.to;      return `        <div class="bcdn-detail-head">          <div class="bcdn-detail-domain">${domain}</div>          <span class="bcdn-copy-btn" role="button" tabindex="0" title="复制域名" data-copy="${domain}">📋</span>        </div>        <div class="bcdn-section-title">仅在版本 B (${target}) 中存在</div>        <div class="bcdn-col-meta"><span class="bcdn-tag">${escapeHtml(nodeLike.provider)}</span><span class="bcdn-tag">${escapeHtml(nodeLike.region)}</span></div>        ${renderNodeDetails(nodeLike)}      `;    }    if (diffItem.kind === 'removed') {      const nodeLike = diffItem.from;      return `        <div class="bcdn-detail-head">          <div class="bcdn-detail-domain">${domain}</div>          <span class="bcdn-copy-btn" role="button" tabindex="0" title="复制域名" data-copy="${domain}">📋</span>        </div>        <div class="bcdn-section-title">仅在版本 A (${base}) 中存在</div>        <div class="bcdn-col-meta"><span class="bcdn-tag">${escapeHtml(nodeLike.provider)}</span><span class="bcdn-tag">${escapeHtml(nodeLike.region)}</span></div>        ${renderNodeDetails(nodeLike)}      `;    }    const from = diffItem.from;    const to = diffItem.to;    const moved = from.providerKey !== to.providerKey || from.regionKey !== to.regionKey;    const movedHtml = moved      ? `<div class="bcdn-section-title">归属变化</div>         <div class="bcdn-col-meta"><span class="bcdn-tag">A: ${escapeHtml(from.provider)}/${escapeHtml(from.region)}</span><span class="bcdn-tag">B: ${escapeHtml(to.provider)}/${escapeHtml(to.region)}</span></div>`      : `<div class="bcdn-section-title">归属</div>         <div class="bcdn-col-meta"><span class="bcdn-tag">${escapeHtml(from.provider)}/${escapeHtml(from.region)}</span></div>`;    const ipDiff = diffItem.ipDiff || { v4: { add: [], del: [] }, v6: { add: [], del: [] } };    const ipHtml = `      <div class="bcdn-section-title">IP 变化（A → B）</div>      <div class="bcdn-section-title">IPv4 新增</div>${renderIpChipList(ipDiff.v4.add || [])}      <div class="bcdn-section-title">IPv4 移除</div>${renderIpChipList(ipDiff.v4.del || [])}      <div class="bcdn-section-title">IPv6 新增</div>${renderIpChipList(ipDiff.v6.add || [])}      <div class="bcdn-section-title">IPv6 移除</div>${renderIpChipList(ipDiff.v6.del || [])}    `;    const metaHtml = `      <div class="bcdn-section-title">来源 / 别名变化（仅统计同 IP）</div>      ${renderMetaDiffList(diffItem.metaDiff || [])}    `;    return `      <div class="bcdn-detail-head">        <div class="bcdn-detail-domain">${domain}</div>        <span class="bcdn-copy-btn" role="button" tabindex="0" title="复制域名" data-copy="${domain}">📋</span>      </div>      ${movedHtml}      ${ipHtml}      ${metaHtml}    `;  }  function toggleDiffExpand(row) {    const isExpanded = row.classList.toggle('is-expanded');    if (!isExpanded) return;    const details = row.querySelector('.bcdn-details');    if (!details || details.dataset.rendered) return;    const domain = row.dataset.domain;    const diffItem = currentState.diffIndex?.get(domain);    if (!diffItem) return;    details.innerHTML = renderDiffDetails(diffItem);    details.dataset.rendered = '1';  }  function copyText(text) {    if (!navigator.clipboard) {      showToast('浏览器不支持剪贴板 API');      return;    }    navigator.clipboard.writeText(text)      .then(() => showToast('已复制: ' + text))      .catch(err => {        console.error('复制失败:', err);        showToast('复制失败，请手动复制');      });  }  window.copyText = copyText;    let toastTimeout;  function showToast(msg) {    clearTimeout(toastTimeout);    el.toast.textContent = msg;    el.toast.classList.add('show');    toastTimeout = setTimeout(() => el.toast.classList.remove('show'), 2000);  }    function highlightText(text, q) {    const idx = text.toLowerCase().indexOf(q);    if (idx === -1) return text;    return `${text.slice(0, idx)}<mark style="background:rgba(255,235,59,0.3);color:inherit;padding:0">${text.slice(idx, idx + q.length)}</mark>${text.slice(idx + q.length)}`;  }  const boot = () => {    const app = document.getElementById('bcdn-app');    if (!app) return;    if (app.dataset.bcdnInited === '1') return;    app.dataset.bcdnInited = '1';    init();  };  boot();  if (window.InstantClick && typeof window.InstantClick.on === 'function') {    window.InstantClick.on('change', () => {      setTimeout(boot, 0);    });  }})();</script>]]></content>
    
    
    <summary type="html">本项目是对 B 站录播姬 CDN 节点概览页面的致敬与精神延续。采用公开 DNS 扫描的方式，力求尽可能全面地揭示目前 B 站视频云自建节点的网络分布情况。</summary>
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="运维" scheme="https://blog.hanlin.press/tags/%E8%BF%90%E7%BB%B4/"/>
    
    <category term="API" scheme="https://blog.hanlin.press/tags/API/"/>
    
    <category term="CDN" scheme="https://blog.hanlin.press/tags/CDN/"/>
    
    <category term="后端" scheme="https://blog.hanlin.press/tags/%E5%90%8E%E7%AB%AF/"/>
    
    <category term="特殊页面" scheme="https://blog.hanlin.press/tags/%E7%89%B9%E6%AE%8A%E9%A1%B5%E9%9D%A2/"/>
    
  </entry>
  
  <entry>
    <title>2025：拿着过去的碎片，到远方去</title>
    <link href="https://blog.hanlin.press/2025/12/2025-annual-report/"/>
    <id>https://blog.hanlin.press/2025/12/2025-annual-report/</id>
    <published>2025-12-31T00:00:00.000Z</published>
    <updated>2025-12-31T00:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<div class="tag-plugin ds-music" style=" --lrc-display:block;" metingargs="IGF1dG89Imh0dHBzOi8vbXVzaWMuMTYzLmNvbS9zb25nP2lkPTIwOTgzNzI0MjMiIGFwaT0iaHR0cHM6Ly9hcGkuaW5qYWhvdy5jbi9tZXRpbmcvP3NlcnZlcj06c2VydmVyJnR5cGU9OnR5cGUmaWQ9OmlkJmF1dGg9OmF1dGgmcj06ciI="></div><blockquote><p>所谓的历史进程，大概就是一边看着新东西涌进来，一边目送旧东西离开。</p></blockquote><h2 id="引子"><a href="#引子" class="headerlink" title="引子"></a>引子</h2><h3 id="历史的进程"><a href="#历史的进程" class="headerlink" title="历史的进程"></a>历史的进程</h3><p>在用 <code>hexo new</code> 指令创建这一篇文章之前，我看了一下目录——这应该是我第四年开始专门写文章形式的年度总结了。四年来年度报告的形式各不相同，但是却一年比一年长。虽然很显然从每年的年度报告里可以回忆到我当时的精神状态，但现在真要回顾对比起来，却又只敢在 VS Code 里点开源文件，然后在右侧的小图里粗略瞅一瞅篇幅，生怕细看一下现在看起来颇显幼稚的话，然后再想起一些别的东西。</p><p>但若细细回顾这一年，与24年相对比，脑海里蹦出来的第一句话反而是长者的名句：「一个人的命运啊，当然要靠自我奋斗，但是也要考虑到历史的行程。」不管你的心态是变或者不变，自22年刹住的巨轮，似乎在这几年来又开始慢慢恢复转动，推动着数不尽的变。</p><p>我们在年初的跨年夜用大模型生成贺年词时，似乎也想不到，一条蓝色的海豚将在新年的火药味里横空出世，然后带着一车动物朋友们彻底涌入几乎所有人的日常生活。又比如，我4月23日时在某个地方感叹：「还得是你们新互联网人会造词，沟槽的原来这就是所谓 vibe coding」，然后各种乱七八糟的 Code 又变成了大伙的年度热词，人人都成为了驯服大模型写代码的斗牛师。</p><p>类似这样的事情还有很多。ChatGPT 的产品版本发布在2022 年 11 月，Stable Diffusion 1.5 发布在 2022 年 10 月。彼时在大家的眼里，ChatGPT 写的代码错误百出根本没法运行，Stable Diffusion 和 NovelAI 生图天天多好几根手指，修图和保持特征一致性和迁移特点更是难上加难（我当时为了研究这个可没少跑各种各样乱七八糟的训练）。而把时间拨到2025年，人们都已经不屑于按 Tab 自动补全，有的甚至编辑器都不开，直接在命令行里让大模型自己干活；而 Nano Banana Pro 的横空出世，也终于较好地解决了困扰我已久的迁移问题——我终于可以给我的AI头像换一套像样的衣服了。</p><p>在这种越来越快的进程面前，能够很明显地感受到，想法如果不很快落地，就会发馊变质。我在学校期间搞的一个项目就是这样，记得当时出想法时搜了一大圈并没有雷同点，然后半鸽半推地做了半年，老板想摇下一届一起做的时候，下一届的说：老师我们不想做了，我们在 GitHub 上找到了差不多功能的（时间确实在我有想法的很往后），几百 Star 了。</p><h3 id="结构化解剖"><a href="#结构化解剖" class="headerlink" title="结构化解剖"></a>结构化解剖</h3><p>就连算不了数和没有数理的问题都被这堆大模型解决了一大截——大模型可以走工具调用（Tool Call）嘛！这堆大模型现在调工具偷懒的能力一个比一个精，ChatGPT 内置的环境甚至能手撕一组二进制文件。</p><p>在我前文所述的项目里，我曾经为了 JSON 的问题头疼半天。当时的大模型虽然已经引入了形如 <code>json_output</code> 的参数，但是实测一下就发现每家都有新花样，在读入输出时总会出一些奇奇怪怪的问题，有换行的、有括号引号对不上的、还有键值牛头不对马嘴的，暴露了这堆太奶奶模型纯靠前文硬猜的底层局限性。而在 2025 年，我把一个 JSON 喂给 GPT 5.2 Thinking 后，只能见到它一个 <code>with zf.open(&#39;result.json&#39;) as f</code> 起手，然后嘎嘎写 Python 脚本，可用性提升得不是一点半点。   </p><p>既然如此，想到某个倒垃圾的小频道有基于 JSON 的消息导出功能，干脆直接导出来喂给了 GPT 和 Gemini. 不知道降没降智的 GPT 先生运转了大约半个小时，吐出了下面这一段：</p><blockquote><p>你会看到一个很“像人生”的结构：<br><strong>7 月爆发（生活+技术双峰）→ 8–10 月维持高强度（信息策展更重）→ 11–12 月更像“打包归档 + 技术深挖 + 年终盘点”。</strong></p><p><strong>技术好奇心很强、动手能力重，喜欢把信息“搬回自己的库里”，平时用梗和吐槽快速表达立场，但真正重要的事你会认真复盘、写给未来的自己。</strong><br>你在“线上内容圈层（群友&#x2F;赛事&#x2F;梗文化）”和“线下城市漫游（照片）”之间来回切换，把两者都当成生活。</p></blockquote><p>某种情况下确实符合我今年的生活曲线。</p><h2 id="过去的碎片与远方"><a href="#过去的碎片与远方" class="headerlink" title="过去的碎片与远方"></a>过去的碎片与远方</h2><h3 id="过去"><a href="#过去" class="headerlink" title="过去"></a>过去</h3><p>在写年度报告的过程中，我发现了比较有意思的一点：有的人对年度报告有一种近乎抵触的态度，认为这其中可能存在着回溯性建构的倒果为因的过程。而我自己对于年度报告的看法却比较复杂，一是如上所述，年度报告能够从一种特殊的角度留存你在那一年的精神状态，每一年都会有些新的细微的变化；二是，这篇年度报告在我的笔记本上躺了有接近半个月多，十多天来这篇博客的内容在我的脑海中不断地重构，一些文段在我的脑海中转瞬即逝——如果不记下来可能就真忘了。</p><p>11 月底，我收到了谷歌的一封邮件，说有个账号将被按不活跃删除，于是费了老大劲来恢复这个账号，点进去发现——这个账号居然是 2013 年注册的，那时候的谷歌还是老图标：  </p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/image-20251230215523669.png"/></div></div><p>于是顺藤摸瓜地找，便发现了一车神秘妙妙的自己的互联网遗产，纳闷于当时的自己的是怎么和这些过去的事物扯上的关系，体验了一把被自己的子弹从背后击中的感觉。  </p><p>甚至能在邮箱的未读列表里发现这样的邮件：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/image-20251230220151979.png"/></div></div><p>在记忆中，过去的事情并不总是连续的，更多的是像这样的零散碎片。由这个碎片起始可以联想出很多东西，但是如果不加唤醒，这个碎片的结局要么是像这次一样因某个契机而唤醒，要么是被不断涌入的新事物挤压而沉睡。   </p><h3 id="特别纪念"><a href="#特别纪念" class="headerlink" title="特别纪念"></a>特别纪念</h3><p>从碎片出发，我觉得有必要特别纪念两个可以被抬到台面上的发生在今年的公共事件，他们都是在今年再次回顾时，让我从不同程度想起了一些东西，值得扩展成一段来说说。</p><h4 id="HIT-FM：广播与座机里的音乐"><a href="#HIT-FM：广播与座机里的音乐" class="headerlink" title="HIT FM：广播与座机里的音乐"></a>HIT FM：广播与座机里的音乐</h4><p>12 月 20 日，<a href="https://x.com/DJWZ/status/2002087775835734372">加菲众</a>发布了一条长推文，给出了中国国际广播电台旗下的 HIT FM 将要停播的消息。12 月 22 日，HIT FM 在白天时段<a href="https://www.bilibili.com/video/BV1MfBWByEeY/">正式播出了停播通知</a>：</p><blockquote><p>各位听众，劲曲调频广播频率北京地区FM88.7和上海地区FM87.9将于12月23日零时起不再播出新的内容。未来的时间里，各位听众可以在中央人民广播电台音乐之声和央视频客户端等相关新媒体平台上继续欣赏精彩的国际流行音乐内容，欢迎大家继续关注……</p></blockquote><div class="tag_plugin ds-bvideo" v_id="BV1MfBWByEeY"><a href="//www.bilibili.com/video/BV1MfBWByEeY" target="_blank">    </a></div><p><a href="https://www.bilibili.com/video/BV1T4BjBWEEC/">晚间</a>在播送完 Owl City 最经典 Good Time 之后，HIT FM 依旧转入「明天会更加精彩」的零点报时引导，只是这次，在嘟嘟的报时声之后，再也没有了象征零点到来的滴声。</p><div class="tag_plugin ds-bvideo" v_id="BV1T4BjBWEEC"><a href="//www.bilibili.com/video/BV1T4BjBWEEC" target="_blank">    </a></div><p>作为欧美音乐的爱好者，在很长一段的时间里 HIT FM 一直是我的干活专用电台，Potplayer 里一直有个 <code>http://sk.cri.cn/887.m3u8</code>，想听的时候就点开挂到后台。<br>但与大部分纪念的听众不同，我实际上是在比较晚的时候才从网络上接触到 HIT FM 的，究其原因也很简单：我并不是京爷沪爷，我所在的地方只有中国之声覆盖。但不得不说，在互联网还没有深度渗透，上网仍然需要2G手机卡的年代，如果你也和我共享着「担心家里人回来摸电视发热」的集体记忆，那么广播便是你绕不开的获取外界新鲜事的途径。<br>我用来听广播的设备也并不是什么专业收音机，而是一个白色的带脚垫的，当时我看来一手也能抓住的蓝牙音箱。那音箱应该也是淘宝1元拍卖来的，分为FM、蓝牙、插卡和插线四种模式，音箱上的那个半圆的 CD 纹按键早已被磨得发亮，大约是在那段时光里替我按住了时间的流速。<br>地方的广播台质量也不是特别好，不是评书就是卖药；而中国之声也并不总是我喜欢的节目，摸鱼的时间总要找个宣泄出口的。这时不知道从哪里听说了 12530，于是想到家里当时摆的移动座机。<br>12530 是咪咕的音乐台，本来只是用来设彩铃用的，但是同时也兼具付费点歌台的作用，当时的记忆是按几个键，然后说话就可以点歌，能点到的歌有的会完整放，有的只放一小段，总之就是引导你去花钱设那个彩铃。很显然，走 2G 语音信道传过来的音质可谓是差中之差，比广播听的质量还差，但多少也是填补了广播不在的那一部分。那一大段时间里天天跪在座机旁边，一听就是几十分钟，直到后面发现话费少了很多，才发现原来那个台是付费的。<br>刚刚写的时候才发现 B 站上居然也有人录，可以来体验一下：<a href="https://www.bilibili.com/video/BV1sd4y1S79H/">https://www.bilibili.com/video/BV1sd4y1S79H/</a>   </p><h4 id="Crypko-ai：早期的拼凑时光"><a href="#Crypko-ai：早期的拼凑时光" class="headerlink" title="Crypko.ai：早期的拼凑时光"></a>Crypko.ai：早期的拼凑时光</h4><p>如果你一直对生图领域感兴趣，那你可能会对 Crypko.ai 有所了解。<br>这个玩意儿在 2018 年横空出世，特点是将 GAN（对抗神经网络）生图与区块链相结合，生成的动漫女孩可以进行融合与交易，当时在知乎上有这么一篇文章，标题写得格外吓人：<a href="https://zhuanlan.zhihu.com/p/39135876">人工智能+区块链首次成功着陆居然在二次元领域</a>  </p><blockquote><p>AlphaGo 战胜了全世界所有最强旗手，掀起人们对人工智能（AI）的狂热关注。</p><p>AlphaZero 只通过34小时训练，战胜了战胜所有人类王者的 AlphaGo。</p><p>以比特币代表的区块链技术，引发了人们对新一代互联网革命的运动。</p><p>——人工智能+区块链，两大顶尖技术联起手来，打破了二次元人口结界。</p></blockquote><p>这是他们的官图：  </p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/EwFUnwfU8A4lD1e.jpeg"/></div></div>       <p>与现在的各路模型对比起来，这种 GAN 生成的图片看起来自然瑕疵百出，清晰度也不高。但在当时，第一代 Crypko.ai 跑在测试链上，并不需要真金白银就可以体验到当时看起来还很新鲜的生图玩意儿，那吸引力自然也是非常大的。   </p><p>比如这是我存档里的一张图，看上去好像是买的别人融合的：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Crypko #708605</span><br><span class="line">Iter: 18</span><br><span class="line">Name: 优良miku素材请看族谱</span><br><span class="line">Bio: 虽然长得不是非常miku但是拥有超优良基因</span><br><span class="line">Hashtags: None</span><br><span class="line">Origins: #704727, #704591</span><br></pre></td></tr></table></figure><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/708605.jpg"/></div></div><p>这一张像是自己融的，充满了古早互联网二次元头像的风格：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Crypko #708469</span><br><span class="line">Iter: 362</span><br><span class="line">Name: None</span><br><span class="line">Bio: None</span><br><span class="line">Hashtags: None</span><br><span class="line">Origins: #708378, #706700</span><br></pre></td></tr></table></figure><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/708469.jpg"/></div></div><p>如果你对这张图的两个「祖先」（Origins）感兴趣：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/image-20251231003348179.png"/></div></div><p>那段拿着手机合新图片的过程大抵也是快乐的，只是后来项目方突然宣布第一代停止公测，把大伙账户里的 Crypkos 导出来发邮箱里（正如上面那样），然后沉寂了好久。   </p><p>突然在 2022 年，我偶然刷到一条广告，才知道原来第二代来了。第二代离开了测试链，开始真金白银地分模型对生图操作收费，于是就不再关注。看上去他们的模型进步了不少，但我因为不再关注，只留下了后面调研时截在 PPT 里的一张图：   </p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/image-20251231004217386.png"/></div></div><p>然后就是在今年，我在给最近图像生成技术作综述时，得知了它将在 2025 年 6 月 30 日停止运营的消息。如今早已过去半年，<a href="https://crypko.ai/">官网</a> 也不再有更多告示，只留下了三国语言的「Crypko Service Has Ended」，多少有一点赛博坟墓的感觉。<br>很明显，Crypko.ai 也没能抵过新一代生图模型的浪潮。跑过 GAN 生图的大约都知道，其和 Stable Diffusion 的资源消耗就不是在同一个量级的，生成的效果更是被后者吊打。随着资源和收支的不平衡，作为新浪潮的牺牲品也是历史的必然，它不是第一个，也必然不是最后一个。<br>只是，它多少也是寄托了早期的一段时光，在那段时光里，你对着现在看上去错漏百出的图，大脑里也能拟合出一些幻想，那时你看着前面知乎文章里吹的「新一代互联网革命」茫然不知所以，却想着这些图哪些可以融合，什么时候他们会整出更多的花样。<br>又想到了 will7101 ，很不幸在换了好几台电脑后我这台电脑本地上已搜不到任何和他相关的记录，只记得我和他一起玩 Crypko.ai ，他用了好长一段生成的图当头像，如今其他的记忆已经模糊，只记下了这一串 ID。</p><p>同样也是去找图的时候，想到这个团队的前作是 <a href="https://make.girls.moe/">MakeGirlsMoe</a>，Crypko.ai 已经倒闭，这个网站因为 GAN 模型加载在前端，居然还能正常生图，令人唏嘘。</p><p>如果说 HIT FM 是旧时代的落幕，Crypko.ai 我觉得就是新时代死在沙滩上的前浪。</p><h3 id="向远方去"><a href="#向远方去" class="headerlink" title="向远方去"></a>向远方去</h3><p>在去年窜访南京之后，很荣幸今年的旅程进一步延伸到了武汉和上海。<br>在武汉主要是去打比赛，因为半公费的关系时间卡得非常紧，仅仅在最后一天参观了一下光谷步行街的假路牌以及华为大教堂，未能成功轰入<a href="https://sh1mar.in/">飞鸟老师</a>的音游窝，倍感遗憾。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/image-20251231010904683.png"/></div></div>   <p>然后就是7月末去上海，参加了 <a href="https://aosc.io/aoscc/2025">AOSCC 2025</a> ，于是便在会前会后见到了好多好多好多之前仅在网络上见过的群友，详细的面基情况甚至不太好计量。   .</p><p>说是面基，实际上更多是对自己心里建立的形象作一次大补全<del>（比如发现 <a href="https://rvalue.moe/">rvalue</a> 老师完全符合头像和表情包）</del>，同时进行更多的延伸和探索（比较羞愧的一点是在大桌上和 Asuna 老师约了饭，此前没有啥正面交互，后来一想鼓起勇气加个 contact 吧，回头一翻人家 21 年就在频道了）。</p><p>只是有必要强调的是，那一整段的时间里上海的天气并不算好，顶着台风天换酒店赶路多少也是狼狈了一些。然后就在回来后过了好长一段时间的某天晚上，被某张街道图（后来被群友补充其实是中科大门口，现在看来似乎有点不太适合发挥）trigger 了一下，倒了一大段垃圾，本着展现原生状态的原则全文抄录在这里：</p><blockquote><p>刚刚点开某个群友的小频道，看见他拍的一张夜景街道图，突然就意识到：自己在渐渐地对着「外面」的一些东西祛魅。看上去外面城市的道路也就那样，外面的道路也是下了雨湿漉漉的地，也是一片一片霓虹灯，一排共享单车后面是一堆楼，楼里的灯也没亮几个。对于这种场景，我内心里看到的只是孤独：这道路还没我这门口宽呢，我只想躲进熟悉的被窝里睡一觉。<br>又想起来自己无论是在南京，武汉还是上海，都曾干过独自探索城市的行为，要找他们的共性也很容易：时间往往是深夜，我坐着空荡荡的公交车，在雨天，跑在城市的大外环上。<br>最近一次在上海的最为夸张：我举着云台开着手机上的延时摄影，在车上迷迷糊糊了一个小时，手机可以清楚地看到因公交车急刹车而被甩来甩去的视角。到站了，公交车右转进某个大空地，大约是下班了。我被扔在昏黄的路灯底下，打开手机想找共享单车，空旷的场地稀稀拉拉几辆，拖着腿走过去，扫上车开始蹬，头顶又下起了雨。回到酒店后，迷迷糊糊地打开携程改签酒店跑路。<br>然而事后再看录下来的延时，发现也就是那样：快速路，高架桥，并不繁华的路段，稀稀拉拉的灯光。一座现代城市不可能把自己的每个角落都做成旅游资源，你真的跑到了城市的边角，容易发现也就是那样，城市的城建建设大约都是趋于同质化，到最后甚至可能没你家门口楼下的社区市场热闹。<br>刚刚看到那张照片，心里产生了这么一些破碎的想法，也对下面这段好久之前就看到的话有了点肤浅的认知：<br>旅游就是从自己活腻了的地方，跑到别人活腻了的地方，去花掉自己的钱，让别人富起来，然后满身疲惫、口袋空空的再回到自己活腻了的地方，继续顽强的活下去。</p></blockquote><p>可以说是为「远方」提供了一个新的角度。   </p><p>但有时候我也在想，向「远方」去探索带来的新鲜感，是否有一部分是来源于回应那个当年趴在座机旁听 12530、对‘外面’充满噪点般幻想的小学时的自己？你可以说是给自己心里的某张图加边，构建新的联系；也可以说是呼应那时候听电台的自己对外面世界的幻想，给想象一个新的注解。</p><hr><p>倒垃圾的部分暂且结束，如下是一些零散的数据总结。</p><h2 id="数据们"><a href="#数据们" class="headerlink" title="数据们"></a>数据们</h2><p>2025 年从大基调上来看，整体上是一个充满探索与回顾的一年。由于某个大伙可能猜得到的原因，这一年的综合娱乐活动整体上处于暂停状态，在年底才有补锅的机会。   </p><ul><li>今年的博客数据<strong>与去年基本持平</strong>，访问量略有下降。主要来源于今年的两篇大型文章，<a href="https://www.travellings.cn/">开往</a>的跳转，以及<a href="https://blog.hanlin.press/qbot">自建 QQ Bot</a> 项目。虽然文章量少了访问量自然会掉，但是能维持住这么一个量级还是挺让我惊喜的。</li></ul><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/image-20251231014242045.png"/></div></div><ul><li>今年的 Tai 统计数据多少也能看出些端倪，很明显反恐的数据远不如去年：</li></ul><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/image-20251231014653146.png"/></div></div><ul><li>在音乐软件方面，可以分享的一点是网易云居然凑了个整：你怎么知道我听 3456 首歌听了 23333 Min ：</li></ul><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/image-20251231014910348.png"/></div></div><p>Spotify 侧的画风和网易云截然不同，还是软件分开听惹的祸：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2025-annual-report/image-20251231015027653.png"/></div></div><ul><li><p>TG 频道今年合计多了两千多号人，具体图就不在这里附了。</p></li><li><p>还需要补充的是邮政类，今年合计收到 32 张明信片，期待明年再创新高。</p></li></ul><h2 id="年度们"><a href="#年度们" class="headerlink" title="年度们"></a>年度们</h2><p>我们来推歌好了，以下列表的主体是这一年时间内我特殊分享出来的歌：</p><ul><li><a href="https://music.163.com/song?id=30284458">来吧，甜蜜的吐槽(ED) - 卢恒宇&#x2F;李姝洁</a> ：很显然这首歌是 neta 了隔壁那首「now the guilt is all mine」（我相信大伙多少都有一点ptsd了）。我个人应该是先听了这一首点了红心，过了好多年才被推到EVA上的原版。这首歌我觉得和甩饼歌一样，带着早期中文互联网那种优酷时代的网络歌曲痕迹（比较典型的就是各类翻唱填词），我觉得这应该是改编的比较好的，能够让你每次听完之后后劲都挺大的那一类。不得不承认还是母语歌潜意识里带来的情感调动更大。</li><li><a href="https://music.163.com/song?id=1916961513">Human Again - Boxplot</a>：11分钟的 DNB 作品，11分钟的循序渐进与情感调动，强烈推荐。</li><li><a href="https://music.163.com/song?id=2722320071">The Big Goodbye - AJR</a>：AJR 今年的新作，一如既往的特色编曲，经典的回顾性歌词——「Why my town feels like home for the first time in years, Why’d I need to be known, they fucking know me here」。</li><li><a href="https://music.163.com/song?id=2003582108">UNDERCOVER (feat. Shinpei Nasuno) - 電音部&#x2F;Shinpei Nasuno&#x2F;蔀祐佳&#x2F;天音みほ&#x2F;堀越せな</a>：电音部的耐听王，单纯是过了两年被 Spotify 推到发现还是很耐听。</li><li><a href="https://music.163.com/song?id=21680447">Nothing’s Gonna Stop Us Now - Starship</a>：经典老歌，陪我扛过了今年情绪低谷的一个时期。</li><li><a href="https://music.163.com/song?id=2309988">It’s All Coming Back to Me Now - Céline Dion</a>：同上，感觉今年对经典老歌没抵抗力。</li><li><a href="https://music.163.com/song?id=1405421750">Ariana - Tony Anderson</a>：今年非常喜欢听的一首纯音乐，适合情绪舒缓与冥想。</li><li><a href="https://music.163.com/song?id=2059532170">流沙 (Reimagined) - 陶喆</a>：陶喆重置歌的新编曲很棒。</li><li><a href="https://music.163.com/song?id=1374607534">Full Heart Fancy (Instrumental) - Lucky Chops</a>：入选《地平线5》的一首纯音乐，时常让人想起派对的欢乐场景。</li></ul>]]></content>
    
    
    <summary type="html">所谓的历史进程，大概就是一边看着新东西涌进来，一边目送旧东西离开。</summary>
    
    
    
    <category term="杂记" scheme="https://blog.hanlin.press/categories/%E6%9D%82%E8%AE%B0/"/>
    
    <category term="日常" scheme="https://blog.hanlin.press/categories/%E6%97%A5%E5%B8%B8/"/>
    
    
    <category term="口水" scheme="https://blog.hanlin.press/tags/%E5%8F%A3%E6%B0%B4/"/>
    
    <category term="杂谈" scheme="https://blog.hanlin.press/tags/%E6%9D%82%E8%B0%88/"/>
    
    <category term="年度报告" scheme="https://blog.hanlin.press/tags/%E5%B9%B4%E5%BA%A6%E6%8A%A5%E5%91%8A/"/>
    
    <category term="2025" scheme="https://blog.hanlin.press/tags/2025/"/>
    
    <category term="回顾" scheme="https://blog.hanlin.press/tags/%E5%9B%9E%E9%A1%BE/"/>
    
    <category term="城市漫游" scheme="https://blog.hanlin.press/tags/%E5%9F%8E%E5%B8%82%E6%BC%AB%E6%B8%B8/"/>
    
    <category term="音乐" scheme="https://blog.hanlin.press/tags/%E9%9F%B3%E4%B9%90/"/>
    
  </entry>
  
  <entry>
    <title>Bad Apple：ALAC 音乐完整性验证与速查指南</title>
    <link href="https://blog.hanlin.press/2025/09/ALAC-Verification-Guide/"/>
    <id>https://blog.hanlin.press/2025/09/ALAC-Verification-Guide/</id>
    <published>2025-09-12T16:00:26.000Z</published>
    <updated>2026-03-17T05:44:26.000Z</updated>
    
    <content type="html"><![CDATA[<div class="tag-plugin image"><div class="image-bg"><img src="/uploads/ALAC-Verification-Guide/header.png"/></div></div><p><strong>Update on 2026-03-17:</strong> 使用某下载器对 <a href="https://music.apple.com/cn/album/fragile-violet-single/1820118990">TOGENASHI TOGEARI - Fragile Violet - Single</a> 进行了复测，发现解码失败的问题仍然存在，而且元信息里的 <code>Encoded date</code> 仍然是 <code>2025-06-23</code>，证明苹果并没有重新编码。鉴于内录测试已经证明了下载器的可靠性，故此次并没有重新使用内录法测试，有兴趣的可自行验证。</p><blockquote><p><strong>TL;DR for non-Chinese readers</strong>:<br>Since around May 2025, We’ve discovered that many lossless ALAC files sourced from Apple Music are <strong>fundamentally corrupted</strong>—an issue that remains unfixed as of March 2026. Through rigorous internal recording tests, where We <strong>captured raw audio directly</strong> from the official Windows client <strong>(shout out to <a href="https://space.bilibili.com/50306781">@TheM14</a>)</strong> and compared it with pristine tracks from platforms like MORA and Tidal, We’ve proven that this is not a third-party downloader or DRM decryption glitch, but a <strong>defect originating directly from Apple’s own source files or encoders</strong>.<br>Because these corrupted files consistently fail standard integrity checks in software like foobar2000 and throw specific processing errors in FFmpeg, I’ve included automated batch scripts (for Windows, Bash, and PowerShell) below to help you easily scan your own local library.<br>Ultimately, Apple Music’s lossless tracks can no longer be blindly trusted for high-fidelity archiving; <strong>I strongly advise you to verify your files, be cautious of converted FLACs that might mask these underlying errors, and always cross-reference your music with known good sources.</strong></p></blockquote><h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p>本文是对频道一段时间前的发送的<a href="https://channel.0w0.best/posts/6294">这条推文</a>的整理与补充，方便追踪、讨论与评论区拍砖：</p><blockquote><p>在大约今年5月份之后，Apple Music 某个节点上的无损音乐编码器似乎坏掉了，导致他们提供的音乐源文件就是爆的，相比于其他来源会出现丢失采样的问题。<strong>根据社区的实验，内录官方客户端并分析采样，会发现问题依然存在，因此该问题似乎与社区中热门的第三方下载实现无关</strong>。</p></blockquote><p>叠甲在先：   </p><ol start="0"><li><strong>尽管 Apple Music 的下载方案已从去年 DRM 被公开破拆之后成为马奇诺防线，并可在多个国内平台看到教程，但本文并不提供任何下载指导，也不鼓励任何形式的侵权行为。</strong></li><li>本文的方法论建立在对几张典型专辑的实际测试之上，我并非 Apple Music 的利益相关者，我甚至并不知道当前的情况是 Apple Music 官方的编码器压坏了，还是 Apple 故意为之，推出了某个很新的妙妙 DRM 方案。众所周知，Apple Music 的编码器会对音频进行一些特殊处理（比如将部分数字母带™的响度调高），如果这次发现的音频问题在内录后依然存在，那么我认为它就不应被归类为 DRM，<strong>而是一种事实上的音频水印</strong>。</li></ol><h2 id="现象"><a href="#现象" class="headerlink" title="现象"></a>现象</h2><p>正文开始，以下是编写时发现仍有问题的专辑，你可以用来进行测试：</p><ul><li><a href="https://music.apple.com/cn/album/fragile-violet-single/1820118990">TOGENASHI TOGEARI - Fragile Violet - Single</a>   </li><li><a href="https://music.apple.com/cn/album/for-us/1811381025">Nate Sib - for us</a></li><li><a href="https://music.apple.com/jp/album/pleasure/1827894274">Pleasure - JOLIN蔡依林</a></li></ul><p>基于如上的叠甲，本文不会讨论更多的原理，只说对速查有用的现象：</p><ol><li>出问题的音乐大多数是在今年（即 2025 年）之后被 Apple 重编码，通过一个现代的播放器（如 Potplayer）你可以在音频文件的 <code>Encoded date</code> 里找到 2025 年之后的日期。</li></ol><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/ALAC-Verification-Guide/image-20250911140815297.png"/></div></div><ol start="2"><li><strong>如果使用带有校验功能的下载工具，会发现这些文件无法通过测试</strong>。使用 foobar2000 等第三方软件进行验证完整性，也无法通过：</li></ol><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/ALAC-Verification-Guide/image-20250911141223844.png"/></div></div><p>使用 foobar2000 内置的 FLAC 转换也会报错：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/ALAC-Verification-Guide/image-20250911141359884.png"/></div></div><ol start="3"><li><strong>使用 ffmpeg 进行转码时会报错</strong>，根据你不同的 ffmpeg 版本，报错通常会是如下的其中一种或多种：</li></ol><ul><li><code>Invalid data found when processing input</code></li><li><code>Not yet implemented in FFmpeg</code></li><li><code>Syntax element</code></li><li><code>patch welcome</code> &#x2F; <code>patches welcome</code></li></ul><p>其中，对于一大组文件，我认为使用 ffmpeg 进行检测是最为方便的方法，尤其是当你需要批量处理时。你可以使用以下命令来模拟：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">ffmpeg -i <span class="string">&quot;m4a文件&quot;</span> -f wav -hide_banner -loglevel info NUL 2&gt;&amp;1</span><br></pre></td></tr></table></figure><p>以如上的 <a href="https://music.apple.com/cn/album/fragile-violet-single/1820118990">TOGENASHI TOGEARI - Fragile Violet - Single</a> 为例，输出会是这样的：</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><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><span class="line">Stream <span class="comment">#0:0(und): Audio: pcm_s16le ([1][0][0][0] / 0x0001), 44100 Hz, stereo, s16, 1411 kb/s (default)</span></span><br><span class="line">    Metadata:</span><br><span class="line">      encoder         : Lavc62.13.101 pcm_s16le</span><br><span class="line">      creation_time   : 2025-06-23T04:06:21.000000Z</span><br><span class="line">      handler_name    : Fragile Violet</span><br><span class="line">      vendor_id       : [0][0][0][0]</span><br><span class="line">[alac @ 00000289a4f48580] invalid element channel count</span><br><span class="line">[aist#0:0/alac @ 00000289a2cc8fc0] [dec:alac @ 00000289a2cc9180] Error submitting packet to decoder: Invalid data found when processing input</span><br><span class="line">[alac @ 00000289a2cee6c0] invalid element channel count</span><br><span class="line">[alac @ 00000289a4f48580] invalid element channel count</span><br><span class="line">[aist#0:0/alac @ 00000289a2cc8fc0] [dec:alac @ 00000289a2cc9180] Error submitting packet to decoder: Invalid data found when processing input</span><br><span class="line">    Last message repeated 1 <span class="built_in">times</span></span><br><span class="line">[alac @ 00000289a4f79a40] Syntax element 4 is not implemented. Update your FFmpeg version to the newest one from Git. If the problem still occurs, it means that your file has a feature <span class="built_in">which</span> has not been implemented.</span><br><span class="line">[aist#0:0/alac @ 00000289a2cc8fc0] [dec:alac @ 00000289a2cc9180] Error submitting packet to decoder: Not yet implemented <span class="keyword">in</span> FFmpeg, patches welcome</span><br><span class="line">[out#0/wav @ 00000289a2c23080] video:0KiB audio:32168KiB subtitle:0KiB other streams:0KiB global headers:0KiB muxing overhead: 0.000929%</span><br><span class="line">size=   32168KiB time=00:03:07.10 bitrate=1408.4kbits/s speed=3.54e+03x elapsed=0:00:00.05  </span><br></pre></td></tr></table></figure><h2 id="速查工具"><a href="#速查工具" class="headerlink" title="速查工具"></a>速查工具</h2><p>出于验证的需求，我找 Gemini 生了个批处理脚本并改了改，可以用于批量校验这些文件。 你可以直接下载使用，或是在<a href="#%E9%99%84%E5%BD%95">附录</a>找到源代码，自己看一眼再跑。在运行脚本时请确保您的环境中已安装并配置好 <code>ffmpeg</code>，并且可以在命令行中直接调用。     </p><ul><li><a href="/uploads/ALAC-Verification-Guide/find_bad_alac.zip">Windows 的批处理脚本</a>（版本 <code>20250911</code>），支持递归检测当前文件夹与拖放检测。</li><li><a href="/uploads/ALAC-Verification-Guide/find_bad_alac.sh">Bash 的脚本</a>（版本 <code>20250911</code>），支持递归检测当前文件夹与命令行。</li></ul><h2 id="验证"><a href="#验证" class="headerlink" title="验证"></a>验证</h2><p>此前的发现在社区内引发了广泛讨论，其中不乏有观点认为这是 Apple Music 的一种防盗版机制。为了厘清这一问题，本段将提供一份客户端内录的验证记录，让每位读者都能亲手检验——这些音频瑕疵究竟是源于 DRM 保护，还是 Apple 自身的编码器缺陷。<br>在动手前，请务必再次阅读以下声明：首先，出于版权考量，<strong>本文不会涉及任何下载教学</strong>，我们<strong>假设您已合法拥有需要验证的音乐</strong>。其次，我们判断的逻辑很简单，也已在上文中叙述：如果所谓的“问题”在内录后仍然存在，那它就和 DRM 无关了，而是一种被编码进音频里的“事实水印”，是该和国内的流媒体坐一桌的。</p><p>此处<strong>非常感谢 <a href="https://space.bilibili.com/50306781">@TheM14</a> 老师进行了严谨的测试，为本文提供了非常重要的参考依据。</strong></p><h3 id="准备工作"><a href="#准备工作" class="headerlink" title="准备工作"></a>准备工作</h3><p>首先，我们需要证明内录的结果是正确的，这里需要提前进行以下处理：</p><ol><li><strong>寻找一个内录通道</strong>，这里使用的是 Minifuse 声卡驱动自带的内录通道（主要是方便），像 VoiceMeeter 的虚拟内录通道也是可以的。</li><li>将输出的采样率和位深设置成和原曲相同。</li><li><strong>干掉 Windows 自带的 CAudioLimiter。</strong> 这是 Windows 操作系统音频引擎中一个内置的音频处理组件。它的主要功能是作为一个峰值限制器（limiter），防止音频信号在输出时超过最大电平（0 dBFS），从而避免产生削波（clipping）失真。但为了忠实地还原音频，我们需要确保录制的音频不经过任何额外处理。你可以参考 B 站上的 <a href="https://www.bilibili.com/video/BV1GPNezjEsM/">BV1GPNezjEsM</a> 来干掉它。<strong>（在进行任何有关注册表的操作前，请手动备份注册表）</strong></li></ol><h3 id="内录与验证"><a href="#内录与验证" class="headerlink" title="内录与验证"></a>内录与验证</h3><p>接下来进行内录，此处以<a href="https://music.apple.com/cn/album/%E5%83%95%E3%81%A8%E4%B8%89%E5%8E%9F%E8%89%B2/1759525321">「結束バンド」的「僕と三原色」</a>这首歌为例。</p><ol><li>先在 <a href="https://support.apple.com/zh-cn/guide/music-windows/mus19e7bc658/windows">Apple Music 的 Windows 客户端</a>中寻找对应的歌曲并进行下载（以免网络不良的情况下播放成 lossy 的 AAC）.</li><li>使用内录通道配合 <a href="https://www.reaper.fm/download.php">Reaper</a> 内录（录制的时候要设置好工程的采样率），之后导出 48&#x2F;24 （与已知采样率一致）的 WAV .</li></ol><p>在进行后续对比之前，<strong>我们必须首先确保录音能够精确还原 Apple Music 输出的原始音频流</strong>。我们可以使用 foobar2000 的 Binary Comparator 组件（下称 bitcompare）来进行对比，你可以在 <a href="https://www.foobar2000.org/components/view/foo_bitcompare">foobar2000 的组件库</a> 中找到它并安装，请注意一下这个组件只有 32 位格式。<br>此处，我们手里有三份音源，一份是通过工具下载解密 DRM 获得的 ALAC（<code>decrypted.m4a</code>），一份是内录的 WAV（<code>recorded.wav</code>），另一份是通过 MORA 自购下载的无损 FLAC（<code>mora.flac</code>）。</p><p>首先将 <code>decrypted.m4a</code> 与 <code>mora.flac</code> 进行比较：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/ALAC-Verification-Guide/image-20250912234210878.png"/></div></div><p>此处 bitcompare 没有找到差别，证明了下载解密工具本身没有问题，两个来源的音源是一致的。</p><p>接下来对比 <code>mora.flac</code> 与 <code>recorded.wav</code>：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/ALAC-Verification-Guide/image-20250912234526340.png"/></div></div><p>注意到 bitcompare 的提示：<br>Differences found in compared tracks; <strong>the tracks became identical after applying offset and truncating first&#x2F;last samples.</strong><br>Extra leading&#x2F;trailing sections contained only null samples.</p><p>我们显然不能在内录的时候做到和原曲完全同步，因此 bitcompare 通过对齐和裁剪的方式来忽略掉了前后多余的采样点（事实上全是空的），然后发现<strong>中间的部分是完全一致的</strong>。<br>通过如上的对比，我们可以得出结论：**内录是没问题的，解密也是没问题的。**因为可以和 MORA 自购下载的文件对齐每个采样点的数值，如果不是完美解密这根本不可能。</p><h3 id="问题专辑"><a href="#问题专辑" class="headerlink" title="问题专辑"></a>问题专辑</h3><p>接下来，我们再来对比一下已知有问题的<a href="https://music.apple.com/jp/album/pleasure/1827894274">蔡依林「Pleasure」专辑里的 Safari</a>，首先我们使用解密工具下载一版，然后尝试使用 foobar2000 来进行完整性验证：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/ALAC-Verification-Guide/image-20250912235212738.png"/></div></div><p>为了排除发行方对不同渠道的影响，此处从 Tidal 和 Amazon Music HD 也分别购买下载了同一首歌的同音质音频，并进行 bitcompare 测试：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/ALAC-Verification-Guide/image-20250912235423340.png"/></div></div><p>结果是一模一样且无错误的，可以证明并非发行商的问题。</p><p>然后参照上述的步骤对这首歌进行内录，然后直接使用 Adobe Audition 与来自 Amazon&#x2F;Tidal 的音源进行反相相消比较，可以发现<strong>右声道似乎出现了一个孤零零的音频信号</strong>：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/ALAC-Verification-Guide/image-20250912235725721.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/ALAC-Verification-Guide/image-20250912235732120.png"/></div></div><p>这里就是 Apple 经过内录和 Amazon&#x2F;Tidal 的音源不同的地方了，此处出现了一个采样值的错误，这个错误很有可能由于解码器的不支持，导致直接丢弃这个数据块，从而从一个采样点的错误衍生到 4096 个采样点的错误（一个 block）。</p><p>验证就到这里，**我们可以得出结论：Apple Music 的无损音源确实存在问题，这个问题在内录后依然存在。**出于版权考量，本文不会提供相关的源文件下载，读者可以参考上述的步骤自行实验。</p><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>所以从音乐收藏和发布的角度来说，我们可以得出什么结论？</p><ol><li>Apple Music 的无损音源现在<strong>已经不再能和 Amazon 和 Qobuz 一样可信</strong>（这应该会给各路喜欢用 Apple Music 搞视听的 HiFi 爱好者一记闷棍），从各路来源获取的时候应该格外谨慎。（实际上真正应该得到的教训是从多个来源交叉验证）   </li><li>无论来源如何，任何 ALAC 文件，尤其是已被转码为 FLAC 的文件，都应被视为潜在的<strong>污染源</strong>，<strong>尤其是已被转码过的 FLAC 文件</strong>，因为在转码完毕后他们的编码错误被掩盖了。</li><li>务必优先采用亲手获取、来源清晰的音频。切勿信赖第三方的链接解析站或下载机器人，因为它们分发的文件缺乏来源保证（有的甚至在静默转码到 FLAC 的时候忽略了上述问题），可能继承了原始文件存在的所有风险。</li><li>如果你正在使用社区里比较流行的下载实现，<strong>请务必把他们提供的完整性验证功能打开</strong>（原理实际上和本文是类似的）。在排除了网络、账户等个人因素后，若校验持续失败，则应判定 Apple Music 的官方音源本身已损坏。此时，请<strong>果断放弃该来源</strong>，以避免收藏或传播有问题的音频。</li></ol><p>本文提供的方法与结论仅供参考。此方法可有效识别并排除部分存在问题的专辑，但<strong>无法保证通过验证的专辑绝对无误</strong>。若您获取了 Apple Music 的 ALAC 音频文件，最可靠的验证方式仍是与其他无损<strong>且已知无音频水印与后处理</strong>的音源（如通过对比未压缩音轨的 MD5 哈希值）进行交叉比对。在撰写本文时，我对一些文件进行了此类交叉验证，目前尚未发现响度匹配而 MD5 值不同的案例，欢迎评论区补充拍砖。  </p><h2 id="附录"><a href="#附录" class="headerlink" title="附录"></a>附录</h2><p>以下是速查脚本的源代码，理论上与可以直接下载的版本一致，你可以自行验证与修改：</p><h3 id="Windows-批处理脚本"><a href="#Windows-批处理脚本" class="headerlink" title="Windows 批处理脚本"></a>Windows 批处理脚本</h3><p>请注意：因为批处理中使用 <code>CHCP 65001</code> 设置了 UTF-8 代码页，<strong>请保证你在保存时选择使用 UTF-8 编码</strong>。如果你不知道这是什么且在运行的时候遇见了编码错误（比如乱码），请把下列代码中的 <code>CHCP 65001 &gt;NUL</code> 这一行删除。</p><figure class="highlight bat"><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><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br><span class="line">142</span><br><span class="line">143</span><br><span class="line">144</span><br><span class="line">145</span><br><span class="line">146</span><br><span class="line">147</span><br><span class="line">148</span><br><span class="line">149</span><br><span class="line">150</span><br><span class="line">151</span><br><span class="line">152</span><br><span class="line">153</span><br><span class="line">154</span><br><span class="line">155</span><br><span class="line">156</span><br><span class="line">157</span><br></pre></td><td class="code"><pre><span class="line">@<span class="built_in">ECHO</span> OFF</span><br><span class="line"><span class="built_in">SETLOCAL</span> ENABLEDELAYEDEXPANSION</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">REM https://blog.hanlin.press/2025/09/ALAC-Verification-Guide/</span></span><br><span class="line"><span class="comment">REM --- 脚本初始化 ---</span></span><br><span class="line"><span class="comment">REM 切换到 UTF-8 代码页(65001)以正确显示和处理中文字符</span></span><br><span class="line"><span class="built_in">CHCP</span> <span class="number">65001</span> &gt;<span class="built_in">NUL</span></span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">REM --- 获取当前日期和时间 (格式: YYYY-MM-DD 和 HH-MM-SS)，使用 WMIC 以避免区域设置问题 ---</span></span><br><span class="line"><span class="keyword">FOR</span> /F &quot;tokens=<span class="number">2</span> delims==&quot; <span class="variable">%%I</span> <span class="keyword">IN</span> (&#x27;wmic os get LocalDateTime /value&#x27;) <span class="keyword">DO</span> <span class="built_in">SET</span> &quot;dt=<span class="variable">%%I</span>&quot;</span><br><span class="line"><span class="built_in">SET</span> &quot;CURRENT_DATE=<span class="variable">%dt:~0,4%</span>-<span class="variable">%dt:~4,2%</span>-<span class="variable">%dt:~6,2%</span>&quot;</span><br><span class="line"><span class="built_in">SET</span> &quot;CURRENT_TIME=<span class="variable">%dt:~8,2%</span>-<span class="variable">%dt:~10,2%</span>-<span class="variable">%dt:~12,2%</span>&quot;</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">REM 定义报告文件的基础名和完整路径</span></span><br><span class="line"><span class="built_in">SET</span> &quot;report_basename=ALAC_Scan_Report_<span class="variable">%CURRENT_DATE%</span>_<span class="variable">%CURRENT_TIME%</span>.txt&quot;</span><br><span class="line"><span class="built_in">SET</span> &quot;report_file=<span class="variable">%~dp0%</span>report_basename%&quot;</span><br><span class="line"><span class="built_in">SET</span> &quot;temp_log=<span class="variable">%TEMP%</span>\ffmpeg_scan_error.log&quot;</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">REM 初始化计数器</span></span><br><span class="line"><span class="built_in">SET</span> /A problem_count=<span class="number">0</span></span><br><span class="line"><span class="built_in">SET</span> /A total_files_scanned=<span class="number">0</span></span><br><span class="line"></span><br><span class="line"><span class="built_in">ECHO</span>.</span><br><span class="line"><span class="built_in">ECHO</span> =======================================================</span><br><span class="line"><span class="built_in">ECHO</span> == ALAC 编码问题扫描工具 <span class="number">20250911</span>         ==</span><br><span class="line"><span class="built_in">ECHO</span> =======================================================</span><br><span class="line"><span class="built_in">ECHO</span>.</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">REM --- 环境检查: FFMPEG ---</span></span><br><span class="line">WHERE ffmpeg &gt;<span class="built_in">NUL</span> <span class="number">2</span>&gt;<span class="built_in">NUL</span></span><br><span class="line"><span class="keyword">IF</span> <span class="variable">%ERRORLEVEL%</span> <span class="keyword">NEQ</span> <span class="number">0</span> (</span><br><span class="line">    <span class="built_in">ECHO</span> [错误] 未在您的 <span class="built_in">PATH</span> 中找到 FFMPEG。</span><br><span class="line">    <span class="built_in">ECHO</span> 请先安装 FFMPEG 并确保可以从命令行调用它。</span><br><span class="line">    <span class="built_in">PAUSE</span></span><br><span class="line">    <span class="keyword">GOTO</span> :EOF</span><br><span class="line">)</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">REM --- 准备报告文件 ---</span></span><br><span class="line"><span class="keyword">IF</span> <span class="keyword">EXIST</span> &quot;<span class="variable">%report_file%</span>&quot; <span class="built_in">DEL</span> &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"><span class="built_in">ECHO</span> ALAC 文件扫描报告 &gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"><span class="built_in">ECHO</span> 生成时间: <span class="variable">%DATE%</span> <span class="variable">%TIME%</span> &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"><span class="built_in">ECHO</span> ====================================================== &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">REM --- 判断执行模式 (拖放或直接运行) ---</span></span><br><span class="line"><span class="keyword">IF</span> &quot;%~<span class="number">1</span>&quot;==&quot;&quot; (</span><br><span class="line">    <span class="built_in">ECHO</span> [模式] 未拖放文件或文件夹，将递归扫描当前目录。</span><br><span class="line">    <span class="built_in">ECHO</span>.</span><br><span class="line">    <span class="keyword">CALL</span> :ScanDirectory &quot;.&quot;</span><br><span class="line">) <span class="keyword">ELSE</span> (</span><br><span class="line">    <span class="built_in">ECHO</span> [模式] 检测到拖放操作，将处理指定的文件或文件夹。</span><br><span class="line">    <span class="built_in">ECHO</span>.</span><br><span class="line">    <span class="keyword">FOR</span> <span class="variable">%%A</span> <span class="keyword">IN</span> (%*) <span class="keyword">DO</span> (</span><br><span class="line">        <span class="keyword">IF</span> <span class="keyword">EXIST</span> &quot;<span class="variable">%%~</span>A\&quot; (</span><br><span class="line">            <span class="keyword">CALL</span> :ScanDirectory &quot;<span class="variable">%%~</span>A&quot;</span><br><span class="line">        ) <span class="keyword">ELSE</span> (</span><br><span class="line">            <span class="keyword">IF</span> /I &quot;<span class="variable">%%~</span>xA&quot;==&quot;.m4a&quot; (</span><br><span class="line">                 <span class="keyword">CALL</span> :ProcessFile &quot;<span class="variable">%%~</span>A&quot;</span><br><span class="line">            ) <span class="keyword">ELSE</span> (</span><br><span class="line">                 <span class="built_in">ECHO</span> [跳过] &quot;<span class="variable">%%~</span>nxA&quot; 不是 M4A 文件。</span><br><span class="line">            )</span><br><span class="line">        )</span><br><span class="line">    )</span><br><span class="line">)</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">REM --- 扫描结束，生成总结 ---</span></span><br><span class="line"><span class="built_in">ECHO</span>.</span><br><span class="line"><span class="built_in">ECHO</span> --------------------------------------------------</span><br><span class="line"><span class="built_in">ECHO</span>.</span><br><span class="line"><span class="built_in">ECHO</span> 扫描完成！</span><br><span class="line"></span><br><span class="line"><span class="built_in">ECHO</span> 总共扫描了 <span class="variable">!total_files_scanned!</span> 个文件。</span><br><span class="line"></span><br><span class="line"><span class="built_in">SET</span> &quot;summary=&quot;</span><br><span class="line"><span class="keyword">IF</span> <span class="variable">%problem_count%</span> == <span class="number">0</span> (</span><br><span class="line">    <span class="built_in">SET</span> &quot;summary=结果: 未发现任何存在编码问题的文件。&quot;</span><br><span class="line">    <span class="built_in">ECHO</span> <span class="variable">!summary!</span></span><br><span class="line">) <span class="keyword">ELSE</span> (</span><br><span class="line">    <span class="built_in">SET</span> &quot;summary=结果: 共发现 <span class="variable">!problem_count!</span> 个文件存在编码问题。&quot;</span><br><span class="line">    <span class="built_in">ECHO</span> <span class="variable">!summary!</span></span><br><span class="line">)</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">REM 将总结也写入报告文件底部</span></span><br><span class="line"><span class="built_in">ECHO</span>. &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"><span class="built_in">ECHO</span> ====================================================== &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"><span class="built_in">ECHO</span> 扫描总结: &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"><span class="built_in">ECHO</span> 总共扫描了 <span class="variable">%total_files_scanned%</span> 个 M4A 文件。 &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"><span class="built_in">ECHO</span> <span class="variable">%summary%</span> &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"><span class="built_in">ECHO</span> ====================================================== &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"></span><br><span class="line">:AfterChecksum</span><br><span class="line"><span class="built_in">ECHO</span> 详细的综合报告已生成:</span><br><span class="line"><span class="built_in">ECHO</span> &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"><span class="keyword">IF</span> <span class="variable">%problem_count%</span> <span class="keyword">GTR</span> <span class="number">0</span> (</span><br><span class="line">    <span class="built_in">ECHO</span>.</span><br><span class="line">    <span class="built_in">ECHO</span> 因为发现了问题文件，正在为您自动打开报告...</span><br><span class="line">    <span class="built_in">START</span> &quot;&quot; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="built_in">ECHO</span>.</span><br><span class="line"><span class="built_in">PAUSE</span></span><br><span class="line"><span class="keyword">GOTO</span> :EOF</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">REM --- 子程序: 扫描整个目录 (递归) ---</span></span><br><span class="line">:ScanDirectory</span><br><span class="line">    <span class="built_in">SET</span> &quot;target_dir=%~<span class="number">1</span>&quot;</span><br><span class="line">    <span class="built_in">ECHO</span> --- 正在递归扫描文件夹: &quot;<span class="variable">%target_dir%</span>&quot; ---</span><br><span class="line">    <span class="keyword">FOR</span> /R &quot;<span class="variable">%target_dir%</span>&quot; <span class="variable">%%F</span> <span class="keyword">IN</span> (*.m4a) <span class="keyword">DO</span> (</span><br><span class="line">        <span class="keyword">CALL</span> :ProcessFile &quot;<span class="variable">%%F</span>&quot;</span><br><span class="line">    )</span><br><span class="line"><span class="keyword">GOTO</span> :EOF</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">REM --- 子程序: 处理单个文件 ---</span></span><br><span class="line">:ProcessFile</span><br><span class="line">    <span class="built_in">SET</span> &quot;current_file=%~<span class="number">1</span>&quot;</span><br><span class="line">    <span class="built_in">SET</span> /A total_files_scanned+=<span class="number">1</span></span><br><span class="line">    <span class="built_in">ECHO</span>.</span><br><span class="line">    <span class="built_in">ECHO</span> [<span class="variable">!total_files_scanned!</span>] 正在检查: &quot;<span class="variable">!current_file!</span>&quot;</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">    REM 核心逻辑: 运行 ffmpeg -i，将所有输出(包括分析和错误)都重定向到临时文件</span></span><br><span class="line">    ffmpeg -i &quot;<span class="variable">!current_file!</span>&quot; -f wav -hide_banner -loglevel info <span class="built_in">NUL</span> <span class="number">2</span>&gt; &quot;<span class="variable">%temp_log%</span>&quot;</span><br><span class="line"><span class="comment">    </span></span><br><span class="line"><span class="comment">    REM 在临时文件中查找指定的错误字符串</span></span><br><span class="line">    <span class="built_in">findstr</span> /C:&quot;Invalid data found when processing input&quot; /C:&quot;<span class="keyword">Not</span> yet implemented <span class="keyword">in</span> FFmpeg&quot; /C:&quot;Syntax element&quot; /C:&quot;patch welcome&quot; /C:&quot;patches welcome&quot; &quot;<span class="variable">%temp_log%</span>&quot; &gt;<span class="built_in">NUL</span></span><br><span class="line">    </span><br><span class="line">    <span class="built_in">ECHO</span>. &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line">    <span class="built_in">ECHO</span> ---------------------------------------- &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line">    <span class="built_in">ECHO</span> [文件] &quot;<span class="variable">!current_file!</span>&quot; &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">IF</span> <span class="variable">!ERRORLEVEL!</span> == <span class="number">0</span> (</span><br><span class="line"><span class="comment">        REM 发现问题</span></span><br><span class="line">        <span class="built_in">SET</span> /A problem_count+=<span class="number">1</span></span><br><span class="line">        <span class="built_in">ECHO</span>   [状态] 发现问题！</span><br><span class="line"><span class="comment">        </span></span><br><span class="line"><span class="comment">        REM --- 将详细错误信息写入报告文件 ---</span></span><br><span class="line">        <span class="built_in">ECHO</span> [状态] 发现问题！ &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line">        <span class="built_in">ECHO</span> [详情] FFmpeg 完整输出: &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line">        <span class="built_in">TYPE</span> &quot;<span class="variable">%temp_log%</span>&quot; &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line">        </span><br><span class="line">    ) <span class="keyword">ELSE</span> (</span><br><span class="line"><span class="comment">        REM 文件正常</span></span><br><span class="line">        <span class="built_in">ECHO</span>   [状态] 正常</span><br><span class="line">        <span class="built_in">ECHO</span> [状态] 正常 &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">        REM --- 提取音频流信息并输出 ---</span></span><br><span class="line">        <span class="keyword">FOR</span> /F &quot;tokens=*&quot; <span class="variable">%%L</span> <span class="keyword">IN</span> (&#x27;<span class="built_in">findstr</span> &quot; Audio:&quot; &quot;<span class="variable">%temp_log%</span>&quot;&#x27;) <span class="keyword">DO</span> (</span><br><span class="line"><span class="comment">            REM 在控制台输出单行分析</span></span><br><span class="line">            <span class="built_in">ECHO</span>   [信息] <span class="variable">%%L</span></span><br><span class="line"><span class="comment">            REM 将单行分析写入报告</span></span><br><span class="line">            <span class="built_in">ECHO</span> [信息] <span class="variable">%%L</span> &gt;&gt; &quot;<span class="variable">%report_file%</span>&quot;</span><br><span class="line">        )</span><br><span class="line">    )</span><br><span class="line"><span class="comment"></span></span><br><span class="line"><span class="comment">    REM 清理临时日志文件</span></span><br><span class="line">    <span class="keyword">IF</span> <span class="keyword">EXIST</span> &quot;<span class="variable">%temp_log%</span>&quot; <span class="built_in">DEL</span> &quot;<span class="variable">%temp_log%</span>&quot;</span><br><span class="line"><span class="keyword">GOTO</span> :EOF</span><br></pre></td></tr></table></figure><h3 id="Bash-脚本"><a href="#Bash-脚本" class="headerlink" title="Bash 脚本"></a>Bash 脚本</h3><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><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><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br><span class="line">142</span><br><span class="line">143</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#!/usr/bin/env bash</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># https://blog.hanlin.press/2025/09/ALAC-Verification-Guide/</span></span><br><span class="line"><span class="built_in">set</span> -o pipefail</span><br><span class="line"><span class="built_in">export</span> LC_ALL=<span class="variable">$&#123;LC_ALL:-C.UTF-8&#125;</span></span><br><span class="line"><span class="built_in">export</span> LANG=<span class="variable">$&#123;LANG:-C.UTF-8&#125;</span></span><br><span class="line">CURRENT_DATE=$(<span class="built_in">date</span> +%Y-%m-%d)</span><br><span class="line">CURRENT_TIME=$(<span class="built_in">date</span> +%H-%M-%S)</span><br><span class="line">script_dir=$(<span class="built_in">cd</span> <span class="string">&quot;<span class="subst">$(dirname <span class="string">&quot;<span class="variable">$0</span>&quot;</span>)</span>&quot;</span> &amp;&amp; <span class="built_in">pwd</span>)</span><br><span class="line">report_basename=<span class="string">&quot;ALAC_Scan_Report_<span class="variable">$&#123;CURRENT_DATE&#125;</span>_<span class="variable">$&#123;CURRENT_TIME&#125;</span>.txt&quot;</span></span><br><span class="line">report_file=<span class="string">&quot;<span class="variable">$&#123;script_dir&#125;</span>/<span class="variable">$&#123;report_basename&#125;</span>&quot;</span></span><br><span class="line">temp_log=$(<span class="built_in">mktemp</span> -t ffmpeg_scan_error.XXXXXX.<span class="built_in">log</span>)</span><br><span class="line"></span><br><span class="line">problem_count=0</span><br><span class="line">total_files_scanned=0</span><br><span class="line"></span><br><span class="line"><span class="built_in">echo</span></span><br><span class="line"><span class="built_in">echo</span> <span class="string">&quot;=======================================================&quot;</span></span><br><span class="line"><span class="built_in">echo</span> <span class="string">&quot;== ALAC 编码问题扫描工具 20250911                    ==&quot;</span></span><br><span class="line"><span class="built_in">echo</span> <span class="string">&quot;=======================================================&quot;</span></span><br><span class="line"><span class="built_in">echo</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> ! <span class="built_in">command</span> -v ffmpeg &gt;/dev/null 2&gt;&amp;1; <span class="keyword">then</span></span><br><span class="line">  <span class="built_in">echo</span> <span class="string">&quot;[错误] 未在 PATH 中找到 ffmpeg&quot;</span></span><br><span class="line">  <span class="built_in">exit</span> 1</span><br><span class="line"><span class="keyword">fi</span></span><br><span class="line"></span><br><span class="line">: &gt; <span class="string">&quot;<span class="variable">$report_file</span>&quot;</span></span><br><span class="line">&#123;</span><br><span class="line">  <span class="built_in">echo</span> <span class="string">&quot;ALAC 文件扫描报告&quot;</span></span><br><span class="line">  <span class="built_in">echo</span> <span class="string">&quot;生成时间: <span class="subst">$(date &#x27;+%Y-%m-%d %H:%M:%S&#x27;)</span>&quot;</span></span><br><span class="line">  <span class="built_in">echo</span> <span class="string">&quot;======================================================&quot;</span></span><br><span class="line">&#125; &gt;&gt; <span class="string">&quot;<span class="variable">$report_file</span>&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="function"><span class="title">append_report</span></span>() &#123;</span><br><span class="line">  <span class="built_in">printf</span> <span class="string">&#x27;%s\n&#x27;</span> <span class="string">&quot;<span class="variable">$@</span>&quot;</span> &gt;&gt; <span class="string">&quot;<span class="variable">$report_file</span>&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="title">process_file</span></span>() &#123;</span><br><span class="line">  <span class="built_in">local</span> file=<span class="string">&quot;<span class="variable">$1</span>&quot;</span></span><br><span class="line">  total_files_scanned=$((total_files_scanned + <span class="number">1</span>))</span><br><span class="line">  <span class="built_in">echo</span></span><br><span class="line">  <span class="built_in">echo</span> <span class="string">&quot;[<span class="variable">$total_files_scanned</span>] 正在检查: \&quot;<span class="variable">$file</span>\&quot;&quot;</span></span><br><span class="line">  : &gt; <span class="string">&quot;<span class="variable">$temp_log</span>&quot;</span></span><br><span class="line">  ffmpeg -i <span class="string">&quot;<span class="variable">$file</span>&quot;</span> -f wav -hide_banner -loglevel info -y /dev/null 2&gt; <span class="string">&quot;<span class="variable">$temp_log</span>&quot;</span></span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> grep -F -e <span class="string">&quot;Invalid data found when processing input&quot;</span> \</span><br><span class="line">             -e <span class="string">&quot;Not yet implemented in FFmpeg&quot;</span> \</span><br><span class="line">             -e <span class="string">&quot;patch welcome&quot;</span> \</span><br><span class="line">             -e <span class="string">&quot;patches welcome&quot;</span> \</span><br><span class="line">             -e <span class="string">&quot;Syntax element&quot;</span> <span class="string">&quot;<span class="variable">$temp_log</span>&quot;</span> &gt;/dev/null 2&gt;&amp;1; <span class="keyword">then</span></span><br><span class="line">    problem=<span class="literal">true</span></span><br><span class="line">  <span class="keyword">else</span></span><br><span class="line">    problem=<span class="literal">false</span></span><br><span class="line">  <span class="keyword">fi</span></span><br><span class="line"></span><br><span class="line">  append_report <span class="string">&quot;&quot;</span></span><br><span class="line">  append_report <span class="string">&quot;----------------------------------------&quot;</span></span><br><span class="line">  append_report <span class="string">&quot;[文件] \&quot;<span class="variable">$file</span>\&quot;&quot;</span></span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> <span class="variable">$problem</span>; <span class="keyword">then</span></span><br><span class="line">    problem_count=$((problem_count + <span class="number">1</span>))</span><br><span class="line">    <span class="built_in">echo</span> <span class="string">&quot;  [状态] 发现问题！&quot;</span></span><br><span class="line">    append_report <span class="string">&quot;[状态] 发现问题！&quot;</span></span><br><span class="line">    append_report <span class="string">&quot;[详情] FFmpeg 完整输出:&quot;</span></span><br><span class="line">    <span class="built_in">cat</span> <span class="string">&quot;<span class="variable">$temp_log</span>&quot;</span> &gt;&gt; <span class="string">&quot;<span class="variable">$report_file</span>&quot;</span></span><br><span class="line">  <span class="keyword">else</span></span><br><span class="line">    <span class="built_in">echo</span> <span class="string">&quot;  [状态] 正常&quot;</span></span><br><span class="line">    append_report <span class="string">&quot;[状态] 正常&quot;</span></span><br><span class="line">    <span class="keyword">if</span> grep -F <span class="string">&quot; Audio:&quot;</span> <span class="string">&quot;<span class="variable">$temp_log</span>&quot;</span> &gt;/dev/null 2&gt;&amp;1; <span class="keyword">then</span></span><br><span class="line">      <span class="keyword">while</span> IFS= <span class="built_in">read</span> -r line; <span class="keyword">do</span></span><br><span class="line">        <span class="built_in">echo</span> <span class="string">&quot;  [信息] <span class="variable">$line</span>&quot;</span></span><br><span class="line">        append_report <span class="string">&quot;[信息] <span class="variable">$line</span>&quot;</span></span><br><span class="line">      <span class="keyword">done</span> &lt; &lt;(grep -F <span class="string">&quot; Audio:&quot;</span> <span class="string">&quot;<span class="variable">$temp_log</span>&quot;</span>)</span><br><span class="line">    <span class="keyword">fi</span></span><br><span class="line">  <span class="keyword">fi</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="title">scan_directory</span></span>() &#123;</span><br><span class="line">  <span class="built_in">local</span> target_dir=<span class="string">&quot;<span class="variable">$1</span>&quot;</span></span><br><span class="line">  <span class="built_in">echo</span> <span class="string">&quot;--- 正在递归扫描文件夹: \&quot;<span class="variable">$target_dir</span>\&quot; ---&quot;</span></span><br><span class="line">  <span class="keyword">while</span> IFS= <span class="built_in">read</span> -r -d <span class="string">&#x27;&#x27;</span> f; <span class="keyword">do</span></span><br><span class="line">    process_file <span class="string">&quot;<span class="variable">$f</span>&quot;</span></span><br><span class="line">  <span class="keyword">done</span> &lt; &lt;(find <span class="string">&quot;<span class="variable">$target_dir</span>&quot;</span> -<span class="built_in">type</span> f -iname <span class="string">&#x27;*.m4a&#x27;</span> -print0)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> [[ <span class="variable">$#</span> -eq 0 ]]; <span class="keyword">then</span></span><br><span class="line">  <span class="built_in">echo</span> <span class="string">&quot;[模式] 未提供参数，将递归扫描当前目录。&quot;</span></span><br><span class="line">  <span class="built_in">echo</span></span><br><span class="line">  scan_directory <span class="string">&quot;.&quot;</span></span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">  <span class="built_in">echo</span> <span class="string">&quot;[模式] 检测到传入参数，将处理指定的文件或文件夹。&quot;</span></span><br><span class="line">  <span class="built_in">echo</span></span><br><span class="line">  <span class="keyword">for</span> path <span class="keyword">in</span> <span class="string">&quot;<span class="variable">$@</span>&quot;</span>; <span class="keyword">do</span></span><br><span class="line">    <span class="keyword">if</span> [[ -d <span class="string">&quot;<span class="variable">$path</span>&quot;</span> ]]; <span class="keyword">then</span></span><br><span class="line">      scan_directory <span class="string">&quot;<span class="variable">$path</span>&quot;</span></span><br><span class="line">    <span class="keyword">elif</span> [[ -f <span class="string">&quot;<span class="variable">$path</span>&quot;</span> ]]; <span class="keyword">then</span></span><br><span class="line">      ext=<span class="string">&quot;<span class="variable">$&#123;path##*.&#125;</span>&quot;</span></span><br><span class="line">      ext=<span class="string">&quot;<span class="variable">$&#123;ext,,&#125;</span>&quot;</span></span><br><span class="line">      <span class="keyword">if</span> [[ <span class="string">&quot;<span class="variable">$ext</span>&quot;</span> == <span class="string">&quot;m4a&quot;</span> ]]; <span class="keyword">then</span></span><br><span class="line">        process_file <span class="string">&quot;<span class="variable">$path</span>&quot;</span></span><br><span class="line">      <span class="keyword">else</span></span><br><span class="line">        <span class="built_in">echo</span> <span class="string">&quot;[跳过] \&quot;<span class="subst">$(basename <span class="string">&quot;<span class="variable">$path</span>&quot;</span>)</span>\&quot; 不是 M4A 文件。&quot;</span></span><br><span class="line">      <span class="keyword">fi</span></span><br><span class="line">    <span class="keyword">else</span></span><br><span class="line">      <span class="built_in">echo</span> <span class="string">&quot;[跳过] \&quot;<span class="variable">$path</span>\&quot; 无法访问。&quot;</span></span><br><span class="line">    <span class="keyword">fi</span></span><br><span class="line">  <span class="keyword">done</span></span><br><span class="line"><span class="keyword">fi</span></span><br><span class="line"></span><br><span class="line"><span class="built_in">echo</span></span><br><span class="line"><span class="built_in">echo</span> <span class="string">&quot;--------------------------------------------------&quot;</span></span><br><span class="line"><span class="built_in">echo</span></span><br><span class="line"><span class="built_in">echo</span> <span class="string">&quot;扫描完成！&quot;</span></span><br><span class="line"><span class="built_in">echo</span> <span class="string">&quot;总共扫描了 <span class="variable">$total_files_scanned</span> 个文件。&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> [[ <span class="variable">$problem_count</span> -eq 0 ]]; <span class="keyword">then</span></span><br><span class="line">  summary=<span class="string">&quot;结果: 未发现任何存在编码问题的文件。&quot;</span></span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">  summary=<span class="string">&quot;结果: 共发现 <span class="variable">$problem_count</span> 个文件存在编码问题。&quot;</span></span><br><span class="line"><span class="keyword">fi</span></span><br><span class="line"><span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$summary</span>&quot;</span></span><br><span class="line"></span><br><span class="line">append_report <span class="string">&quot;&quot;</span></span><br><span class="line">append_report <span class="string">&quot;======================================================&quot;</span></span><br><span class="line">append_report <span class="string">&quot;扫描总结:&quot;</span></span><br><span class="line">append_report <span class="string">&quot;总共扫描了 <span class="variable">$&#123;total_files_scanned&#125;</span> 个 M4A 文件。&quot;</span></span><br><span class="line">append_report <span class="string">&quot;<span class="variable">$summary</span>&quot;</span></span><br><span class="line">append_report <span class="string">&quot;======================================================&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="built_in">echo</span> <span class="string">&quot;详细的综合报告已生成:&quot;</span></span><br><span class="line"><span class="built_in">echo</span> <span class="string">&quot;\&quot;<span class="variable">$report_file</span>\&quot;&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> [[ <span class="variable">$problem_count</span> -gt 0 ]]; <span class="keyword">then</span></span><br><span class="line">  <span class="keyword">if</span> <span class="built_in">command</span> -v xdg-open &gt;/dev/null 2&gt;&amp;1; <span class="keyword">then</span></span><br><span class="line">    xdg-open <span class="string">&quot;<span class="variable">$report_file</span>&quot;</span> &gt;/dev/null 2&gt;&amp;1 &amp;</span><br><span class="line">  <span class="keyword">elif</span> <span class="built_in">command</span> -v open &gt;/dev/null 2&gt;&amp;1; <span class="keyword">then</span></span><br><span class="line">    open <span class="string">&quot;<span class="variable">$report_file</span>&quot;</span> &gt;/dev/null 2&gt;&amp;1 &amp;</span><br><span class="line">  <span class="keyword">fi</span></span><br><span class="line"><span class="keyword">fi</span></span><br><span class="line"></span><br><span class="line"><span class="built_in">rm</span> -f <span class="string">&quot;<span class="variable">$temp_log</span>&quot;</span></span><br><span class="line"><span class="built_in">echo</span></span><br></pre></td></tr></table></figure><h3 id="PowerShell-7-脚本"><a href="#PowerShell-7-脚本" class="headerlink" title="PowerShell 7 脚本"></a>PowerShell 7 脚本</h3><p>由 <a href="https://nptr.cc/">Nullpointer</a> 提供，支持多线程处理，适合大批量文件的扫描。</p><figure class="highlight powershell"><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><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br><span class="line">142</span><br><span class="line">143</span><br><span class="line">144</span><br><span class="line">145</span><br><span class="line">146</span><br><span class="line">147</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">#requires -Version 7.0</span></span><br><span class="line"><span class="keyword">param</span>(</span><br><span class="line">  [<span class="type">Parameter</span>(<span class="type">Position</span> = <span class="number">0</span>)]</span><br><span class="line">  [<span class="built_in">string</span>[]]<span class="variable">$Path</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="built_in">Set-StrictMode</span> <span class="literal">-Version</span> Latest</span><br><span class="line"><span class="variable">$ErrorActionPreference</span> = <span class="string">&#x27;Stop&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> (<span class="operator">-not</span> (<span class="built_in">Get-Command</span> ffmpeg <span class="literal">-ErrorAction</span> SilentlyContinue)) &#123;</span><br><span class="line">  <span class="built_in">Write-Host</span> <span class="string">&quot;[错误] 未在 PATH 中找到 ffmpeg，请先安装。&quot;</span> <span class="literal">-ForegroundColor</span> Red</span><br><span class="line">  <span class="keyword">exit</span> <span class="number">1</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">function</span> <span class="title">Get-M4AFiles</span></span> &#123;</span><br><span class="line">  <span class="keyword">param</span>([<span class="built_in">string</span>[]]<span class="variable">$Paths</span>)</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (<span class="operator">-not</span> <span class="variable">$Paths</span> <span class="operator">-or</span> <span class="selector-tag">@</span>(<span class="variable">$Paths</span>).Count <span class="operator">-eq</span> <span class="number">0</span>) &#123;</span><br><span class="line">    <span class="built_in">Write-Host</span> <span class="string">&quot;[模式] 未提供参数，将递归扫描当前目录。&quot;</span></span><br><span class="line">    <span class="keyword">return</span> <span class="built_in">Get-ChildItem</span> <span class="literal">-Path</span> <span class="string">&#x27;.&#x27;</span> <span class="literal">-Filter</span> <span class="string">&#x27;*.m4a&#x27;</span> <span class="operator">-File</span> <span class="literal">-Recurse</span> <span class="literal">-ErrorAction</span> SilentlyContinue</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="built_in">Write-Host</span> <span class="string">&quot;[模式] 处理指定的文件/文件夹。&quot;</span></span><br><span class="line">  <span class="variable">$list</span> = <span class="built_in">New-Object</span> System.Collections.Generic.List<span class="function">[<span class="type">System.IO.FileInfo</span>]</span></span><br><span class="line">  <span class="keyword">foreach</span> (<span class="variable">$p</span> <span class="keyword">in</span> <span class="variable">$Paths</span>) &#123;</span><br><span class="line">    <span class="variable">$resolved</span> = (<span class="built_in">Resolve-Path</span> <span class="literal">-LiteralPath</span> <span class="variable">$p</span> <span class="literal">-ErrorAction</span> SilentlyContinue)?.Path</span><br><span class="line">    <span class="keyword">if</span> (<span class="operator">-not</span> <span class="variable">$resolved</span>) &#123; <span class="built_in">Write-Host</span> <span class="string">&quot;[跳过] 找不到路径：<span class="variable">$p</span>&quot;</span>; <span class="keyword">continue</span> &#125;</span><br><span class="line">    <span class="keyword">if</span> (<span class="built_in">Test-Path</span> <span class="literal">-LiteralPath</span> <span class="variable">$resolved</span> <span class="literal">-PathType</span> Container) &#123;</span><br><span class="line">      <span class="built_in">Get-ChildItem</span> <span class="literal">-LiteralPath</span> <span class="variable">$resolved</span> <span class="literal">-Filter</span> <span class="string">&#x27;*.m4a&#x27;</span> <span class="operator">-File</span> <span class="literal">-Recurse</span> <span class="literal">-ErrorAction</span> SilentlyContinue |</span><br><span class="line">      <span class="built_in">ForEach-Object</span> &#123; [<span class="built_in">void</span>]<span class="variable">$list</span>.Add(<span class="variable">$_</span>) &#125;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">elseif</span> (<span class="built_in">Test-Path</span> <span class="literal">-LiteralPath</span> <span class="variable">$resolved</span> <span class="literal">-PathType</span> Leaf) &#123;</span><br><span class="line">      <span class="keyword">if</span> ([<span class="type">IO.Path</span>]::GetExtension(<span class="variable">$resolved</span>).ToLowerInvariant() <span class="operator">-eq</span> <span class="string">&#x27;.m4a&#x27;</span>) &#123;</span><br><span class="line">        [<span class="built_in">void</span>]<span class="variable">$list</span>.Add([<span class="type">IO.FileInfo</span>]<span class="variable">$resolved</span>)</span><br><span class="line">      &#125;</span><br><span class="line">      <span class="keyword">else</span> &#123;</span><br><span class="line">        <span class="built_in">Write-Host</span> <span class="string">&quot;[跳过] `&quot;<span class="variable">$</span>([IO.Path]::GetFileName(<span class="variable">$resolved</span>))`&quot; 不是 M4A 文件。&quot;</span></span><br><span class="line">      &#125;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">return</span> <span class="variable">$list</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="variable">$targets</span> = <span class="selector-tag">@</span>( <span class="built_in">Get-M4AFiles</span> <span class="literal">-Paths</span> <span class="variable">$Path</span> | <span class="built_in">Sort-Object</span> FullName )</span><br><span class="line"><span class="keyword">if</span> (<span class="variable">$targets</span>.Count <span class="operator">-eq</span> <span class="number">0</span>) &#123;</span><br><span class="line">  <span class="built_in">Write-Host</span> <span class="string">&quot;未找到任何 .m4a 文件。&quot;</span></span><br><span class="line">  <span class="keyword">exit</span> <span class="number">0</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="variable">$stamp</span> = (<span class="built_in">Get-Date</span>).ToString(<span class="string">&#x27;yyyy-MM-dd_HH-mm-ss&#x27;</span>)</span><br><span class="line"><span class="variable">$report</span> = <span class="built_in">Join-Path</span> <span class="variable">$PSScriptRoot</span> <span class="string">&quot;ALAC_Scan_Report_<span class="variable">$stamp</span>.txt&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="variable">$ErrorPatterns</span> = <span class="selector-tag">@</span>(</span><br><span class="line">  <span class="string">&#x27;Invalid data found when processing input&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;Not yet implemented in FFmpeg&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;Syntax element&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;patch welcome&#x27;</span>,</span><br><span class="line">  <span class="string">&#x27;patches welcome&#x27;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="string">&quot;ALAC 文件扫描报告&quot;</span> | <span class="built_in">Out-File</span> <span class="literal">-LiteralPath</span> <span class="variable">$report</span> <span class="literal">-Encoding</span> UTF8</span><br><span class="line"><span class="string">&quot;生成时间: <span class="variable">$</span>(Get-Date -Format &#x27;yyyy/MM/dd HH:mm:ss&#x27;)&quot;</span> | <span class="built_in">Out-File</span> <span class="literal">-LiteralPath</span> <span class="variable">$report</span> <span class="literal">-Encoding</span> UTF8 <span class="literal">-Append</span></span><br><span class="line"><span class="string">&quot;=====================================================&quot;</span> | <span class="built_in">Out-File</span> <span class="literal">-LiteralPath</span> <span class="variable">$report</span> <span class="literal">-Encoding</span> UTF8 <span class="literal">-Append</span></span><br><span class="line"></span><br><span class="line"><span class="variable">$maxParallel</span> = [<span class="type">Math</span>]::Max(<span class="number">2</span>, [<span class="type">Environment</span>]::ProcessorCount)</span><br><span class="line"><span class="variable">$total</span> = <span class="variable">$targets</span>.Count</span><br><span class="line"><span class="variable">$problemCount</span> = <span class="number">0</span></span><br><span class="line"></span><br><span class="line"><span class="built_in">Write-Host</span> <span class="string">&quot;&quot;</span></span><br><span class="line"><span class="built_in">Write-Host</span> <span class="string">&quot;=======================================================&quot;</span></span><br><span class="line"><span class="built_in">Write-Host</span> <span class="string">&quot;== ALAC 编码问题扫描工具&quot;</span></span><br><span class="line"><span class="built_in">Write-Host</span> <span class="string">&quot;=======================================================&quot;</span></span><br><span class="line"><span class="built_in">Write-Host</span> <span class="string">&quot;&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="variable">$sw</span> = [<span class="type">System.Diagnostics.Stopwatch</span>]::StartNew()</span><br><span class="line"></span><br><span class="line"><span class="variable">$results</span> = <span class="variable">$targets</span> | <span class="built_in">ForEach-Object</span> <span class="literal">-Parallel</span> &#123;</span><br><span class="line">  <span class="variable">$filePath</span> = <span class="variable">$_</span>.FullName</span><br><span class="line">  <span class="variable">$ErrorPatterns</span> = <span class="variable">$using:ErrorPatterns</span></span><br><span class="line">  </span><br><span class="line">  <span class="variable">$log</span> = [<span class="type">System.IO.Path</span>]::GetTempFileName()</span><br><span class="line">  <span class="keyword">try</span> &#123;</span><br><span class="line">    &amp; ffmpeg <span class="literal">-v</span> info <span class="literal">-hide_banner</span> <span class="literal">-i</span> <span class="variable">$filePath</span> <span class="operator">-f</span> null - <span class="number">1</span>&gt;<span class="variable">$null</span> <span class="number">2</span>&gt; <span class="variable">$log</span></span><br><span class="line">    <span class="variable">$text</span> = <span class="keyword">if</span> (<span class="built_in">Test-Path</span> <span class="variable">$log</span>) &#123; <span class="built_in">Get-Content</span> <span class="variable">$log</span> <span class="literal">-Raw</span> <span class="literal">-Encoding</span> UTF8 &#125; <span class="keyword">else</span> &#123; <span class="string">&quot;&quot;</span> &#125;</span><br><span class="line">    <span class="variable">$hasProblem</span> = <span class="variable">$false</span></span><br><span class="line">    <span class="keyword">foreach</span> (<span class="variable">$p</span> <span class="keyword">in</span> <span class="variable">$ErrorPatterns</span>) &#123;</span><br><span class="line">      <span class="keyword">if</span> (<span class="variable">$text</span> <span class="operator">-match</span> [<span class="type">Regex</span>]::Escape(<span class="variable">$p</span>)) &#123; <span class="variable">$hasProblem</span> = <span class="variable">$true</span>; <span class="keyword">break</span> &#125;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="variable">$audio</span> = <span class="selector-tag">@</span>()</span><br><span class="line">    <span class="keyword">if</span> (<span class="variable">$text</span>) &#123;</span><br><span class="line">      <span class="variable">$audio</span> = (<span class="variable">$text</span> <span class="operator">-split</span> <span class="string">&quot;`r?`n&quot;</span>) | <span class="built_in">Where-Object</span> &#123; <span class="variable">$_</span> <span class="operator">-match</span> <span class="string">&#x27;\bAudio:&#x27;</span> &#125;</span><br><span class="line">    &#125;</span><br><span class="line">    </span><br><span class="line">    <span class="variable">$fileName</span> = [<span class="type">System.IO.Path</span>]::GetFileName(<span class="variable">$filePath</span>)</span><br><span class="line">    <span class="built_in">Write-Host</span> <span class="string">&quot;处理完成: <span class="variable">$fileName</span>&quot;</span> <span class="literal">-ForegroundColor</span> Green</span><br><span class="line">    </span><br><span class="line">    [<span class="type">pscustomobject</span>]<span class="selector-tag">@</span>&#123;</span><br><span class="line">      File    = <span class="variable">$filePath</span></span><br><span class="line">      Problem = <span class="variable">$hasProblem</span></span><br><span class="line">      Log     = <span class="variable">$text</span></span><br><span class="line">      Audio   = <span class="variable">$audio</span></span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">finally</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (<span class="built_in">Test-Path</span> <span class="variable">$log</span>) &#123; <span class="built_in">Remove-Item</span> <span class="variable">$log</span> <span class="literal">-Force</span> <span class="literal">-ErrorAction</span> SilentlyContinue &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125; <span class="literal">-ThrottleLimit</span> <span class="variable">$maxParallel</span></span><br><span class="line"></span><br><span class="line"><span class="variable">$sw</span>.Stop()</span><br><span class="line"></span><br><span class="line"><span class="keyword">foreach</span> (<span class="variable">$r</span> <span class="keyword">in</span> <span class="variable">$results</span>) &#123;</span><br><span class="line">  <span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;&quot;</span></span><br><span class="line">  <span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;----------------------------------------&quot;</span></span><br><span class="line">  <span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;[文件] `&quot;<span class="variable">$</span>(<span class="variable">$r</span>.File)`&quot;&quot;</span></span><br><span class="line">  <span class="keyword">if</span> (<span class="variable">$r</span>.Problem) &#123;</span><br><span class="line">    <span class="variable">$problemCount</span>++</span><br><span class="line">    <span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;[状态] 发现问题！&quot;</span></span><br><span class="line">    <span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;[详情] FFmpeg 完整输出:&quot;</span></span><br><span class="line">    <span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="variable">$r</span>.Log</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">else</span> &#123;</span><br><span class="line">    <span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;[状态] 正常&quot;</span></span><br><span class="line">    <span class="keyword">foreach</span> (<span class="variable">$line</span> <span class="keyword">in</span> <span class="variable">$r</span>.Audio) &#123;</span><br><span class="line">      <span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;[信息] <span class="variable">$line</span>&quot;</span></span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;&quot;</span></span><br><span class="line"><span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;=====================================================&quot;</span></span><br><span class="line"><span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;扫描总结:&quot;</span></span><br><span class="line"><span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;总共扫描了 <span class="variable">$total</span> 个 M4A 文件。&quot;</span></span><br><span class="line"><span class="built_in">Add-Content</span> <span class="variable">$report</span> (<span class="string">&quot;耗时: &#123;0:N1&#125; 秒&quot;</span> <span class="operator">-f</span> <span class="variable">$sw</span>.Elapsed.TotalSeconds)</span><br><span class="line"><span class="keyword">if</span> (<span class="variable">$problemCount</span> <span class="operator">-eq</span> <span class="number">0</span>) &#123;</span><br><span class="line">  <span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;结果: 未发现任何存在编码问题的文件。&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">else</span> &#123;</span><br><span class="line">  <span class="built_in">Add-Content</span> <span class="variable">$report</span> <span class="string">&quot;结果: 共发现 <span class="variable">$problemCount</span> 个文件存在编码问题。&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="built_in">Write-Host</span> <span class="string">&quot;&quot;</span></span><br><span class="line"><span class="built_in">Write-Host</span> (<span class="string">&quot;扫描完成！共 &#123;0&#125; 个文件，&#123;1&#125; 个有问题。&quot;</span> <span class="operator">-f</span> <span class="variable">$total</span>, <span class="variable">$problemCount</span>) `</span><br><span class="line">  <span class="literal">-ForegroundColor</span> (<span class="variable">$problemCount</span> <span class="operator">-eq</span> <span class="number">0</span> ? <span class="string">&#x27;Green&#x27;</span> : <span class="string">&#x27;Yellow&#x27;</span>)</span><br><span class="line"><span class="built_in">Write-Host</span> <span class="string">&quot;报告位置：<span class="variable">$report</span>&quot;</span></span><br><span class="line"><span class="keyword">if</span> (<span class="variable">$problemCount</span> <span class="operator">-gt</span> <span class="number">0</span>) &#123;</span><br><span class="line">  <span class="built_in">Start-Process</span> <span class="literal">-FilePath</span> <span class="variable">$report</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>]]></content>
    
    
    <summary type="html">唉，苹果；唉，编码；唉，DRM——我们如何亲手验证一个连转码都会报错的残次品？</summary>
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="音乐" scheme="https://blog.hanlin.press/tags/%E9%9F%B3%E4%B9%90/"/>
    
    <category term="ALAC" scheme="https://blog.hanlin.press/tags/ALAC/"/>
    
  </entry>
  
  <entry>
    <title>年轻人的第一个包：不太严谨的 AOSC OS 贡献指南</title>
    <link href="https://blog.hanlin.press/2025/08/AOSC-Newbie-Contribution-Guide/"/>
    <id>https://blog.hanlin.press/2025/08/AOSC-Newbie-Contribution-Guide/</id>
    <published>2025-08-08T12:20:20.000Z</published>
    <updated>2025-08-09T08:10:00.000Z</updated>
    
    <content type="html"><![CDATA[<div class="tag-plugin image"><div class="image-bg"><img src="/uploads/AOSC-Newbie-Contribution-Guide/header.png"/></div></div><h2 id="导语"><a href="#导语" class="headerlink" title="导语"></a>导语</h2><h3 id="「我打包？真的假的」"><a href="#「我打包？真的假的」" class="headerlink" title="「我打包？真的假的」"></a>「我打包？真的假的」</h3><p>刚刚落下帷幕的 <a href="https://aosc.io/events#aoscc">AOSCC 2025</a> 气氛热烈（<del>尽管从一个广为流传的投票来看，似乎参会的半数以上人都没用过 AOSC</del>），相信通过这次盛会，有更多朋友认识了 <a href="https://aosc.io/">AOSC OS</a>——这个以「简明可靠」为目标的社区主导型 Linux 发行版。<br>作为一个充满活力的开源项目，AOSC OS 的每一点进步都离不开社区成员的热情参与。当你下载安装，并亲身体验了一段时间的 AOSC OS 后，心里或许已经萌生了不少想法：你可能对它活跃的社区支持印象深刻，也可能对某些功能有了自己独特的改进点子，甚至……你已经开始摩拳擦掌，想要亲自为这个“共建”的系统添砖加瓦了！<br>而在遇见 AOSC OS 之前，你也许已经和许多 Linux 发行版打过交道了——从稳重的 CentOS，到流行的 Ubuntu，再到一滚到底的 Arch Linux。大多数时候，你可能只是默默地作为一名用户，看过了一车车繁杂的交互，淹没在了无尽的邮件列表中。于是来到了 AOSC OS ，当有人提到了**「打包」**这个词后，你的心中或许会产生一些疑问：我能给一个 Linux 发行版贡献软件包，真的假的？   </p><p>**当然可以！**本篇文章将会从一个不太严谨的角度，介绍一下如何为 AOSC OS 的软件包添砖加瓦。你会发现，只需通过你熟悉的 GitHub，就能轻松参与其中，并亲眼见证自己的代码通过系统更新，被送到每一位用户手中！</p><p>本篇由本人入门经历总结而成，希望能为你提供一份友好的实践向导。更全面和权威的信息，推荐随时查阅 <a href="https://wiki.aosc.io/zh/developer/packaging/">AOSC OS 官方维基</a>。</p><h3 id="前置知识"><a href="#前置知识" class="headerlink" title="前置知识"></a>前置知识</h3><p>参与 AOSC OS 的软件包维护，通常有三条路径：你可以引入一个全新的软件包，为系统拓展功能；可以重构或改进现有软件包，让它变得更好用；当然，你也可以从最常见、最容易上手的<strong>更新现有软件包</strong>开始。本文将聚焦于最后这一种，它也是感受社区协作、积累贡献经验的绝佳起点。<br>为了让整个过程更顺利，本篇<strong>默认你对 Git 和 GitHub 的基础操作有所了解，已经使用了 AOSC OS 一段时间，具备一定的调试和找代码读日志经验</strong>。<br>并且正如上所说，本文将带你走完一次完整的软件包更新流程，并不会涉及过多的软件包新增重构方面的复杂工作。如果你完成了「新手任务」，可以继续参考更详细的 <a href="https://wiki.aosc.io/zh/developer/packaging/package-styling-manual/">AOSC OS 软件包样式指南</a> 来了解软件包构建这方面的具体细节。</p><h2 id="包从哪里来"><a href="#包从哪里来" class="headerlink" title="包从哪里来"></a>包从哪里来</h2><h3 id="核心工作流「主题制」"><a href="#核心工作流「主题制」" class="headerlink" title="核心工作流「主题制」"></a>核心工作流「主题制」</h3><p>在动手之前，我们需要先了解一下 AOSC OS 管理软件包的独特方式。<br>首先我们要明白一点：AOSC OS 目前还没有演进到「有社区共识的第三方库」的阶段，因此你一般情况下不会见到类似 PPA 或是 AUR 这样的第三方软件源。所有的包都来自于 AOSC OS 的官方仓库，用户可以通过系统自带的包管理工具（实际上就是 <a href="https://github.com/AOSC-Dev/oma"><code>oma</code></a>）来安装和更新软件。<br>AOSC 使用 Monorepo 的方式来管理软件构建，所有软件包的构建脚本和配置文件都集中在一个名为 <a href="https://github.com/AOSC-Dev/aosc-os-abbs"><code>aosc-os-abbs</code></a> 的仓库中。   </p><p>其中，你一点仓库就会看到的 <code>stable</code> 分支就是<strong>经过审查的 AOSC OS 稳定源</strong>，也是你会从软件源中默认获取到的包。而同仓库的其他分支在 AOSC OS 中被称作<strong>主题(topic)</strong> 。在 AOSC OS 里，主题是更新、重建或修改一个或多个软件包的独立流程。通过创建不同分支的方式，每个主题可以在分支内独立进行调研、讨论、打包、测试和发布，避免与其他更新相互影响。你可以在目前还是英文的<a href="https://wiki.aosc.io/developer/packaging/topic-based-maintenance-guideline/">AOSC OS 主题制维护指南</a>了解更多细节。</p><p>如果你是最终用户，可以通过 <code>oma topics</code> 命令来查看当前可用的测试源主题，并且可以选择性地加入测试，提前尝鲜，并帮助开发者发现问题。（注意：要使用测试源，请保证你的 <code>oma mirrors</code> 选项中包含了至少一个主仓库 <code>AOSC main repository</code> ）  </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><span class="line">root@jerry-aosc-vmware [ ~ ] <span class="comment"># oma topics</span></span><br><span class="line">? 打开测试源以获取实验性更新，关闭测试源以回滚到稳定版本：</span><br><span class="line">    cmake: update to 4.0.3 (cmake-4.0.3)</span><br><span class="line">    ······</span><br><span class="line">    rocm: add new subpackage (rocm-641-add-package)</span><br><span class="line">    ······</span><br><span class="line">    [PREVIEW] Python 3.14 (python-3.14)</span><br><span class="line">    ······</span><br><span class="line">    f3d: new, 3.0.0 (f3d-new)</span><br><span class="line">    AVR Survey (May, 2025) (avr-survey-20250512)</span><br><span class="line">▼   photils-cli: new, 0.4.1 (photils-new)</span><br><span class="line">按 [Space]/[Enter] 切换开关状态，按 [Esc] 应用更改，按 [Ctrl-c] 退出。</span><br></pre></td></tr></table></figure><p>而作为开发者如何进行主题制维护，我们将在后续章节中详细介绍。</p><h3 id="寻找更新"><a href="#寻找更新" class="headerlink" title="寻找更新"></a>寻找更新</h3><p>除了 GitHub 外，你还可以在 <a href="https://packages.aosc.io/">https://packages.aosc.io/</a> 查看目前 AOSC OS 的所有软件包。首页显示了最近被推送到 <code>stable</code> 分支的包更新，点击某个包名可以查看该包的详细信息。在右上角可进行软件包搜索，在下方 <code>Repositories</code> 列表可以按架构查看包的列表。<br>当然，这肯定是个工具站，仅适用于你明确了想更新哪个软件包的情况下搜索使用。<br>那么，如果你只是想随便看看，当当扫地机器人，有没有哪个站提供了上游更新的信息呢？<br>有的有的，请转到 <a href="https://anicca.aosc.io/">https://anicca.aosc.io/</a> ，这里会给出由 <code>aosc-findupdate</code> 工具自动生成的 AOSC OS 包更新列表。你可以在这里查看到所有包的最新版本信息，来找找有没有你感兴趣的更新。<br>注意看 Warnings 里的 <code>Compliance mode enabled</code>，这代表了版本号可能会被合规处理过。根据<a href="https://wiki.aosc.io/zh/developer/packaging/package-styling-manual/">样式指南</a>，有些来自上游的版本号可能需要进行修改以符合规范，具体的规则我们之后介绍。   </p><h3 id="善其事，利其器"><a href="#善其事，利其器" class="headerlink" title="善其事，利其器"></a>善其事，利其器</h3><p>现在可以把手里的 AOSC OS 终端开起来了，我们将在本节中介绍软件包维护中通常会接触到的工具。<br>根据 <a href="https://wiki.aosc.io/zh/developer/packaging/basics/">软件包维护入门：基础教程</a> 的介绍，我们可以获知：在软件包维护的过程中总共需要和下面三个工具打交道：</p><ul><li><a href="https://github.com/AOSC-Dev/ciel-rs/">Ciel</a><ul><li>用于管理独立的 AOSC OS 构建环境（systemd-nspawn(1) 容器）。</li></ul></li><li><a href="https://github.com/AOSC-Dev/acbs/">ACBS</a><ul><li>用于管理软件包树（如主树 <em><a href="https://github.com/AOSC-Dev/aosc-os-abbs">https://github.com/AOSC-Dev/aosc-os-abbs</a></em>）及各类构建配置。</li><li>运行时，调用 <a href="https://github.com/AOSC-Dev/autobuild4">Autobuild</a> 读取软件包构建配置并执行构建脚本。</li></ul></li><li><a href="https://github.com/AOSC-Dev/autobuild4">Autobuild</a><ul><li>用于读取软件包构建配置并执行构建脚本。</li></ul></li></ul><p>通常而言，从打包者的角度来说你只需要接触第一个 Ciel 工具，因为后续的 <a href="https://github.com/AOSC-Dev/autobuild4">Autobuild</a> 和 <a href="https://github.com/AOSC-Dev/acbs/">ACBS</a> 都会在 <a href="https://github.com/AOSC-Dev/ciel-rs/">Ciel</a> 的调度下在容器中运行。</p><p>在你开始软件包维护之前，请**务必确保你已经通过各种妙妙的方式，保证你的 AOSC OS 到国际的互联不会产生什么太大的问题。**从 AOSC OS 的基础 tarball 镜像、到基于 GitHub 的 abbs 仓库，甚至于软件包构建过程中访问外部代码包，都需要顺畅的国际互联支持，本方面点到为止。</p><p>在你配置好自己的网络环境或是确保自己的互联不会出现问题后，请通过 <code>sudo -i</code> 等方式<strong>进入 <code>root</code> 用户</strong>，然后通过 <code>oma</code> 安装 <code>ciel</code> ：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">oma install ciel</span><br></pre></td></tr></table></figure><p>然后找一个地方创建一个专门的目录作为 <code>ciel</code> 的工作目录，比如 <code>~/ciel</code> ，然后在该目录下运行 <code>ciel new</code> 以创建一个新的构建环境。</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><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></pre></td><td class="code"><pre><span class="line">root@jerry-aosc-vmware [ ~ ] <span class="comment"># mkdir ~/ciel/</span></span><br><span class="line">root@jerry-aosc-vmware [ ~ ] <span class="comment"># cd ciel/</span></span><br><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># ciel new</span></span><br><span class="line">info: Welcome to ciel!</span><br><span class="line">info: Before continuing, I need to ask you a few questions:</span><br><span class="line">✔ Target Architecture · mainline: amd64</span><br><span class="line">✔ Maintainer Information · abc1763613206 &lt;wowjerry@aosc.io&gt;</span><br><span class="line">✔ Enable DNSSEC · no</span><br><span class="line">✔ Edit sources.list · no</span><br><span class="line">✔ Enable <span class="built_in">local</span> sources caching · <span class="built_in">yes</span></span><br><span class="line">✔ Enable <span class="built_in">local</span> packages repository · <span class="built_in">yes</span></span><br><span class="line">✔ Use different OUTPUT <span class="built_in">dir</span> <span class="keyword">for</span> different branches · <span class="built_in">yes</span></span><br><span class="line">✔ Use volatile mode <span class="keyword">for</span> filesystem operations · no</span><br><span class="line">info: Ciel now uses oma as the default package manager <span class="keyword">for</span> base system updating tasks.</span><br><span class="line">info: You can choose whether to use oma instead of apt <span class="keyword">while</span> configuring.</span><br><span class="line">✔ Use apt as package manager · no</span><br><span class="line">✔ Do you want to add a new instance now? · <span class="built_in">yes</span></span><br><span class="line">✔ Name of the instance · main</span><br><span class="line">info: Understood. `main` will be created after initialization is finished.</span><br><span class="line">info: Initializing workspace...</span><br><span class="line">info: Initializing container OS...</span><br></pre></td></tr></table></figure><p><code>Target Architecture</code> 请根据自己的实际情况选择合适的架构，比如 <code>amd64</code> 或 <code>arm64</code>。<br>然后是该环境的维护者信息，照前面的格式填写即可。实际上现在 AOSC OS 采用 BuildIt! 全自动构建，分发到软件源的包并不会在你的环境上被构建，所以此处填其他的问题也不算太大。<br>其他选项保持默认即可，<strong>但请注意在 <code>Do you want to add a new instance now?</code> 这个问题上选择 <code>yes</code>，并为实例命名</strong>，比如 <code>main</code>。   </p><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><span class="line">info: Searching <span class="keyword">for</span> latest AOSC OS buildkit release...</span><br><span class="line">info: Ciel has picked buildkit <span class="keyword">for</span> amd64, released on 20250606</span><br><span class="line">info: Downloading base OS rootfs...</span><br></pre></td></tr></table></figure><p><code>ciel</code> 允许你使用 <code>--from-tarball</code> 的选项来让你从一个第三方源加载 AOSC OS 的根文件系统镜像。<br>如果你卡得受不了，就可以考虑利用国内开源镜像站的镜像来加速下载了**（尽管如此，我还是建议你在进行如下的步骤之前配置好相关的网络工具）**。<br>观察如上的 <code>info</code> ，然后来拼出一个 tarball 地址：</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></pre></td><td class="code"><pre><span class="line"><span class="comment"># 比方说这里是 amd64 的 20250606 ，就可以直接使用下面的链接</span></span><br><span class="line"><span class="comment"># 如果修改架构的话，注意需要修改两处地方，注意最终的格式须为 squashfs</span></span><br><span class="line">https://mirrors.cernet.edu.cn/anthon/aosc-os/os-amd64/buildkit/aosc-os_buildkit_20250606_amd64.squashfs</span><br><span class="line"><span class="comment"># 如果 MirrorZ 给你分配的镜像站表现过差，也可以考虑直接指定一个</span></span><br><span class="line"><span class="comment"># https://mirrors.bfsu.edu.cn/anthon/aosc-os/os-amd64/buildkit/aosc-os_buildkit_20250606_amd64.squashfs</span></span><br></pre></td></tr></table></figure><p>然后，将下得极慢的进程给 <kbd>Ctrl</kbd>-<kbd>C</kbd> 终止掉，删掉整个文件夹里的内容（<code>ciel</code> 在初始化不完整时会报错，恢复空白环境可以规避掉一些问题）。<br>然后重新运行 <code>ciel new</code> ，这次加上 <code>--from-tarball</code> 选项，并将上面的 tarball 地址粘贴进去，此时会跳过让你选择架构的过程，直接下载：</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><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><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># rm -rf .ciel * </span></span><br><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># ciel new --from-tarball https://mirrors.cernet.edu.cn/anthon/aosc-os/os-amd64/buildkit/aosc-os_buildkit_20250606_amd64.squashfs</span></span><br><span class="line">info: Welcome to ciel!</span><br><span class="line">info: Before continuing, I need to ask you a few questions:</span><br><span class="line">✔ Maintainer Information · abc1763613206 &lt;wowjerry@aosc.io&gt;</span><br><span class="line">······</span><br><span class="line">✔ Do you want to add a new instance now? · <span class="built_in">yes</span></span><br><span class="line">✔ Name of the instance · main</span><br><span class="line">info: Understood. `main` will be created after initialization is finished.</span><br><span class="line">info: Initializing workspace...</span><br><span class="line">info: Initializing container OS...</span><br><span class="line">info: Using custom squashfs from https://mirrors.cernet.edu.cn/anthon/aosc-os/os-amd64/buildkit/aosc-os_buildkit_20250606_amd64.squashfs</span><br><span class="line">info: Downloading base OS rootfs...</span><br><span class="line">info: Initializing ABBS tree...                                                                                                                                                                              </span><br><span class="line">info: Applying configurations...                                                                                                                                                                             </span><br><span class="line">info: Configurations applied.</span><br><span class="line">info: Setting up <span class="built_in">local</span> repository ...</span><br><span class="line">info: Scanning 0 packages...</span><br><span class="line"></span><br><span class="line">info: Local repository ready.</span><br><span class="line">info: main: instance initialized.</span><br><span class="line">info: main: filesystem mounted.</span><br><span class="line">info: Scanning 0 packages...</span><br><span class="line"></span><br><span class="line">info: main: <span class="built_in">local</span> repository initialized.</span><br></pre></td></tr></table></figure><p>在工作区配置完成后，请首先运行 <code>ciel update-os</code> 来尽可能地更新容器内的系统环境，否则以后在每次构建时都要更新一遍，浪费时间。   </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><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># ciel update-os</span></span><br><span class="line">info: Updating base OS...</span><br><span class="line">info: update-ce9206ea: instance created.</span><br><span class="line">info: update-ce9206ea: filesystem mounted.</span><br><span class="line">info: update-ce9206ea-7ec908fe: waiting <span class="keyword">for</span> container to start...</span><br><span class="line">info: update-ce9206ea-7ec908fe: setting up mounts...</span><br><span class="line">     INFO Refreshing <span class="built_in">local</span> database ...</span><br></pre></td></tr></table></figure><p>接下来，你就可以使用 <code>ciel build -i main</code> 来构建软件包了，可以像官方 Wiki 那样，打一个 <code>flac</code> 包试一试：</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><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></pre></td><td class="code"><pre><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># ciel build -i main flac</span></span><br><span class="line">······</span><br><span class="line">[INFO]:  Archiving package(s) ...</span><br><span class="line">[INFO]:  Using abpm_aosc_archive as autobuild archiver ...</span><br><span class="line"><span class="string">&#x27;flac_1.5.0-0_amd64.deb&#x27;</span> -&gt; <span class="string">&#x27;/debs/f/flac_1.5.0-0_amd64.deb&#x27;</span></span><br><span class="line"><span class="string">&#x27;flac-dbg_1.5.0-0_amd64.deb&#x27;</span> -&gt; <span class="string">&#x27;/debs/f/flac-dbg_1.5.0-0_amd64.deb&#x27;</span></span><br><span class="line">[INFO]: Asking ciel to refresh repository...</span><br><span class="line">[INFO]: Waiting <span class="keyword">for</span> ciel to refresh repository...</span><br><span class="line">info: Scanning 2 packages...</span><br><span class="line">..</span><br><span class="line">[INFO]: Ciel finished refreshing repository...</span><br><span class="line"></span><br><span class="line">========================================</span><br><span class="line">    ACBS Build Successful</span><br><span class="line">========================================</span><br><span class="line"></span><br><span class="line">Package(s) built:</span><br><span class="line">flac (amd64 @ 1.5.0-0)                  0:00:34.624463</span><br><span class="line"></span><br><span class="line">info: main: stopping...</span><br><span class="line">info: main: instance stopped.</span><br><span class="line">info: main: filesystem un-mounted.</span><br><span class="line">info: main: mount point removed.</span><br><span class="line">info: main: rolling back instance...</span><br><span class="line">info: main: instance has been rolled back.                                                                                                                                                                   </span><br><span class="line">BUILD SUCCESSFUL - 1 packages <span class="keyword">in</span> 00:00:51</span><br></pre></td></tr></table></figure><p>通常而言，看到了 <code>ACBS Build Successful</code> ，就说明构建过程没有问题，可以找到生成的包了。<br>因为我们没有切换分支，所以我们此时构建的包在 <code>OUTPUT-stable</code> 目录下，具体到 <code>flac</code> 包就是 <code>OUTPUT-stable/debs/f/flac_1.5.0-0_amd64.deb</code> 。<br>你可以使用 <code>oma install</code> 命令来亲手试用这个包：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">oma install OUTPUT-stable/debs/f/flac_1.5.0-0_amd64.deb</span><br></pre></td></tr></table></figure><h2 id="小试牛刀"><a href="#小试牛刀" class="headerlink" title="小试牛刀"></a>小试牛刀</h2><h3 id="第一个主题"><a href="#第一个主题" class="headerlink" title="第一个主题"></a>第一个主题</h3><p>在上一章中，我们已经搭建好了名为 <code>ciel</code> 的工作间，完成了一些说大不大说小不小的准备工作，现在是时候把它投入使用了。<br>请注意，本节的流程是为首次贡献的朋友们设计的。当你成功提交代码并成为社区贡献者后，部分操作会变得更加简化，我们到时再谈。 </p><p>使用 <code>ls</code> 命令查看当前目录，通常你现在看到的会类似这样：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># ls</span></span><br><span class="line">aosc-os_buildkit_20250606_amd64.squashfs  CACHE  OUTPUT-stable  SRCS  TREE</span><br></pre></td></tr></table></figure><p>注意看那个 <code>TREE</code> 目录，它就是之前我们提到的 <a href="https://github.com/AOSC-Dev/aosc-os-abbs">aosc-os-abbs</a>，也就是存放构建配置的主仓库。<br>作为 Newcomer 我们自然还没有向这个仓库推送的权限，因此先去 <a href="https://github.com/AOSC-Dev/aosc-os-abbs/fork">https://github.com/AOSC-Dev/aosc-os-abbs/fork</a> ，创建一个自己的 Fork ，比如说我的创建后就是 <a href="https://github.com/abc1763613206/aosc-os-abbs">https://github.com/abc1763613206/aosc-os-abbs</a>。</p><p>然后 <code>cd</code> 到 <code>TREE</code> 目录下，把 <code>origin</code> 暂时指向到自己的 Fork （当然你如果精通 Git 操作的话，形式上创建一个 upstream 是一个更好的实践）：</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><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># cd TREE</span></span><br><span class="line">root@jerry-aosc-vmware [ TREE@stable ] <span class="comment"># git remote set-url origin https://github.com/abc1763613206/aosc-os-abbs.git</span></span><br><span class="line">root@jerry-aosc-vmware [ TREE@stable ] <span class="comment"># git pull # 更新本地代码</span></span><br></pre></td></tr></table></figure><p>然后就可以挑选包了！我们以写稿时挂在 <a href="https://anicca.aosc.io/">Anicca</a> 首页的 <code>7-zip</code> 为例：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/AOSC-Newbie-Contribution-Guide/image-20250807205358017.png" alt="7-zip"/></div><div class="image-meta"><span class="image-caption center">7-zip</span></div></div><p>可以看到，<code>7-zip</code> 的目录是 <code>app-utils</code> ，当前仓库里的版本是 <code>25.00</code> ，但是最新版来到了 <code>25.01</code>。<br>确定了目标，我们就可以在「主题制」维护机制下，亲手创建属于自己的第一个主题了。<br>正如上所说，主题是更新、重建或修改一个或多个软件包的独立流程。参考 <a href="https://wiki.aosc.io/developer/packaging/topic-based-maintenance-guideline/">AOSC OS 主题制维护指南</a> ，我们可以总结出如下的主题（对应到 Git 中也就是分支）命名规则：   </p><ul><li><strong>更新</strong>：<code>$PKGNAME-$PKGVER</code>（如 <code>nano-5.4</code>）。</li><li><strong>非更新</strong>（<code>PURPOSE</code> 说明意图）：<code>$PKGNAME-$PKGVER-$PURPOSE</code>（如 <code>gnome-shell-3.38.1-build-fix</code>）。</li></ul><p>还有个复杂的例外：如果一次主题涉及<strong>多个软件包、多个版本，并且这些软件包之间没有明确的主从关系或统一版本号</strong>时，主题名将使用<strong>主包名（或统称）+ <code>-survey</code> + 日期</strong>，例如：<code>rime-data-survey-20200928</code>。<br>你可以回到之前那一段，看看 <code>oma topics</code> 的输出，来验证分别属于哪种类型。</p><p>对应到我们这个例子，我们要创建的主题名就是 <code>7-zip-25.01</code>。<br>进入到 TREE 目录，使用 <code>git checkout -b 7-zip-25.01</code> 创建一个新的分支，然后开工！</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></pre></td><td class="code"><pre><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># cd TREE</span></span><br><span class="line">root@jerry-aosc-vmware [ TREE@stable ] <span class="comment"># git checkout -b 7-zip-25.01</span></span><br><span class="line">切换到一个新分支 <span class="string">&#x27;7-zip-25.01&#x27;</span></span><br><span class="line">root@jerry-aosc-vmware [ TREE@7-zip-25.01 ] <span class="comment"># </span></span><br></pre></td></tr></table></figure><h3 id="检包、拆包"><a href="#检包、拆包" class="headerlink" title="检包、拆包"></a>检包、拆包</h3><p>进入到 TREE ，这个包处在 <code>app-utils/7-zip</code> 目录下，用 <code>tree</code> 命令看一眼这个文件夹的架构：</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></pre></td><td class="code"><pre><span class="line">root@jerry-aosc-vmware [ TREE@7-zip-25.01 ] <span class="comment"># tree app-utils/7-zip/</span></span><br><span class="line">app-utils/7-zip/</span><br><span class="line">├── autobuild</span><br><span class="line">│   ├── build</span><br><span class="line">│   ├── defines</span><br><span class="line">│   └── patches</span><br><span class="line">│       └── 0001-fix-CPP-missing-predefined-compiler-flags.patch</span><br><span class="line">└── spec</span><br></pre></td></tr></table></figure><p>通常而言，这个包就代表了大部分最简软件包的架构（当然了，有些顺风顺水的软件包连补丁 <code>patches</code> 文件夹都没有）。<br>对于软件包的全部细节，请直接参照 <a href="https://wiki.aosc.io/zh/developer/packaging/package-styling-manual/">AOSC OS 软件包样式指南</a> ，事实上在打包的全流程中，你应该把这个指南作为参考，在遇到问题时查阅，未覆盖的地方再求助于社区。   </p><p>我们以实践出真知的精神，来具体看看包里都有啥。</p><h4 id="spec"><a href="#spec" class="headerlink" title="spec"></a>spec</h4><p>对于 <code>7-zip</code> 而言，<code>spec</code> 文件的内容长这样：</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></pre></td><td class="code"><pre><span class="line">VER=25.00</span><br><span class="line">SRCS=<span class="string">&quot;git::commit=tags/<span class="variable">$VER</span>::https://github.com/ip7z/7zip&quot;</span></span><br><span class="line">CHKSUMS=<span class="string">&quot;SKIP&quot;</span></span><br><span class="line">CHKUPDATE=<span class="string">&quot;anitya::id=17875&quot;</span></span><br></pre></td></tr></table></figure><p>虽然略显简陋，但是构成软件包的必要信息都在这里了。<br>第一行的 <code>VER</code> 变量定义了软件包的版本号，然后 <code>SRCS</code> 变量定义了软件包的源代码上游，<code>CHKSUMS</code> 变量定义了软件包的校验值，<code>CHKUPDATE</code> 变量定义了软件包的更新来源。<br><code>CHKUPDATE</code> 变量是用于给 Anicca 这类工具提供更新信息的，仅在新增软件包时会用到，通常情况下你不需要修改它。我们重点说一下其他三个。   </p><p>首先是 <code>VER</code> 变量，在 AOSC OS 的软件包中 <code>VER</code> 直接指向软件包的版本号，这就带来了几个问题：   </p><ol><li>之前说过的 <code>Compliance mode enabled</code> 怎么回事？<br> 请参考 <a href="https://wiki.aosc.io/zh/developer/packaging/package-styling-manual/#ruan-jian-bao-ban-ben">AOSC OS 软件包样式指南</a> 里的「软件包版本」一节。通常而言，当上游的版本号只带有半角句号时，可以直接被使用；如果上游的版本号带有其他符号（如 <code>+</code> 或 <code>_</code> 等），则需要进行<strong>规范化</strong>处理。具体参见样式指南里的表格。   </li><li>如果一个软件包里复合了多个上游软件包，或者说被规范化后的版本号无法直接被下面的 <code>SRCS</code> 变量所使用，怎么办？<br> <code>SRCS</code> 不一定非要引用 <code>VER</code> 变量，实际上你可以按需要在 <code>spec</code> 文件中<strong>定义任意多个变量来满足你的需求</strong>。比如对 <code>lang-golang/go</code> 软件包而言，它复合了多个上游软件包，版本号也比较复杂，因此它的 <code>spec</code> 文件变成了这样：</li></ol><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><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><span class="line">UPSTREAM_VER=1.24.2</span><br><span class="line"><span class="comment"># Versions of Golang, net library and Go tools</span></span><br><span class="line">GO_VER=<span class="variable">$&#123;UPSTREAM_VER/\~/&#125;</span></span><br><span class="line">TOOLS_VER=0.31.0</span><br><span class="line">NET_VER=0.38.0</span><br><span class="line">REL=3</span><br><span class="line"></span><br><span class="line">VER=<span class="string">&quot;<span class="variable">$&#123;UPSTREAM_VER&#125;</span>+tools<span class="variable">$&#123;TOOLS_VER&#125;</span>+net<span class="variable">$&#123;NET_VER&#125;</span>&quot;</span></span><br><span class="line">SRCS=<span class="string">&quot;tbl::https://go.dev/dl/go<span class="variable">$&#123;GO_VER&#125;</span>.src.tar.gz \</span></span><br><span class="line"><span class="string">      git::commit=tags/v<span class="variable">$&#123;TOOLS_VER&#125;</span>;rename=tools::https://go.googlesource.com/tools \</span></span><br><span class="line"><span class="string">      git::commit=tags/v<span class="variable">$&#123;NET_VER&#125;</span>;rename=net::https://github.com/golang/net&quot;</span></span><br><span class="line">CHKSUMS=<span class="string">&quot;sha256::9dc77ffadc16d837a1bf32d99c624cb4df0647cee7b119edd9e7b1bcc05f2e00 \</span></span><br><span class="line"><span class="string">         SKIP \</span></span><br><span class="line"><span class="string">         SKIP&quot;</span></span><br><span class="line">CHKUPDATE=<span class="string">&quot;anitya::id=1227&quot;</span></span><br><span class="line">SUBDIR=<span class="string">&quot;go&quot;</span></span><br></pre></td></tr></table></figure><p>它目前的软件包版本为 <code>1.24.2+tools0.31.0+net0.38.0</code> ，使用 <code>+</code> 符号复合了不同的来源，在 <code>SRCS</code> 里的不同来源也引用了不同变量。</p><p>而 <code>SRCS</code> 可在 <a href="https://wiki.aosc.io/developer/packaging/acbs/spec-format/#spec">ACBS - Specification Files</a> 文档中查看详情，我们和下面的 <code>CHKSUMS</code> 一起看。<br>源码的来源一般分为两种情况：</p><ul><li>来源于基于 Git 的代码仓库，使用 <code>git::commit=tags/xxx::https://github.com/xxx/xxx</code> 这种形式，这种情况下 <code>CHKSUMS</code> 变量请使用 <code>SKIP</code>。</li><li>来源于基于 tarball 的代码包，使用 <code>tbl::https://xxx/xxx.tar.gz</code> 这种形式，这种情况下 <code>CHKSUMS</code> 变量需要提供一个校验值，比如说 SHA256。</li></ul><p>如果是复合引用的软件包，你通常需要对其他来源进行重命名，这时请在 <code>SRCS</code> 里使用 <code>rename</code> 选项。<br>此时，你通常可能需要<strong>显式指定一个子目录作为构建的主目录</strong>，使得 <code>build</code> 构建工作流能够进入正确的文件夹发起构建，此时<strong>请在 <code>spec</code> 文件中使用 <code>SUBDIR</code> 变量来指定目录</strong>。<br>在如上的 <code>lang-golang</code> 包中，因为有多个上游复合，所以显式指定了 <code>SUBDIR=&quot;go&quot;</code> 。这里的指定需要根据不同包的情况灵活变通，通常需要自己打开相关 tarball 查看具体的结构，例如在 <code>nginx</code> 中，这里的值就变成了 <code>SUBDIR=&quot;nginx-$NGINX_VER&quot;</code> 。<strong>如果你的源码包只有单个&#x2F;零个文件夹且没有复合来源的情况，你通常不需要考虑这个值。</strong>  </p><p>除此之外，还有一个如上 <code>spec</code> 文件中没有体现，但仍需要重点说明的变量：<code>REL</code> 。   </p><p><code>REL</code> 变量定义了软件包的修订号，该变量用于处理上游软件包未发生改变，但是在 AOSC OS 生态中需要进行修订的情况（通常是之前适配出现了 BUG 或者应急响应其他补丁）。此变量的默认值为 0，在主版本 (<code>VER</code>) 不变的情况下，每次修订应为该值递增 1。<strong>当软件包的主版本（<code>VER</code>）发生变化时，<code>REL</code> 变量应重置为 0，体现在实践中时，你应当直接把含有 <code>REL</code> 变量的行删除。</strong>  </p><p>比如这是一个 <code>ninja</code> 的更新：</p><figure class="highlight patch"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="deletion">- VER=1.12.1</span></span><br><span class="line"><span class="addition">+ VER=1.13.1</span></span><br><span class="line">SRCS=&quot;git::commit=tags/v$VER::https://github.com/ninja-build/ninja&quot;</span><br><span class="line">CHKSUMS=&quot;SKIP&quot;</span><br><span class="line">CHKUPDATE=&quot;anitya::id=2089&quot;</span><br><span class="line"><span class="deletion">- REL=1</span></span><br></pre></td></tr></table></figure><p>回到 <code>7-zip</code> 包的 <code>spec</code> 文件，很幸运我们并不需要处理如上大部分复杂的操作，也不存在需要我们删除的 <code>REL</code> 行，所以直接修改一下版本号即可。</p><figure class="highlight patch"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="deletion">- VER=25.00</span></span><br><span class="line"><span class="addition">+ VER=25.01</span></span><br><span class="line">SRCS=&quot;git::commit=tags/$VER::https://github.com/ip7z/7zip&quot;</span><br><span class="line">CHKSUMS=&quot;SKIP&quot;</span><br><span class="line">CHKUPDATE=&quot;anitya::id=17875&quot;</span><br></pre></td></tr></table></figure><h4 id="autobuild"><a href="#autobuild" class="headerlink" title="autobuild"></a>autobuild</h4><p>我们已经仔细研究了软件包的「身份证」 <code>spec</code>，现在，让我们来参观一下它的「引擎室」—— <code>autobuild</code> 目录。这个目录里的文件，共同决定了软件包是如何从一堆源代码，被 <a href="https://github.com/AOSC-Dev/autobuild4">Autobuild</a> 一步步加工成可安装文件的。<br>不过，在开始导览前，请先松一口气：<strong>对于大多数常规的版本更新，这个目录里的文件你可能一个字都不用改。</strong> 我们即将采用的策略是「先信任，后验证」——首先假设现有的构建流程在新版本上依然有效，只有在后续构建失败时，我们才需要回到这里来排查问题。<br>首先是 <code>defines</code> 文件，该文件定义软件包的各项核心配置。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">PKGNAME=7-zip</span><br><span class="line">PKGSEC=utils</span><br><span class="line">PKGDEP=&quot;gcc-runtime glibc&quot;</span><br><span class="line">PKGDES=&quot;A file archive manager with support for multiple formats&quot;</span><br></pre></td></tr></table></figure><p>该文件详细的定义请参考 <a href="https://wiki.aosc.io/zh/developer/packaging/basics/#autobuild-defines">Wiki 上的入门教程</a> 文档。<br>通常对于软件包维护更新而言，我们需要关注的是<strong>软件包的依赖关系在更新后有没有发生变化</strong>，分为构建依赖 <code>BUILDDEP</code> 和运行时依赖 <code>PKGDEP</code>，其中前者是仅在构建过程中需要的。你可以在<a href="https://wiki.aosc.io/zh/developer/packaging/package-styling-manual/#yi-lai-xiang">样式指南</a>查看更多细节，但对于软件包更新而言，一个比较符合直觉的工作流是：通过之后构建时的表现以及最后运行时的测试来验证这些依赖关系是否仍然有效，是否需要添加新的依赖。</p><p><code>patches</code> 目录下存放了软件包的补丁文件，通常用于修复上游代码中的问题或适配 AOSC OS 的特定需求。对于 <code>7-zip</code> 包而言，我们可以看到一个名为 <code>0001-fix-CPP-missing-predefined-compiler-flags.patch</code> 的补丁文件，这个补丁是为了修复编译器预定义标志缺失的问题。<br><code>patches</code> 目录下的补丁文件请带序号依次保存，具体制作补丁的方式请参考 <a href="https://wiki.aosc.io/zh/developer/packaging/package-styling-manual/#bu-ding-ming-ming">AOSC OS 软件包样式指南</a> 。通常而言，建议补丁文件的命名应符合下面的格式：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">NNNN-<span class="variable">$CATEGORY</span>-<span class="variable">$CONTENT</span>.patch</span><br></pre></td></tr></table></figure><p>其中：</p><ul><li><code>NNNN</code> 与前面的示例补丁名称一样，是用于为补丁排序的“序列号”。</li><li><code>$CATEGORY</code> 定义了补丁的类别，例如 <code>bugfix</code>、<code>feature</code> 等。</li><li><code>$CONTENT</code> 定义了补丁的作用，例如 <code>fix-build-with-openssl-1.1</code>。</li></ul><p>当然，在软件包更新中，我们经常会遇到<strong>因为软件包代码段更新而导致补丁失效</strong>的情况。这时，我们需要根据实际情况对补丁进行调整，确保其能够正确应用。<br>具体而言，你可以拉取下对应的 Git 仓库，参考现有的 patch 文件人工完成相应的修改，然后使用类似 <code>git diff &gt; xxx.patch</code> 的命令生成新的补丁文件。<strong>对于有补丁的软件包而言，这是更新过程中经常会遇到的情况。</strong></p><p><code>build</code> 文件定义了软件包构建的具体细节，它被 <a href="https://github.com/AOSC-Dev/autobuild4">Autobuild</a> 读取并执行，你可以在<a href="https://wiki.aosc.io/zh/developer/packaging/advanced-techniques/">Wiki 上的进阶教程</a>中获取一些细节。</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><span class="line">abinfo <span class="string">&quot;Building 7-zip ...&quot;</span></span><br><span class="line"><span class="built_in">cd</span> <span class="string">&quot;<span class="variable">$&#123;SRCDIR&#125;</span>/CPP/7zip/Bundles/Alone2&quot;</span></span><br><span class="line">make -f ../../cmpl_gcc.mak</span><br><span class="line"></span><br><span class="line">abinfo <span class="string">&quot;Installing 7-zip ...&quot;</span></span><br><span class="line">install -v -Dm755 <span class="string">&quot;<span class="variable">$&#123;SRCDIR&#125;</span>/CPP/7zip/Bundles/Alone2/b/g/7zz&quot;</span> <span class="string">&quot;<span class="variable">$&#123;PKGDIR&#125;</span>/usr/bin/7zz&quot;</span></span><br><span class="line">install -v -Dm644 <span class="string">&quot;<span class="variable">$&#123;SRCDIR&#125;</span>/DOC/&quot;</span>* -t <span class="string">&quot;<span class="variable">$&#123;PKGDIR&#125;</span>/usr/share/doc/<span class="variable">$&#123;PKGNAME&#125;</span>&quot;</span></span><br></pre></td></tr></table></figure><p>此外，还可能会出现构建前会运行的 <code>prepare</code> 文件，用于初始化一些构建环境；构建后会运行的 <code>beyond</code> 文件，用于打扫一下构建现场。还可能会出现一个 <code>overrides</code> 文件夹，该文件夹存放了一些源代码中不存在，但是对最终软件包有用的文件，通常是一些安装到桌面的文件和预设配置，你可以在<a href="https://wiki.aosc.io/zh/developer/packaging/advanced-techniques/"> Wiki 上的进阶教程</a>中获取一些细节。   </p><p>但通常如上文所说，对于大多数软件包的常规更新，<code>autobuild</code> 目录中的配置都相对稳定。因此在实际操作中，我们不必过早陷入 <code>autobuild</code> 目录的全部细节。对于绝大多数增量更新，首要的策略是<strong>仅修改 spec 文件中的版本号（<code>VER</code>）与校验和（<code>CHKSUMS</code>），然后大胆尝试构建</strong>。除非遇到涉及底层重构的重大版本更迭，否则构建脚本通常能平稳运行。只有当构建失败或是后续测试出现问题时，才有必要回过头来，依据错误信息深入排查 <code>autobuild</code> 目录中的具体配置。</p><h3 id="构建一下试试？"><a href="#构建一下试试？" class="headerlink" title="构建一下试试？"></a>构建一下试试？</h3><p>我们可以在 <code>ciel</code> 文件夹下的任意目录，使用 <code>ciel build -i main 7-zip</code> 来尝试构建一下我们更新的 <code>7-zip</code> 包：</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><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></pre></td><td class="code"><pre><span class="line">root@jerry-aosc-vmware [ TREE@7-zip-25.01 ] <span class="comment"># ciel build -i main 7-zip</span></span><br><span class="line">······</span><br><span class="line">[INFO]:  Using free-formed build script</span><br><span class="line">[INFO]:  Applying patch 0001-fix-CPP-missing-predefined-compiler-flags.patch ...</span><br><span class="line">patching file CPP/7zip/7zip_gcc.mak</span><br><span class="line">······</span><br><span class="line">========================================</span><br><span class="line">    ACBS Build Successful</span><br><span class="line">========================================</span><br><span class="line"></span><br><span class="line">Package(s) built:</span><br><span class="line">7-zip (amd64 @ 25.01-0)                 0:00:37.503451</span><br><span class="line"></span><br><span class="line">info: main: stopping...</span><br><span class="line">info: main: instance stopped.</span><br><span class="line">info: main: filesystem un-mounted.</span><br><span class="line">info: main: mount point removed.</span><br><span class="line">info: main: rolling back instance...</span><br><span class="line">info: main: instance has been rolled back.</span><br><span class="line">BUILD SUCCESSFUL - 1 packages <span class="keyword">in</span> 00:00:53</span><br></pre></td></tr></table></figure><p>可以看到，这里的构建过程顺利完成，没有遇到任何问题。<br>而如果你看到的不是 <code>Build Successful</code> 的信息，那就要专门看一眼日志了。   </p><p>比如在更新软件包时最常出现的补丁失效情况，本着实验的精神，我们来故意破坏一下 <code>7-zip</code> 目录下的那个补丁，然后重新运行构建：<br>构建自然会失败，然后你往上翻一翻日志会看见这一段：</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></pre></td><td class="code"><pre><span class="line">[INFO]:  Applying patch 0001-fix-CPP-missing-predefined-compiler-flags.patch ...</span><br><span class="line">patching file CPP/7zip/7zip_gcc.mak</span><br><span class="line">Hunk <span class="comment">#1 FAILED at 44.</span></span><br><span class="line">1 out of 4 hunks FAILED -- saving rejects to file CPP/7zip/7zip_gcc.mak.rej</span><br><span class="line">In file included from /usr/bin/autobuild:13</span><br><span class="line">                 from /usr/lib/autobuild4/proc/40-patch.sh:23</span><br><span class="line">/usr/lib/autobuild4/lib/builtin.sh:17: In <span class="keyword">function</span> `ab_apply_patches<span class="string">&#x27;:</span></span><br><span class="line"><span class="string">/usr/lib/autobuild4/lib/builtin.sh:17: error: command exited with 1</span></span><br><span class="line"><span class="string">   16 |                         abinfo &quot;Applying patch $(basename &quot;$i&quot;) ...&quot;</span></span><br><span class="line"><span class="string">&gt;  17 |                         patch &quot;$&#123;PATCHFLAGS[@]&#125;&quot; -i &quot;$i&quot; || abdie &quot;Applying patch $i failed&quot;</span></span><br><span class="line"><span class="string">   18 |                 fi</span></span><br></pre></td></tr></table></figure><p>这段日志就具体地告诉了我们哪个补丁应用失败，这时我们就要结合具体情况，考虑人工修改补丁或是重做补丁。<br>当然这里是我们人工破坏的，玩够了还原回去就好。</p><p>重新构建完成后回到 <code>ciel</code> 的根工作目录，我们会发现现在变成了这样：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># ls</span></span><br><span class="line">aosc-os_buildkit_20250606_amd64.squashfs  CACHE  OUTPUT-7-zip-25.01  OUTPUT-stable  SRCS  STATES  TREE</span><br></pre></td></tr></table></figure><p>可以看到，输出目录多出来了个以主题名命名的 <code>OUTPUT-7-zip-25.01</code> 目录，这个目录下存放了我们刚刚构建的 <code>7-zip</code> 包：</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></pre></td><td class="code"><pre><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># tree OUTPUT-7-zip-25.01/</span></span><br><span class="line">OUTPUT-7-zip-25.01/</span><br><span class="line">└── debs</span><br><span class="line">    ├── 7</span><br><span class="line">    │   ├── 7-zip_25.01-0~pre20250807T182427Z~dirty_amd64.deb</span><br><span class="line">    │   ├── 7-zip-dbg_25.01-0~pre20250807T182427Z~dirty_amd64.deb</span><br><span class="line">    ├── fresh.lock</span><br><span class="line">    ├── Packages</span><br><span class="line">    └── Release</span><br><span class="line"></span><br><span class="line">3 directories, 7 files</span><br></pre></td></tr></table></figure><p>具体到这个软件包，我们可以使用 <code>oma install</code> 来安装它：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">oma install OUTPUT-7-zip-25.01/debs/7/7-zip_25.01-0~pre20250807T182427Z~dirty_amd64.deb</span><br></pre></td></tr></table></figure><p>接着，我们就可以对软件包进行基础的功能测试了，具体到 <code>7-zip</code> 这个包，我们可以直接使用它自带的 <code>7zz b</code> 来首先跑一个 Benchmark：</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><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></pre></td><td class="code"><pre><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># 7zz b</span></span><br><span class="line"></span><br><span class="line">7-Zip (z) 25.01 (x64) : Copyright (c) 1999-2025 Igor Pavlov : 2025-08-03</span><br><span class="line"> 64-bit locale=zh_CN.UTF-8 Threads:128 OPEN_MAX:1024</span><br><span class="line"></span><br><span class="line">Compiler:  ver:14.3.0 20250523 (AOSC OS, Core) GCC 14.3.0 : SSE2</span><br><span class="line">Linux : 6.14.11-aosc-main : <span class="comment">#1 SMP PREEMPT_DYNAMIC Tue Jul 22 02:28:29 UTC 2025 : x86_64 : VMwareVMware :  : 0.0.0.0.0.0</span></span><br><span class="line">PageSize:4KB THP:always hwcap:2 hwcap2:2</span><br><span class="line">12th Gen Intel(R) Core(TM) i7-12700H</span><br><span class="line">(906A3)</span><br><span class="line"></span><br><span class="line">1T CPU Freq (MHz):  3992  4019  3887  3878  3861  3891  3874</span><br><span class="line">2T CPU Freq (MHz): 200% 3869   199% 3885  </span><br><span class="line">4T CPU Freq (MHz): 362% 3497   357% 3502  </span><br><span class="line"></span><br><span class="line">RAM size:    3690 MB,  <span class="comment"># CPU hardware threads:   4 / 128 : 0000000000000000000000000000000F</span></span><br><span class="line">RAM usage:    889 MB,  <span class="comment"># Benchmark threads:      4</span></span><br><span class="line"></span><br><span class="line">                       Compressing  |                  Decompressing</span><br><span class="line">Dict     Speed Usage    R/U Rating  |      Speed Usage    R/U Rating</span><br><span class="line">         KiB/s     %   MIPS   MIPS  |      KiB/s     %   MIPS   MIPS</span><br><span class="line"></span><br><span class="line">22:      28211   356   7708  27445  |     187602   370   4321  16005</span><br><span class="line">23:      20539   320   6546  20928  |     182650   374   4231  15804</span><br><span class="line">24:      16914   296   6141  18187  |     165834   364   4002  14553</span><br><span class="line">25:      15152   307   5629  17300  |     160144   351   4061  14253</span><br><span class="line">----------------------------------  | ------------------------------</span><br><span class="line">Avr:     20204   320   6506  20965  |     174058   365   4154  15154</span><br><span class="line">Tot:             342   5330  18059</span><br></pre></td></tr></table></figure><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><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><span class="line">root@jerry-aosc-vmware [ ciel ] <span class="comment"># 7zz a 7zip.7z OUTPUT-7-zip-25.01/</span></span><br><span class="line"></span><br><span class="line">7-Zip (z) 25.01 (x64) : Copyright (c) 1999-2025 Igor Pavlov : 2025-08-03</span><br><span class="line"> 64-bit locale=zh_CN.UTF-8 Threads:128 OPEN_MAX:1024</span><br><span class="line"></span><br><span class="line">Scanning the drive:</span><br><span class="line">3 folders, 8 files, 21114503 bytes (21 MiB)</span><br><span class="line"></span><br><span class="line">Creating archive: 7zip.7z</span><br><span class="line"></span><br><span class="line">Add new data to archive: 3 folders, 8 files, 21114503 bytes (21 MiB)</span><br><span class="line"></span><br><span class="line">                                                                                        </span><br><span class="line">Files <span class="built_in">read</span> from disk: 8</span><br><span class="line">Archive size: 11040172 bytes (11 MiB)</span><br><span class="line">Everything is Ok</span><br></pre></td></tr></table></figure><p>版本也确实是更新后的 <code>25.01</code> ，本地的测试没有问题，就可以准备发起 PR 了。</p><h3 id="发起贡献"><a href="#发起贡献" class="headerlink" title="发起贡献"></a>发起贡献</h3><p>在发起构建之前，首先需要了解一下 AOSC OS 中的 Commit 规范：Commit 面向最终用户，通常情况下对于同一个包的所有更改<strong>应该在同一条 Commit 中完成</strong>。<br>你可以在 <a href="https://wiki.aosc.io/zh/developer/packaging/package-styling-manual/#git-ti-jiao-xin-xi">软件包样式指南</a> 中找到所有的规范，Commit 的消息通常遵从 <code>&lt;包名&gt;: &lt;动词短语&gt; [附加说明] [; #&lt;issue号&gt;]</code> 的格式，最常用的如下：</p><ul><li><strong>引入新包</strong>：<code>$PKGNAME: new, $PKGVER</code>，比如 <code>cherry-studio: new, 1.5.2</code></li><li><strong>版本更新</strong>：<ul><li>普通更新：<code>$PKGNAME: update to $PKGVER</code>，比如 <code>7-zip: update to 25.01</code></li><li>安全更新：<code>$PKGNAME: update to $PKGVER; #NNN</code>，比如 <code>7-zip: update to 25.00; #11761</code>，其中 <code>#NNN</code> 是对应安全通告的 issue 编号。</li></ul></li><li><strong>软件包改动</strong>：<code>$PKGNAME: $PURPOSE</code>，通常这种情况下之前说过的 <code>REL</code> 也要递增。</li></ul><p>如果你的 Commit 干了不止跳版本的一件事，你可以在 Commit 中换行，分条详细说明，参见<a href="https://wiki.aosc.io/zh/developer/packaging/package-styling-manual/#git-ti-jiao-xin-xi">软件包样式指南</a>。</p><p>具体到我们这个例子，我们直接添加所有改动，使用 <code>7-zip: update to 25.01</code> 作为 Commit，然后推送即可：</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><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></pre></td><td class="code"><pre><span class="line">root@jerry-aosc-vmware [ TREE@7-zip-25.01 ] <span class="comment"># git add .</span></span><br><span class="line">root@jerry-aosc-vmware [ TREE@7-zip-25.01 ] <span class="comment"># git status</span></span><br><span class="line">位于分支 7-zip-25.01</span><br><span class="line">要提交的变更：</span><br><span class="line">  （使用 <span class="string">&quot;git restore --staged &lt;文件&gt;...&quot;</span> 以取消暂存）</span><br><span class="line">        修改：     app-utils/7-zip/spec</span><br><span class="line">root@jerry-aosc-vmware [ TREE@7-zip-25.01 ] <span class="comment"># git commit -m &quot;7-zip: update to 25.01&quot;</span></span><br><span class="line">[7-zip-25.01 aaa13645db] 7-zip: update to 25.01</span><br><span class="line"> 1 file changed, 1 insertion(+), 1 deletion(-)</span><br><span class="line">root@jerry-aosc-vmware [ TREE@7-zip-25.01 ] <span class="comment"># git push</span></span><br><span class="line">致命错误：当前分支 7-zip-25.01 没有对应的上游分支。</span><br><span class="line">为推送当前分支并建立与远程上游的跟踪，使用</span><br><span class="line"></span><br><span class="line">    git push --set-upstream origin 7-zip-25.01</span><br><span class="line"></span><br><span class="line">为了让没有追踪上游的分支自动配置，参见 <span class="string">&#x27;git help config&#x27;</span> 中的 push.autoSetupRemote。</span><br><span class="line">root@jerry-aosc-vmware [ TREE@7-zip-25.01 ] ! git push --set-upstream origin 7-zip-25.01</span><br><span class="line">枚举对象中: 9, 完成.</span><br><span class="line">对象计数中: 100% (9/9), 完成.</span><br><span class="line">使用 4 个线程进行压缩</span><br><span class="line">压缩对象中: 100% (5/5), 完成.</span><br><span class="line">写入对象中: 100% (5/5), 452 字节 | 452.00 KiB/s, 完成.</span><br><span class="line">总共 5（差异 3），复用 0（差异 0），包复用 0（来自  0 个包）</span><br><span class="line">remote: Resolving deltas: 100% (3/3), completed with 3 <span class="built_in">local</span> objects.</span><br><span class="line">remote:</span><br><span class="line">remote: Create a pull request <span class="keyword">for</span> <span class="string">&#x27;7-zip-25.01&#x27;</span> on GitHub by visiting:</span><br><span class="line">remote:      https://github.com/abc1763613206/aosc-os-abbs/pull/new/7-zip-25.01</span><br><span class="line">remote:</span><br><span class="line">To https://github.com/abc1763613206/aosc-os-abbs.git</span><br><span class="line"> * [new branch]            7-zip-25.01 -&gt; 7-zip-25.01</span><br><span class="line">分支 <span class="string">&#x27;7-zip-25.01&#x27;</span> 设置为跟踪 <span class="string">&#x27;origin/7-zip-25.01&#x27;</span>。</span><br></pre></td></tr></table></figure><p>之后，你就可以回到 GitHub ，参照模板创建一个 Pull Request 了。<br>在本例之中，模板填写完了长这样：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/AOSC-Newbie-Contribution-Guide/image-20250808035857022.png"/></div></div><p>从这一步之后，你就可以来到 <a href="https://aosc.io/contact">AOSC OS 现有的社区群组</a> ，发送自己的 Pull Request 链接，来等待贡献者们进行 Review 。<br>很快，审阅者会为你触发一系列 CI，以验证你的软件包在所有支持的设备架构上都能成功构建。有时，他们可能也会请你在本地环境中进行一些额外的测试（譬如执行类似 <code>oma topics --opt-in</code> 的命令来测试你的包在测试源上的构建表现）。请将你的测试结果以评论的形式回复在 PR 中，并与审阅者保持积极沟通，直到所有的检查项都顺利通过，让下面的 checks 绿起来：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/AOSC-Newbie-Contribution-Guide/image-20250808040738562.png" alt="All checks have passed"/></div><div class="image-meta"><span class="image-caption center">All checks have passed</span></div></div>   <p>从这一刻起，你将从「闭门造车」转向与社区的协同合作。请记住，这并非一场考试，而是一次开放、平等的技术交流。<strong>你需要与审阅者积极互动</strong>，清晰地解释你的修改思路，回答他们的问题，并根据收到的反馈进行调整。有效的沟通是保证贡献能被顺利合入的关键。</p><p>最终，在与审阅者达成共识后，你的 PR 将被合并。你亲手更新的 <code>7-zip</code> 软件包，将很快通过软件源分发到成千上万的用户手中。</p><p>在这一刻，你不仅完成了一次版本更新，更成为了 AOSC OS 开源社区的一名贡献者。恭喜你，欢迎加入！</p><h2 id="Final-Checklist"><a href="#Final-Checklist" class="headerlink" title="Final Checklist"></a>Final Checklist</h2><p>打包的旅程并非一帆风顺，以下是你在打包过程中可能会遇到的一些情况，在打包提交之前不妨先来看看：</p><h3 id="和一条-Commit-打架"><a href="#和一条-Commit-打架" class="headerlink" title="和一条 Commit 打架"></a>和一条 Commit 打架</h3><p>前文已经提到，AOSC OS 的 Commit 规范要求每个软件包的更新都应在一条 Commit 中完成。这就自然而然带来两个问题：   </p><ol><li>我如果已经提交了多个 Commit，如何<strong>合并成一条</strong>？</li></ol><ul><li>一个比较文明的方式是使用 <code>git rebase -i</code> 命令，进入交互式变基模式，然后把所有 Commit 都改成 <code>squash</code>（缩写为 <code>s</code>），然后保存并退出，会弹出另一个编辑器让你修改合并后的 commit message。<br>不过这种方式不太适合你本地还有未提交的更改的情况，就通常的 AOSC OS 仓库维护实践而言，我推荐你使用一个不太文明但是简便的方法：  </li><li>直接使用 <code>git reset --soft HEAD~N</code> 命令（其中 <code>N</code> 是你想要回退的 Commit 数量），然后使用 <code>git commit -a -m &quot;Commit message&quot;</code> 来重新提交所有更改。这样就可以把多个 Commit 合并成一条了。<code>--soft</code> 的选项代表回退到指定的 Commit，但保留所有更改在暂存区中。</li></ul><p>不管你执行了哪种操作，都别忘了使用 <code>git push -f</code> 来强制推送你的更改。   </p><ol start="2"><li>审阅者指出了我的错误，我该如何<strong>修改上一条提交过的 Commit</strong>？</li></ol><ul><li>如果您仅需要修改 Commit 的消息内容，可以直接使用 <code>git commit --amend -m &quot;New commit message&quot;</code> 命令来修改最后一次提交的消息。如果您去掉 <code>-m</code> 选项，Git 会打开一个编辑器，让您修改消息内容。</li><li>但您如果想修改文件内容，请直接在工作区进行修改，然后<strong>直接使用 <code>git add .</code> 来将修改添加到暂存区，最后使用 <code>git commit --amend</code> 来更新这个 Commit。</strong> 同样这次会打开一个编辑器，让您修改 Commit 的消息，如果不需要修改消息，可以直接保存并退出，此时修改就会合并到这个 Commit 中。（附加上 <code>--no-edit</code> 选项可以跳过编辑器）  </li><li>需要修改更早之前的 Commit 涉及到了 <code>rebase</code> 变基，请自行在网络上查找相关资料。</li></ul><p>同样的，完成全部修改后使用 <code>git push -f</code> 来强制推送更改。  </p><blockquote><p>强制推送会覆盖远程历史，是一项有风险的操作。 如果你在一个多人协作的分支中工作，请务必确保你在推送之前已经与团队成员进行了充分的沟通，并且了解可能带来的影响。</p></blockquote><h3 id="安全更新与-TUM"><a href="#安全更新与-TUM" class="headerlink" title="安全更新与 TUM"></a>安全更新与 TUM</h3><p>你可能会注意到，在发起 PR 时的模板中有一个 <code>Security Update?</code> 的小标题，这是用来标识你的更新是否涉及到安全性修复的。<br>这就意味着，如果你发现你更新的软件包碰巧修复了某个安全漏洞，就应该多做一些工作。   </p><p>你可以从以下几个途径尝试了解：  </p><ul><li><a href="https://www.openwall.com/lists/oss-security/">#oss-security</a> 的邮件列表，你可以在谷歌上通过 <code>site:openwall.com/lists/oss-security/</code> 后面带关键词来搜索安全通告。</li><li>在<a href="https://nvd.nist.gov/vuln/search#/nvd/home?resultType=records">官方 CVE 数据库</a> 上搜索软件包的名称，查看是否有相关的安全漏洞。  </li><li>观察 Release Notes 或者 Changelog 中是否有提及安全修复。</li></ul><p>一旦发现安全漏洞，且你<strong>确定你正在更新的软件包会修复该漏洞</strong>，你就要在更新软件包之外，额外做安全通报工作。正巧 <code>7-zip</code> 的 <code>25.00</code> 版本出现了这个情况，下面将以这个更新作举例说明：</p><ol><li>在 <a href="https://github.com/AOSC-Dev/aosc-os-abbs/issues/new?template=security-vulnerabilities-report.yml">aosc-os-abbs</a> 中创建一个 Issue，模板选择 <code>Security Vulnerabilities Report</code>，按照指示填写相关信息并提交。（在本例中我创建了 <a href="https://github.com/AOSC-Dev/aosc-os-abbs/issues/11761">Issue #11761</a>）    </li><li>回到你更新的软件包的 PR 页面，在 <code>Security Update?</code> 的小标题下方填写 <code>Yes</code>，并附上你刚刚创建的安全通报 Issue 的编号并标注 <code>fixes</code> 以便 PR 合并时自动关闭。（参见 <a href="https://github.com/AOSC-Dev/aosc-os-abbs/pull/11760">PR #11760</a>）</li><li>准备发布一个 <a href="https://wiki.aosc.io/zh/developer/packaging/topic-update-manifest/">TUM（Topic Update Manifest）</a>。这意味着当用户更新到你的软件包时，<code>oma</code> 会提示该软件包修复了什么漏洞。<br>TUM 的位置位于 <code>TREE/topics</code> 目录下，你应该创建一个名为 <code>$PKGNAME-$PKGVER.toml</code> 的文件，本例中我们创建了 <code>7-zip-25.00.toml</code>，内容如下：</li></ol><figure class="highlight toml"><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><span class="line"><span class="attr">security</span> = <span class="literal">true</span></span><br><span class="line"></span><br><span class="line"><span class="section">[name]</span></span><br><span class="line"><span class="attr">default</span> = <span class="string">&quot;7-Zip 25.00&quot;</span></span><br><span class="line"><span class="attr">zh_CN</span> = <span class="string">&quot;7-Zip 25.00&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="section">[caution]</span></span><br><span class="line"><span class="attr">default</span> = <span class="string">&quot;Fixes a heap buffer overflow vulnerability - CVE-2025-53816 (Severity: Medium); Fixes a null pointer dereference vulnerability - CVE-2025-53817 (Severity: Medium)&quot;</span></span><br><span class="line"><span class="attr">zh_CN</span> = <span class="string">&quot;修复一处堆缓冲区溢出漏洞，漏洞编号 CVE-2025-53816（严重性：中）；修复一处空指针解引用漏洞，漏洞编号 CVE-2025-53817（严重性：中）&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="section">[packages]</span></span><br><span class="line"><span class="attr">&quot;7-zip&quot;</span> = <span class="string">&quot;25.00&quot;</span></span><br></pre></td></tr></table></figure><p>在完成 TUM 的编写后，请将这个 TUM <strong>单独作为一个 Commit 提交，格式为 <code>topics: add &lt;TUM 文件名&gt;</code>。</strong>（本例中应为 <code>topics: add 7-zip-25.00.toml</code>，实际上应当加上后面的 <code>.toml</code>）</p><p>在你如此操作之后，用户在通过 <code>oma</code> 完成 <code>7-zip</code> 的更新后，会看到类似下面的提示：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/AOSC-Newbie-Contribution-Guide/image-20250808193948295.png" alt="TUM 提示"/></div><div class="image-meta"><span class="image-caption center">TUM 提示</span></div></div><h2 id="下一步？"><a href="#下一步？" class="headerlink" title="下一步？"></a>下一步？</h2><p>再次由衷地恭喜你，你的贡献已正式成为 AOSC OS 的一部分！在你成为 AOSC OS 贡献者之后，你将获得一系列新的权限，旨在让你未来的贡献之旅更加顺畅、高效：</p><ul><li><strong>主仓库的写入权限</strong>：你将可以直接向官方 <a href="https://github.com/AOSC-Dev/aosc-os-abbs"><code>aosc-os-abbs</code></a> 仓库推送你的开发分支。</li><li><strong>BuildIt! 的调用权限</strong>：你将可以使用 AOSC OS 强大的自动化构建与测试系统。你将获得 AOSC OS 构建服务器的访问权限、主仓库的写入权限、构建 CI <a href="https://buildit.aosc.io/">BuildIt!</a> 的调用权限。</li></ul><p>在你开始新的冒险之前，有一个重要的一次性设置需要完成。在确保你加入 <a href="https://github.com/AOSC-Dev">AOSC-Dev</a> 组织后，请将你本地 TREE 目录的 Git 远程地址，<strong>从你个人的 Fork 仓库修改回 AOSC OS 的主仓库</strong>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">git remote set-url origin https://github.com/AOSC-Dev/aosc-os-abbs.git</span><br></pre></td></tr></table></figure><p>从此，你创建的所有新分支都应直接推送到主仓库，而不再需要通过个人 Fork 中转。<br>以及，以后的发起 PR 与构建操作请通过 BuildIt! 完成，BuildIt! 的主操作接口是 TG 上的 <a href="https://t.me/aosc_buildit_bot">@aosc_buildit_bot</a>。请在完成授权后，发送 <code>/help</code> 查看简要帮助。<br>通常从贡献者的角度，你需要依次完成如下操作：</p><ol><li>通过 BuildIt! 创建一个 PR：<code>/openpr PR标题;分支名;涉及到的包</code> ，例如 <code>/openpr 7-zip: update to 25.01;7-zip-25.01;7-zip</code>。<strong>如果涉及到的包有构建顺序要求，请保证按顺序排列并用空格隔开</strong>。  </li><li>在创建成功后，BuildIt! 会返回对应的 PR 链接。打开链接确认无误后，通过 BuildIt! 触发构建：<code>/pr PR编号</code>，例如 <code>/pr 11760</code>。</li><li>监控构建状态，并根据需要进行调整或是重复发起构建。</li><li>在构建完成后进行测试。首先发送 <code>/dickens PR编号</code> 生成自动化报告，例如 <code>/dickens 11760</code> 。</li><li>如果你作为贡献者发起 PR，此时的构建包将会自动<strong>作为主题推送到测试源</strong>。请稍等片刻后在自己的系统中，使用 <code>oma topics --opt-in 主题名</code>（主题名也就是分支名）来加入自己构建的测试源，在尽量多的架构进行更新与测试，并附在 PR 之中。</li><li>在 PR 合并后，<strong>再次在 BuildIt! 中发送 <code>/pr PR编号</code> 触发构建</strong>，此时包将从 <code>stable</code> 发起构建，然后推送到正式的软件源。</li></ol><p>掌握这套流程后，你的贡献效率将大大提升。欢迎来到贡献者的新世界，期待你为 AOSC 带来更多精彩！</p><hr><p>本指南参考 <a href="https://wiki.aosc.io/zh/developer/packaging/">AOSC OS 官方维基</a> 编写，完稿于 2025 年 8 月 8 日，如果你发现本文描述的情况与维基或是社区实际情况不符，或者遇到了本指南没有覆盖到的情况，请以维基与社区为准，并积极与贡献者们沟通。<strong>编写代码、修复 Bug 只是开源贡献的起点，而如何与来自五湖四海的伙伴们有效沟通、协同共进，则是一门更深、也更有魅力的艺术。</strong></p><p><em>*封面与如下 meme 图来自 <a href="https://www.bilibili.com/bangumi/play/ss26291">《天使降临到我身边》</a> 第 12 集的约 13:15 秒处。</em></p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/AOSC-Newbie-Contribution-Guide/image-20250806201423500.png" alt="REVIEW PLEASE!"/></div><div class="image-meta"><span class="image-caption center">REVIEW PLEASE!</span></div></div>]]></content>
    
    
    <summary type="html">「我能给一个 Linux 发行版贡献软件包，真的假的？」</summary>
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="AOSC" scheme="https://blog.hanlin.press/tags/AOSC/"/>
    
    <category term="Linux" scheme="https://blog.hanlin.press/tags/Linux/"/>
    
    <category term="开源" scheme="https://blog.hanlin.press/tags/%E5%BC%80%E6%BA%90/"/>
    
  </entry>
  
  <entry>
    <title>2024：在变与不变之中做一颗不动的石头</title>
    <link href="https://blog.hanlin.press/2024/12/2024-annual-report/"/>
    <id>https://blog.hanlin.press/2024/12/2024-annual-report/</id>
    <published>2024-12-31T09:30:00.000Z</published>
    <updated>2024-12-31T16:00:00.000Z</updated>
    
    <content type="html"><![CDATA[<div class="tag-plugin ds-music" style=" --lrc-display:block;" metingargs="IGF1dG89Imh0dHBzOi8vbXVzaWMuMTYzLmNvbS9zb25nP2lkPTE4Njk0MTE4NTUiIGFwaT0iaHR0cHM6Ly9hcGkuaW5qYWhvdy5jbi9tZXRpbmcvP3NlcnZlcj06c2VydmVyJnR5cGU9OnR5cGUmaWQ9OmlkJmF1dGg9OmF1dGgmcj06ciI="></div><div class="tag-plugin poetry"><div class="content"><div class="body"><p>我见过太阳升太阳落</p><p>朋友们唱着歌 手挽手过山坡</p><p>有些带着伤痕回来</p><p>太阳落月亮升 旧血肉化成风</p><p>只有石头一动不动</p></div></div></div><hr><h2 id="事情越多，事情越少"><a href="#事情越多，事情越少" class="headerlink" title="事情越多，事情越少"></a>事情越多，事情越少</h2><h3 id="打碎滤镜，仍见波澜壮阔"><a href="#打碎滤镜，仍见波澜壮阔" class="headerlink" title="打碎滤镜，仍见波澜壮阔"></a>打碎滤镜，仍见波澜壮阔</h3><p>2024年12月9日，在和某个先前文章中曾经出现过的友人通话时，友人感慨道：今年对你来说真是波澜壮阔的一年。<br>在那时，我的频道群里正被一车来路不明的人骚扰，尽管与其他人交换了详情，但置顶警告的行为宛如打草惊蛇，之后相同的团体就再没出现，调查的弦就此断了线。<br>话题扯得有一点远，我们不妨还是回来讨论下：<strong>如何定义一年是「波澜壮阔」的一年？</strong></p><p>从一方面讲，人在回忆过去的事情时，会对更久远的事物带上一层滤镜，选择性地忽略某些不重要的事，而对最近发生的事复现更深，导致意识上「这一年的事比上一年的多」。而另一方面，2024年又的的确确发生了许许多多的，堪称我人生中**「第一次」**的事，以至于超出了我自己的预期，又或者是处理范围。</p><ul><li><p>第一次大胆和前端代码硬碰硬。学 JavaScript 以及其配套生态的想法在我脑海中至少存活了六年，但每次都停留在图书馆借了一两本书以及搜了点 PDF 下载下来。前年在 <a href="https://www.freecodecamp.org/learn/2022/responsive-web-design">freeCodeCamp</a> 跟着学了下 HTML 架构，但到了实践环节又半途而废。直到开始给<a href="https://blog.hanlin.press/freewill-banpick/">隔壁项目</a> 爆改代码，才发现：已经是 AI 加持的 2024年了，学习一个新领域并没有想象中的那么难，难的是试水的第一步。<strong>执行力是最终成事的关键</strong>。于是有了<a href="https://hanlin.press/">对着 Bento 描的新主页</a>，以及<a href="https://blog.hanlin.press/2024/08/Migrate-to-Hexo/">对博客主题的爆改</a>。</p></li><li><p>第一次收到国际邮件，Giffgaff 的免费电话卡漂洋过海兜兜转转顺利飘进了学校收发室，跑通了来往的邮路，至此暂时了却了我从小到大喜欢集邮，却无处收发邮件的一桩心结。</p></li><li><p>第一次出相对远的门，清晨迷迷糊糊着坐动车到南京，在湿热的天气中度过了一小段插曲般的时光。当你抛开旅游的滤镜，拿最趁手的交通工具走走城市的角落，你便会惊讶地发现：原来北方城市的调性大同小异。大城市是大城市，古城却还是那个古城。</p><p>在此完整放送朋友圈原文：</p><div class="tag-plugin poetry"><div class="content"><div class="body"><p>火车，地铁，汽车，公交车，自行车——可以自豪地说，在南京完成了交通工具的半数制霸。<br>雨天和一个沉重双肩包打乱了一串旅行计划，不得已买了几天骑行卡，逃难般地在大雨天坐上一个小时的公交车，听着公交车上的大妈拿当地话和我加密通话，才发现了在旅游局设计的动线之外，还有一串「无聊」的细节。<br>可以看到比济南还「随性」的交通——四处停放的共享单车，店铺前停着路中央也停着；左转直行混起来的红绿灯到处都是，最终的后果就是路中央挤成一团。可以看到大城市共有的新旧共存，高楼旁边是旧房，巷子两边围满了施工墙。窄巷里的路坑坑洼洼，购物中心的喷泉有三层楼高，中学大门非常显眼的学生业余电台，这些景象竟能在几公里的路线依次展现。<br>大城终有大城的悠久与繁华，但褪去了未知的光环后，不得不说这座城市仍比我想象中的松弛得多。</p></div></div></div></li><li><p>第一次去到线下的 Livehouse，在美好的夜晚后再度坚定了成为 ChiliChill 粉丝的决心。</p></li><li><p>第一次为了 DDL 赶工到凌晨五点看见朝阳，第一次不靠交通工具徒步走进城市的角落…还有一大些可以称作「第一次」的小事。</p></li></ul><p>要说这其中最大的感想，我们不妨往高了些的方向升华：每一次滤镜的打碎都伴随着世界观的重塑。</p><h3 id="独断舍离，难掩旧日远去"><a href="#独断舍离，难掩旧日远去" class="headerlink" title="独断舍离，难掩旧日远去"></a>独断舍离，难掩旧日远去</h3><p>这一章的大标题，我定为了「事情越多，事情越少」。如果说上面那一节讲的是波澜壮阔的「第一次」让事情变多的话，那么这一节大概讲的就是相对的，事情如何变少了吧。</p><p>事情变少的这件事也可以拿来辩证。有的事情是「主观」地减少，通过主动舍弃掉来缓解自己的压力；有的事情是「客观」地变少，当你发现时想找也找不回来。</p><p>独断舍离，又或者就是「切割」，已被提前到<a href="https://blog.hanlin.press/2024/09/Cutting-off-and-Another-ME/">这篇博文</a>去说，这里不再重复那些在发布文章后仍被不断验证着的观点。</p><p>旧日远去，则与三年前的 <a href="https://blog.hanlin.press/2021/01/project-nano-official-writeup/">Project NANO</a> 相关，可以说博客站开站以来的第一篇正式文章就是这个。</p><p>24年12月末，北京大学谜题协会的 COL 老师找到我，说要在校内主讲一篇主题为《网络解谜的前世今生》的例会，他的理由如下：</p><div class="tag-plugin quot"><p class="content" type="text"><span class="empty"></span><span class="text">我有幸见证了 2020-2022 年相关社群之间的谜题活动交互（Phigros, NazoPhiProject, NaNO, Tingazik, ...）。国内的解谜社群近几年活跃在 Puzzlehunt（如 CCBC, P&KU）领域，这些社群近年有着较高的活跃度，但和网络解谜较为割裂，我坚信 NaNO 等网络解谜题材有着巨大的创作潜力。</span><span class="empty"></span></p></div><p>聊天末，他提到了我在 NANO 通关群提出来的 NANOv2 企划，问要不要在例会的最后推介一下。</p><p>能推介固然是好的，那么这个项目后来怎么样了呢？</p><p>我的回答如下：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2024-annual-report/image-20241231004625407.png"/></div></div><p>我仍旧觉得我在之前<a href="https://blog.hanlin.press/2024/09/Cutting-off-and-Another-ME/">「切割」</a>一文中的话说的不错：「切割」行为在近两年<em>看上去</em>发生频率高的原因，是后疫情时代社交关系的回正。</p><p>网络解谜社群兴盛的时段，与疫情居家的时间存在着非常大的重合。在效率较低的「正事」之外，在居家的温馨（又或者是压抑？）的小空间里，物理活动之外，赛博空间成为了我们各方面活动的施展天地。大伙都在网上呢，IM 也得一直开着「等通知」，社交活动的实体浓缩为社交平台上带着头像的账号，想要找人或者发起一个小活动轻轻松松。</p><p>如此乌托邦直到潮水褪去，这时你会发现：你再也找不到一群可以随时随地和你交互的人了。  </p><p>用更新潮的话来说，大伙都去「现充」去了，连空闲的时间档期都挤不出来，有些企划就自然地难产了。</p><p>我曾在 <a href="https://blog.hanlin.press/2023/12/2023-annual-report/#%E6%9F%AF%E4%BC%9F%E5%BE%B7%EF%BC%88Covid%EF%BC%89%E6%97%B6%E4%BB%A3">2023 年的年度报告</a>里提到了疫情的全面放开，现在看来，疫情的结束终究还是带走了一些曾经较为亲近的东西，我也成为了「被切割」的一个。</p><h2 id="一些「年度」与一些数据"><a href="#一些「年度」与一些数据" class="headerlink" title="一些「年度」与一些数据"></a>一些「年度」与一些数据</h2><h3 id="年度游戏"><a href="#年度游戏" class="headerlink" title="年度游戏"></a>年度游戏</h3><p>很遗憾，今年与去年相比并没有什么让我耳目一新的新游戏进账。   </p><p>从网游手游来看，我终于是发现了我与开放世界以及米哈游类的高耗时收集性游戏相性不符，因此今年掀起过水花的网游们——《鸣潮》《绝区零》《无限暖暖》，都仅仅是体验级别。从 Tai 的计时来看，今年有效时间最多的游戏还是《Counter-Strike 2》。</p><p>有效时间第二多的是《极限竞速：地平线4》，我在买了个黑武士4手柄后重新下回了两个《地平线》（当然也有《地平线4》版权到期下架的影响），然后发现**《地平线》系列有手柄和没手柄是两个游戏**。乐高 DLC 还挺好玩的。新硬盘也来了，等待并心怀期望着《地平线6》。</p><p>音乐游戏的话，还是依旧：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2024-annual-report/image-20241231011555406.png" alt="我超，______"/></div><div class="image-meta"><span class="image-caption center">我超，______</span></div></div><h3 id="年度音乐"><a href="#年度音乐" class="headerlink" title="年度音乐"></a>年度音乐</h3><p>由于今年把音乐库的构建全面转到了本地，加之 Spotify+网易云+AM 三端（他们必然统计不到本地数据）同时听，这三个音乐软件的年度报告自然就仅能作为参考，此处不表，仅列举主观上推荐的歌。</p><ul><li>年度专辑：<ul><li><a href="https://music.163.com/album?id=245695664">ChiliChill《混入人类计划》</a> ：伟大无需多言。</li><li><a href="https://music.163.com/album?id=150127127">赵雷《署前街少年》</a>：本年度心情低落时常听专辑。</li></ul></li><li>年度推歌：<ul><li><a href="https://music.163.com/song?id=1869411855">石头歌 - 三无Marblue</a> ：原因后面会详讲。</li><li><a href="https://music.163.com/song?id=487454610">The Good Part - AJR</a>：初听和再听都非常震撼。</li><li><a href="https://music.163.com/song?id=2082379364">Corners of Your Mind - Far West</a>：来自于 Team Spirit 的夺冠 Vlog，非常振奋精神的一首歌。</li><li><a href="https://music.163.com/song?id=563058283">Have It All - Elephante&amp;Nevve</a>：听了心情会非常好的一首歌。</li><li><a href="https://www.bilibili.com/video/av1502232597">【POPY】小镇姑娘【2024VOCALOID吧调音大赛·AC030】</a> ：后半段直接刷新我对 SynthV 调 AI 的印象了，华侃如你做的好啊。</li><li><a href="https://www.bilibili.com/video/BV1pe411q7Yp">イントロダクション － Poppin’Party 中文填词版 未来前奏 feat. 夢ノ結唱 POPY (2024)</a>：有高性能引擎也有优秀的填词，营养非常充足。</li></ul></li></ul><h3 id="频道与网站"><a href="#频道与网站" class="headerlink" title="频道与网站"></a>频道与网站</h3><p>TG 频道：<a href="https://blog.hanlin.press/2023/12/2023-annual-report/#%E9%A2%91%E9%81%93">与去年相比</a>，今年的话痨程度直接把年度推送数拉高 150。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2024-annual-report/image-20241231013103108.png" alt="所以是有活还是没活呢？"/></div><div class="image-meta"><span class="image-caption center">所以是有活还是没活呢？</span></div></div><p>博客今年的数据自不必说，终于下定决心开始写「充实」些的博客，加之有比较爆的话题，数据肯定是比去年好一大截的。</p><p>两次尖峰一次是<a href="https://blog.hanlin.press/2024/07/From-Shanxi-to-Qiniu/">七牛</a>，一次是 <a href="https://blog.hanlin.press/2024/10/HTP-The-HTTP-Time-Protocol/">HTP</a>，都是天上掉下来的流量。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2024-annual-report/image-20241231013708961.png"/></div></div><h3 id="时间都去哪里了"><a href="#时间都去哪里了" class="headerlink" title="时间都去哪里了"></a>时间都去哪里了</h3><p>今年5月，在某人的安利下装了个 <a href="https://github.com/Planshit/Tai">Tai</a> 统计笔记本上的应用时长，以下是它的反馈：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2024-annual-report/image-20241231014127916.png" alt="娱乐活动还是 CS2 多"/></div><div class="image-meta"><span class="image-caption center">娱乐活动还是 CS2 多</span></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2024-annual-report/image-20241231014226235.png" alt="重装电脑后忘了装配套插件，于是12月网页的数据顺理成章地没了"/></div><div class="image-meta"><span class="image-caption center">重装电脑后忘了装配套插件，于是12月网页的数据顺理成章地没了</span></div></div><p>有博客的更新与其他乱七八糟项目撑着，<a href="https://github.com/abc1763613206?tab=overview&from=2024-12-01&to=2024-12-31">今年的 GitHub</a> 还是比去年绿油油很多的：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2024-annual-report/image-20241231014426139.png"/></div></div><p>还有一点值得铭记的：</p><ul><li>今年 Postcrossing 方面，合计收到7张明信片，外寄出11张。</li><li>新创建的 <code>#meetup</code> 面基高达四个，来年继续努力。</li></ul><p>再仔细一想，好像其他就没什么跨度到达一年的报告可供展示了，我总不能再没活了真贴流媒体的报告截图，然后顺利成为七牛的刷流对象吧？</p><h2 id="变与不变，做个石头"><a href="#变与不变，做个石头" class="headerlink" title="变与不变，做个石头"></a>变与不变，做个石头</h2><p>我今年下半年特别爱听《石头歌》，尤其爱听篇首那几句。</p><p>转眼间，这已经是在这个所谓的「新」博客中的第三篇年度报告了，有些东西在变，有些东西始终没变。</p><p>比方说，隔了好多年测我的赛博星座，仍然是 ENFP-T ，但对于今年年度总结的主题而言，我觉得得稍有些改变。   </p><p>上半年的「切割」多少磨平了点我的性子，我终于意识到：既已知自己技不如人，有时候避其锋芒，做一个一动不动的石头，也是一种不错的选择。   </p><p>有些时候的事件进展并不完全由我们掌控，但我们可以选择如何应对。你大可说这是一种「精神胜利法」精神，但比起无尽的精神内耗和辩经，另一个道理显得更为明确：时间是属于一个人自己的，一个人的时间是不能被其他人定义的。  </p><p>我们仍然不知道明年会发生什么，我们仍然不知道今年相比于我们的一生是怎么样的一年。对自己不重要的事情有个「石头」般的态度，方能在重要的事情上集中精力。 </p><p>这一段话写给在今年暴论颇多，仍然是 ENFP-T 的自己：<br>当下行的情绪蔓延，轻松愉快的群聊也变成了针锋相对的辩经大会。「放下助人情结，尊重他人命运」这句话听起来难听，但却是节省精力的良方，你没必要处处去当演说家，对于意见无法达成一致的，还是随他去吧。  </p><p>最后的最后，感谢你读到最后听了我这么多的废话。<br>愿博客的读者，愿我的朋友，新的一年都能摆脱精神内耗，<strong>真正找到自己喜欢做的事，真正找到有意义的事</strong>。</p><div class="tag-plugin ds-music" style=" --lrc-display:block;" metingargs="IGF1dG89Imh0dHBzOi8vbXVzaWMuMTYzLmNvbS9zb25nP2lkPTQ4NzQ1NDYxMCIgYXBpPSJodHRwczovL2FwaS5pbmphaG93LmNuL21ldGluZy8/c2VydmVyPTpzZXJ2ZXImdHlwZT06dHlwZSZpZD06aWQmYXV0aD06YXV0aCZyPTpyIg=="></div><hr><p>封面来源于学校里的🐱学长：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2024-annual-report/cover.jpg" alt="2024 年度报告封面"/></div><div class="image-meta"><span class="image-caption center">2024 年度报告封面</span></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/2024-annual-report/83e99f64e1ea6483c2a6e5d8a121faa.jpg" alt="「北院路上这只🐱都比我有精神」"/></div><div class="image-meta"><span class="image-caption center">「北院路上这只🐱都比我有精神」</span></div></div>]]></content>
    
    
    <summary type="html">愿博客的读者，愿我的朋友，新的一年都能真正找到自己喜欢做的事，真正找到有意义的事。</summary>
    
    
    
    <category term="杂记" scheme="https://blog.hanlin.press/categories/%E6%9D%82%E8%AE%B0/"/>
    
    <category term="日常" scheme="https://blog.hanlin.press/categories/%E6%97%A5%E5%B8%B8/"/>
    
    
    <category term="口水" scheme="https://blog.hanlin.press/tags/%E5%8F%A3%E6%B0%B4/"/>
    
    <category term="杂谈" scheme="https://blog.hanlin.press/tags/%E6%9D%82%E8%B0%88/"/>
    
    <category term="年度报告" scheme="https://blog.hanlin.press/tags/%E5%B9%B4%E5%BA%A6%E6%8A%A5%E5%91%8A/"/>
    
    <category term="2024" scheme="https://blog.hanlin.press/tags/2024/"/>
    
  </entry>
  
  <entry>
    <title>关于浏览器通知推送（Web Push API）的那些事</title>
    <link href="https://blog.hanlin.press/2024/12/Something-About-Web-Push-API/"/>
    <id>https://blog.hanlin.press/2024/12/Something-About-Web-Push-API/</id>
    <published>2024-12-03T17:51:15.000Z</published>
    <updated>2024-12-05T12:51:15.000Z</updated>
    
    <content type="html"><![CDATA[<p>最近在旁观 OI Wiki 搞<a href="https://github.com/OI-wiki/feedback-sys">划词评论</a>项目，有人提出需求来，说要做一个被评论时通知的机制。</p><p>在溜了一圈比如向绑定 Email 发送通知的方案后，有人提出了通过利用「浏览器通知」来推送消息的方式。无奈一窝人都不是 W3C 协议研究大师，之前光见到过具体表现也没研究过实现机制。因而花了<del>几天时间</del> （upd：完稿时已经接近两个月了）仔细研究，发现了一些既可以被称作「冷知识」又可以被称作「烫知识」的东西。就在这里做一些不专业的分享，如果有专业 Web 从业人士指正就更好了。</p><h2 id="这是什么？"><a href="#这是什么？" class="headerlink" title="这是什么？"></a>这是什么？</h2><p>众所周知，运行在现代浏览器的网页在弹窗获取用户授权同意之后，拥有推送<strong>桌面通知</strong>到用户的权限。  </p><p>很多时候，这项功能会被用作网页的订阅推送或是消息推送，比如下图是萌娘百科的一个典型调用实例：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241028222515644.png" alt="「萌娘百科」站点的推送触发实例"/></div><div class="image-meta"><span class="image-caption center">「萌娘百科」站点的推送触发实例</span></div></div><p>而具体到推送本身，从推送特点来看大致可分为两类：<code>Notification</code> 和 <code>Push API</code>。虽然在找最终用户要权限的时候他们弹出的窗口都一模一样，但他们的区别却比较鲜明：</p><ul><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Notification"><code>Notification</code></a> 的推送方式比较简单，它的存在就是为了使用户在切换到其他标签页或应用程序时也能收到较高层级的通知，但它的缺点也比较显著：它的推送依赖由网页相对前台的异步逻辑<strong>直接触发</strong>，因此<strong>只能在用户打开网页时推送消息</strong>，在用户关闭&#x2F;休眠网页后推送就会消失。</li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Push_API"><code>Push API</code></a> 则是一种更加强大和贴近手机通知逻辑的推送方式，它的推送不依赖于网页的打开状态，而是通过浏览器<strong>后台</strong>的 Service Worker 来推送消息。这种推送方式的优势非常明显：<strong>用户不需要打开网页就能收到消息</strong>，我们本篇<strong>主要讨论这种方式</strong>。</li></ul><p>有些网页恨不得往自己的前台塞爆各种引擎和逻辑，活跃的网页开得一多就容易使浏览器变成内存厂战略合作伙伴。把所有无关的逻辑扔掉，不用打开网页就能收到通知，听起来是非常美好的一件事。</p><p>那么，代价是什么呢？</p><p>先别急，我在下面放了两个按钮，他们分别对应了两种推送方式，您可以直接使用 <kbd>F12</kbd> 审查元素查看接下来按钮紧接的源代码，然后在进入后续章节前先点击体验一番。<br>注意：对于推送通知而言，由于不同浏览器的实现方式不同，所需要处理的 Edge Case 非常多，如下的按钮并不一定每次都能奏效。本来这里打算直接引一个专业的方案来实操模拟的，想了想还是算了 :P</p><a class="tag-plugin colorful button" color="red" title="点击我触发推送通知" href="javascript:pushNotification();"><span>点击我触发推送通知</span></a><a class="tag-plugin colorful button" color="green" title="点击我触发普通通知" href="javascript:normalNotification();"><span>点击我触发普通通知</span></a><script>  function base64UrlToUint8Array(base64UrlData) {    const padding = '='.repeat((4 - base64UrlData.length % 4) % 4);    const base64 = (base64UrlData + padding)      .replace(/\-/g, '+')      .replace(/_/g, '/');    const rawData = window.atob(base64);    const buffer = new Uint8Array(rawData.length);        for (let i = 0; i < rawData.length; ++i) {      buffer[i] = rawData.charCodeAt(i);    }    return buffer;  }  async function getPublicKeyOnline(serverUrl) {    try {        const response = await fetch(serverUrl, {            method: 'GET',            headers: {                'Content-Type': 'application/json'            }        });        if (!response.ok) {            throw new Error('Terrible Net :(');        }        const data = await response.json();        return data.publicKey;    } catch (error) {        console.error('Failed to fetch public key:', error);    }  }  function normalNotification() {    Notification.requestPermission().then(function(result) {      console.log(result);      if (result === 'granted') {        const notification = new Notification('普通通知', {          body: '这是一条普通通知',        });      } else {        alert('您拒绝了通知权限 :(');      }    });  }  function pushNotification() {    if ('serviceWorker' in navigator && 'PushManager' in window) {      console.log('Service Worker and Push API is supported');      navigator.serviceWorker.register('/uploads/Something-About-Web-Push-API/sw.js')        .then(function (swReg) {          console.log('Service Worker is registered', swReg);          swRegistration = swReg;          swRegistration.update(); // 强制更新 Service Worker          getPublicKeyOnline('https://push-cf.iabc.work').then(function (publicKey) {            swRegistration.pushManager.subscribe({              userVisibleOnly: true,              applicationServerKey: base64UrlToUint8Array(publicKey)            }).then(function (subscription) {              console.log('User is subscribed:', subscription);              console.log(JSON.stringify(subscription));              fetch('https://push-cf.iabc.work', {                method: 'POST',                headers: {                  'Content-Type': 'application/json'                },                body: JSON.stringify(subscription)              })                .then(function (response) {                  if (!response.ok) {                    throw new Error('Request failed :(');                  }                  console.log(response);                  return response.json();                })                .then(function (data) {                  console.log('Push notification sent successfully:', data);                })                .catch(function (error) {                  console.error('Error sending push notification:', error);                });            }).catch(function (error) {              console.error('Failed to subscribe:', error);            });          })        })        .catch(function (error) {          console.error('Service Worker Error', error);        });    } else {      alert("您的访问环境不支持 Service Worker 或 Push API :(");    }  }</script><h2 id="从底层说起"><a href="#从底层说起" class="headerlink" title="从底层说起"></a>从底层说起</h2><p>所以说，在点击了「同意通知」后，从最终用户的浏览器到远程之间，究竟发生了哪些事情？<br>我们先不去管文档的事，其中的大致逻辑，仔细想想自然也可以猜出大概：</p><ul><li>首先，浏览器自然没有办法掏出一种外星科技，来让<em>每个</em>服务器都能直接穿过层层防火墙和转发直达最终用户。要想让通知服务器直接实现主动推送到最终用户，大约是不太可能。</li><li>要想让所有用户在<em>理论情况下</em>都有收到通知的能力，只能让浏览器主动去拉取消息更新，就像大部分实践一样。但是一方面对于浏览器来说，同时维护到不同服务器之间的连接是一个不小的负担；另一方面对于服务器来说，每个网站都要与一车用户维护连接（即使并没有消息更新）也是不能接受的负担。</li><li>那么最容易想到的实现也就呼之欲出了：摇一个中间商出来不就好了。让中间商统一管理通知服务器和浏览器之间的信息交互。一方面，在统一标准之下，浏览器在通知方面的连接可以被简化到中间商一条；另一方面，服务器也没必要和中间商维护长连接，仅需要在有消息更新时通知中间商即可。</li></ul><p><del>恭喜你，现在你已经会发明「推送服务」了，现在快去成为那个中间商吧。</del></p><p>现在，我们可以去看看真实的文档了。与 Push API 强相关的规范性文件共有如下两份文件：</p><ul><li>W3C 仍在处于草稿状态 (Working Draft) 的<a href="https://www.w3.org/TR/push-api/">「Push API」</a>标准。</li><li><a href="https://datatracker.ietf.org/doc/html/rfc8030">RFC8030</a> 描述的推送协议实现。</li></ul><p>我们可以用 RFC8030 文首的抽象框图来描述一个<em>最简</em>的推送链条：</p><blockquote><pre><code>+-------+           +--------------+       +-------------+|  UA   |           | Push Service |       | Application |+-------+           +--------------+       |   Server    |    |                      |               +-------------+    |      Subscribe       |                      |    |---------------------&gt;|                      |    |       Monitor        |                      |    |&lt;====================&gt;|                      |    |                      |                      |    |          Distribute Push Resource           |    |--------------------------------------------&gt;|    |                      |                      |    :                      :                      :    |                      |     Push Message     |    |    Push Message      |&lt;---------------------|    |&lt;---------------------|                      |    |                      |                      |</code></pre></blockquote><p>从这个框图中可以很轻易地看出，UA (User Agent) 代表了最终用户端的浏览器，Push Service 代表了作为中间商的推送服务，Application Server 代表了网页服务端的应用服务器。<br>Push Service 承担了最终用户与服务器之间的中继桥梁：</p><ol><li>在订阅发起时，UA 向 Push Service 发起订阅请求。</li><li>Push Service 会向 UA 提供一个可以唯一对应到该 UA <strong>本次申请的订阅</strong>的端点对象，通常是一个 URL。</li><li>UA 将这个端点对象发送给 Application Server，这个端点对象通常关联在 Push Service 之下。</li><li>之后，UA <strong>仅需要维护与 Push Service 的连接</strong>，而 Application Server 仅需要在有消息更新时通知 Push Service 即可。</li></ol><p>整体来看，和我们上述预想的方案是非常接近的。  </p><p>而更贴近的推送链条，也不过是对于这个链条中各个环节的扩展整合。比如如上示例中的 OneSignal 是将 Application Server 从真正有内容的服务器前置到了专业的推送服务商，又如谷歌系的 Push Service 事实上与他们的 Firebase 服务紧密关联（这点我们之后还会讲）。由此来看，我们事实上已经得知了这套 Push API 的抽象实现逻辑。</p><h2 id="一条消息的诞生"><a href="#一条消息的诞生" class="headerlink" title="一条消息的诞生"></a>一条消息的诞生</h2><h3 id="Service-Worker-的骨架"><a href="#Service-Worker-的骨架" class="headerlink" title="Service Worker 的骨架"></a>Service Worker 的骨架</h3><p>接下来，我们从抽象的逻辑展开，<strong>以 Google Chrome 为载体</strong>，看看在用户点击「同意推送」之后，整条消息推送的链路发生了什么。</p><p>因为 W3C 对于 Push API 的期望是「不仅仅能推送消息」，因此 Push API 能拿到的信息远不仅仅是「标题+内容」这种标准的通知格式，倒从功能上说，「让浏览器显示通知」仅仅是 Push API 的一个子集（但事实上 Chrome 等浏览器对该 API 的适用范围施加了限制，后面会说）。<br>因此，真正和 Push Service 完成交互的，反而是用户点击「同意」之后驻留在浏览器后台的 Service Worker。Service Worker 作为一个独立的线程，可以在浏览器关闭后继续运行，因此在浏览器 UA 从长连接着的 Push Service 接收到消息后，Service Worker 会被唤醒，然后根据消息内容决定是否显示通知。</p><p>所以，对于 Application Server 而言，它首先便需要注册一个 Service Worker，然后在里面完成消息的处理流程。<br>我们可以使用如下的代码注册一个 Service Worker：</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (<span class="string">&#x27;serviceWorker&#x27;</span> <span class="keyword">in</span> navigator &amp;&amp; <span class="string">&#x27;PushManager&#x27;</span> <span class="keyword">in</span> <span class="variable language_">window</span>) &#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;Service Worker and Push API is supported&#x27;</span>);</span><br><span class="line"></span><br><span class="line">  navigator.<span class="property">serviceWorker</span>.<span class="title function_">register</span>(<span class="string">&#x27;/uploads/Something-About-Web-Push-API/sw.js&#x27;</span>)</span><br><span class="line">  .<span class="title function_">then</span>(<span class="keyword">function</span>(<span class="params">swReg</span>) &#123;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;Service Worker is registered&#x27;</span>, swReg);</span><br><span class="line">    swRegistration = swReg;</span><br><span class="line">    swRegistration.<span class="title function_">update</span>(); <span class="comment">// 强制更新 Service Worker</span></span><br><span class="line">    swRegistration.<span class="property">pushManager</span>.<span class="title function_">subscribe</span>(&#123;</span><br><span class="line">      <span class="attr">userVisibleOnly</span>: <span class="literal">true</span>,</span><br><span class="line">      <span class="attr">applicationServerKey</span>: applicationServerKey</span><br><span class="line">    &#125;)</span><br><span class="line">  &#125;)</span><br><span class="line">  .<span class="title function_">catch</span>(<span class="keyword">function</span>(<span class="params">error</span>) &#123;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;Service Worker Error&#x27;</span>, error);</span><br><span class="line">  &#125;);</span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">  <span class="title function_">alert</span>(<span class="string">&quot;您的访问环境不支持 Service Worker 或 Push API :(&quot;</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>成功注册 Service Worker 后，打开 F12 开发者选项，我们可以在 Console 中观察到，被注册的 <code>ServiceWorkerRegistration</code> 自带了一个 <a href="https://developer.mozilla.org/zh-CN/docs/Web/API/PushManager"><code>pushManager</code></a> 接口，而这就是我们后期发起推送消息时所需要调用的：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241204185808644.png"/></div></div><p>在准备好 Service Worker 之后，我们还需要准备一组 <a href="https://datatracker.ietf.org/doc/html/draft-ietf-webpush-vapid-01">VAPID</a> 密钥对，用于在订阅时对消息进行签名，阻止期望外的其他人向用户推送消息。从形式上而言，它就是经典的公钥加私钥，外带一个 <code>mailto:</code> 或是 <code>http</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><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;vapid&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;publicKey&quot;</span><span class="punctuation">:</span> <span class="string">&quot;这里放公钥&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;privateKey&quot;</span><span class="punctuation">:</span> <span class="string">&quot;这里放私钥&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;subject&quot;</span><span class="punctuation">:</span> <span class="string">&quot;mailto:&quot;</span> <span class="comment">// 或是 &quot;https://&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>具体到实现而言，VAPID 使用了 <code>ES256</code> 算法，使用 ECDSA 在 NIST P-256 曲线上创建一组密钥对，然后对 JWT 进行签名验证。<br>VAPID 密钥对通常仅需要生成一次，而且到处都可以找到一个在线生成站。当然，您可以使用下面的按钮刷新几组，这里的代码来自于 Google 的 <a href="https://glitch.com/edit/#!/web-push-codelab">Web Push Codelab</a>：</p><script>// from https://glitch.com/edit/#!/web-push-codelabfunction base64UrlToUint8Array(base64UrlData) {  const padding = '='.repeat((4 - base64UrlData.length % 4) % 4);  const base64 = (base64UrlData + padding)    .replace(/\-/g, '+')    .replace(/_/g, '/');  const rawData = window.atob(base64);  const buffer = new Uint8Array(rawData.length);  for (let i = 0; i < rawData.length; ++i) {    buffer[i] = rawData.charCodeAt(i);  }  return buffer;}function uint8ArrayToBase64Url(uint8Array, start, end) {  start = start || 0;  end = end || uint8Array.byteLength;  const base64 = window.btoa(    String.fromCharCode.apply(null, uint8Array.subarray(start, end)));  return base64    .replace(/\=/g, '') // eslint-disable-line no-useless-escape    .replace(/\+/g, '-')    .replace(/\//g, '_');}function cryptoKeyToUrlBase64(publicKey, privateKey) {  const promises = [];  promises.push(    crypto.subtle.exportKey('jwk', publicKey)    .then((jwk) => {      const x = base64UrlToUint8Array(jwk.x);      const y = base64UrlToUint8Array(jwk.y);      const publicKey = new Uint8Array(65);      publicKey.set([0x04], 0);      publicKey.set(x, 1);      publicKey.set(y, 33);          return publicKey;    })  );  promises.push(    crypto.subtle      .exportKey('jwk', privateKey)    .then((jwk) => {      return base64UrlToUint8Array(jwk.d);    })  );  return Promise.all(promises)  .then((exportedKeys) => {    return {      public: uint8ArrayToBase64Url(exportedKeys[0]),      private: uint8ArrayToBase64Url(exportedKeys[1]),    };  });}function generateNewKeys() {  return crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'},    true, ['deriveBits'])  .then((keys) => {    return cryptoKeyToUrlBase64(keys.publicKey, keys.privateKey);  });}function clearKeys() {  window.localStorage.removeItem('server-keys');}function storeKeys(keys) {  window.localStorage.setItem('server-keys', JSON.stringify(keys));}function getStoredKeys() {  const storage = window.localStorage.getItem('server-keys');  if (storage) {    return JSON.parse(storage);  }  return null;}function displayKeys(keys) {  const publicElement = document.querySelector('.js-public-key');  const privateElement = document.querySelector('.js-private-key');  publicElement.textContent = keys.public;  privateElement.textContent = keys.private;}function updateKeys() {  clearKeys();  let storedKeys = getStoredKeys();  let promiseChain = Promise.resolve(storedKeys);  if (!storedKeys) {    promiseChain = generateNewKeys()    .then((newKeys) => {      storeKeys(newKeys);      return newKeys;    });  }  return promiseChain.then((keys) => {    displayKeys(keys);  });}</script><a class="tag-plugin colorful button" color="blue" title="创建/刷新 VAPID" href="javascript:updateKeys();"><span>创建/刷新 VAPID</span></a><div class="js-keys">  <p>公钥：<code class="js-public-key"></code></p>  <p>私钥：<code class="js-private-key"></code></p></div><p>接下来，我们将目光转向真正处理通知请求的 Service Worker 中。实际上对于它而言，最短的形态可以长这样：</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></pre></td><td class="code"><pre><span class="line">self.<span class="title function_">addEventListener</span>(<span class="string">&#x27;push&#x27;</span>, <span class="keyword">function</span>(<span class="params">event</span>) &#123;</span><br><span class="line">    <span class="keyword">const</span> payload = event.<span class="property">data</span> ? event.<span class="property">data</span>.<span class="title function_">text</span>() : <span class="string">&#x27;no payload&#x27;</span>;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;[Service Worker] Push Received.&#x27;</span>, payload);</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><p>从上面的代码可以看出，这个 Service Worker 目前只干了一件事：接收到消息后，将消息内容打印到自己的控制台。</p><p>我们可以把这个 Service Worker 按正常的推送流程注册一下，看看会发生什么：</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><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">base64UrlToUint8Array</span>(<span class="params">base64UrlData</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> padding = <span class="string">&#x27;=&#x27;</span>.<span class="title function_">repeat</span>((<span class="number">4</span> - base64UrlData.<span class="property">length</span> % <span class="number">4</span>) % <span class="number">4</span>);</span><br><span class="line">  <span class="keyword">const</span> base64 = (base64UrlData + padding)</span><br><span class="line">    .<span class="title function_">replace</span>(<span class="regexp">/\-/g</span>, <span class="string">&#x27;+&#x27;</span>)</span><br><span class="line">    .<span class="title function_">replace</span>(<span class="regexp">/_/g</span>, <span class="string">&#x27;/&#x27;</span>);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> rawData = <span class="variable language_">window</span>.<span class="title function_">atob</span>(base64);</span><br><span class="line">  <span class="keyword">const</span> buffer = <span class="keyword">new</span> <span class="title class_">Uint8Array</span>(rawData.<span class="property">length</span>);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">for</span> (<span class="keyword">let</span> i = <span class="number">0</span>; i &lt; rawData.<span class="property">length</span>; ++i) &#123;</span><br><span class="line">    buffer[i] = rawData.<span class="title function_">charCodeAt</span>(i);</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">return</span> buffer;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> applicationServerKey = <span class="title function_">base64UrlToUint8Array</span>(<span class="string">&#x27;这里放公钥&#x27;</span>); <span class="comment">// 请将这里替换为您的 VAPID 公钥</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// 这段代码放在注册流程之后</span></span><br><span class="line"><span class="comment">// 即上文中的 swRegistration.update(); 之后</span></span><br><span class="line">swRegistration.<span class="property">pushManager</span>.<span class="title function_">subscribe</span>(&#123;</span><br><span class="line">  <span class="attr">userVisibleOnly</span>: <span class="literal">true</span>, <span class="comment">// 强制规范，表示该订阅仅能用于显示用户通知</span></span><br><span class="line">  <span class="attr">applicationServerKey</span>: applicationServerKey&#125;).</span><br><span class="line">  <span class="title function_">then</span>(<span class="keyword">function</span>(<span class="params">subscription</span>) &#123;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;User is subscribed:&#x27;</span>, subscription);</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="title class_">JSON</span>.<span class="title function_">stringify</span>(subscription));</span><br><span class="line">  &#125;).<span class="title function_">catch</span>(<span class="keyword">function</span>(<span class="params">err</span>) &#123;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;Failed to subscribe the user: &#x27;</span>, err);</span><br><span class="line">  &#125;);</span><br></pre></td></tr></table></figure><h3 id="强耦合的-Push-Service-与-UA"><a href="#强耦合的-Push-Service-与-UA" class="headerlink" title="强耦合的 Push Service 与 UA"></a>强耦合的 Push Service 与 UA</h3><p>从这一步开始，真正有意思的地方开始展开眉目。<br>我们运行上面的代码，将粗糙的 Service Worker 狠狠注册入浏览器，然后打开抓包软件和控制台，点击「同意推送」的按钮。</p><p>然后我们可以在控制台看见这样的输出：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241204205057361.png"/></div></div><p>换句话说，我们完成 Service Worker 的注册之后，得到了如下所示的一串 JSON ：</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><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;endpoint&quot;</span><span class="punctuation">:</span> <span class="string">&quot;https://fcm.googleapis.com/fcm/send/dZiga:APA91bGM5iYsrYjRUjjn4lpmLtEW6&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;expirationTime&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;keys&quot;</span><span class="punctuation">:</span><span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;p256dh&quot;</span><span class="punctuation">:</span><span class="string">&quot;BCUoPTSKzN3QWTEhmRrf&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;auth&quot;</span><span class="punctuation">:</span><span class="string">&quot;AjkZRuLhkK&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><ul><li><code>endpoint</code> 是一个指向 Push Service 的链接，指向 Push Service 为该 UA 上的该网页所准备的订阅端点。后面使用这个端点便能为此次申请的订阅发送消息。</li><li><code>expirationTime</code> 是一个过期时间，表示这个订阅的有效期。在这个时间之后，这个订阅将会被自动删除。   <ul><li>至少对于 Chromium 系浏览器而言，<a href="https://github.com/chromium/chromium/blob/160dfef1f66238a6936d5492033aa7022087e373/chrome/browser/push_messaging/push_messaging_features.cc#L15">查看源代码</a> 可以发现，这一项默认都是禁用的，也就是说返回值一般都是 <code>null</code> 。</li></ul></li><li><code>keys</code> 是一个包含了两个子项的对象，分别是 <code>p256dh</code> 和 <code>auth</code> 。这两个子项是用于在推送消息时对消息进行加密的密钥对。   <ul><li><code>p256dh</code> 是一个 <code>P-256</code> 公钥。</li><li><code>auth</code> 是一个 <code>Base64</code> 密钥，这是 UA 为 Application Server 生成的鉴权密码，与 <code>endpoint</code> 配合使用。</li></ul></li></ul><p>对于 <code>keys</code> 需要额外补充的一点：IETF 的 <a href="https://datatracker.ietf.org/doc/html/rfc8030">RFC8030</a> 要求服务端对推送消息进行<a href="https://datatracker.ietf.org/doc/html/draft-ietf-webpush-encryption-06">RFC8291 加密</a>，并通过 <a href="https://datatracker.ietf.org/doc/html/rfc8188">RFC8188</a> 定义的 <code>aes128gcm</code> 进行<strong>加密传输</strong>，摘抄如下，感兴趣的可回到原标准进一步研究：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><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><span class="line">-- For a user agent:</span><br><span class="line">    ecdh_secret = ECDH(ua_private, as_public)</span><br><span class="line">    auth_secret = random(16)</span><br><span class="line">    salt = &lt;from content coding header&gt;</span><br><span class="line"></span><br><span class="line">-- For an application server:</span><br><span class="line">    ecdh_secret = ECDH(as_private, ua_public)</span><br><span class="line">    auth_secret = &lt;from user agent&gt;</span><br><span class="line">    salt = random(16)</span><br><span class="line"></span><br><span class="line">-- For both:</span><br><span class="line"></span><br><span class="line">    ## Use HKDF to combine the ECDH and authentication secrets</span><br><span class="line">    # HKDF-Extract(salt=auth_secret, IKM=ecdh_secret)</span><br><span class="line">    PRK_key = HMAC-SHA-256(auth_secret, ecdh_secret)</span><br><span class="line">    # HKDF-Expand(PRK_key, key_info, L_key=32)</span><br><span class="line">    key_info = &quot;WebPush: info&quot; || 0x00 || ua_public || as_public</span><br><span class="line">    IKM = HMAC-SHA-256(PRK_key, key_info || 0x01)</span><br><span class="line"></span><br><span class="line">    ## HKDF calculations from RFC 8188</span><br><span class="line">    # HKDF-Extract(salt, IKM)</span><br><span class="line">    PRK = HMAC-SHA-256(salt, IKM)</span><br><span class="line">    # HKDF-Expand(PRK, cek_info, L_cek=16)</span><br><span class="line">    cek_info = &quot;Content-Encoding: aes128gcm&quot; || 0x00</span><br><span class="line">    CEK = HMAC-SHA-256(PRK, cek_info || 0x01)[0..15]</span><br><span class="line">    # HKDF-Expand(PRK, nonce_info, L_nonce=12)</span><br><span class="line">    nonce_info = &quot;Content-Encoding: nonce&quot; || 0x00</span><br><span class="line">    NONCE = HMAC-SHA-256(PRK, nonce_info || 0x01)[0..11]</span><br></pre></td></tr></table></figure><p>相信有心的读者已经对 <code>endpoint</code> 里的 <code>fcm.googleapis.com</code> 有了一些敏感度，而结果也确实如此：这个端点，实际上是谷歌的 <code>Firebase Cloud Messaging</code> 服务。这也就意味着，我们的消息实际上是通过谷歌的推送服务来传递的。</p><p>这时我们回到抓包软件，会发现在点击推送按钮的同时，Chrome 发送了一个有意思的请求：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241204212136185.png"/></div></div><p>至此，整个推送链条的第二环浮出水面：此处的 Push Service (FCM) 与 UA(Chrome) 实际上是<strong>强耦合</strong>的关系。</p><h3 id="向-Push-Service-的更深一步"><a href="#向-Push-Service-的更深一步" class="headerlink" title="向 Push Service 的更深一步"></a>向 Push Service 的更深一步</h3><p>接下来我们来研究一下这个请求，部分字段进行了脱敏处理但是可以保证一对一映射：</p><figure class="highlight http"><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><span class="line"><span class="keyword">POST</span> <span class="string">/c2dm/register3</span> <span class="meta">HTTP/2</span></span><br><span class="line"><span class="attribute">host</span><span class="punctuation">: </span>android.clients.google.com</span><br><span class="line"><span class="attribute">content-length</span><span class="punctuation">: </span>268</span><br><span class="line"><span class="attribute">authorization</span><span class="punctuation">: </span>AidLogin 114514:1919810</span><br><span class="line"><span class="attribute">content-type</span><span class="punctuation">: </span>application/x-www-form-urlencoded</span><br><span class="line"><span class="attribute">sec-fetch-site</span><span class="punctuation">: </span>none</span><br><span class="line"><span class="attribute">sec-fetch-mode</span><span class="punctuation">: </span>no-cors</span><br><span class="line"><span class="attribute">sec-fetch-dest</span><span class="punctuation">: </span>empty</span><br><span class="line"><span class="attribute">user-agent</span><span class="punctuation">: </span>Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36</span><br><span class="line"><span class="attribute">accept-encoding</span><span class="punctuation">: </span>gzip, deflate, br, zstd</span><br><span class="line"><span class="attribute">accept-language</span><span class="punctuation">: </span>zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7</span><br><span class="line"><span class="attribute">priority</span><span class="punctuation">: </span>u=4, i</span><br><span class="line"></span><br><span class="line"><span class="language-apache"><span class="attribute">app</span>=com.chrome.windows&amp;X-subtype=wp:https://blog.hanlin.press/%<span class="number">235008</span>CBF7-<span class="number">830</span>C-<span class="number">43</span>E6-<span class="number">9</span>F8E-<span class="number">76</span>ADF5AA4-V2&amp;device=<span class="number">114514</span>&amp;scope=GCM&amp;X-scope=GCM&amp;gmsv=<span class="number">131</span>&amp;appid=dZiga&amp;sender=BBdRH5vr</span></span><br><span class="line"><span class="language-apache"></span></span><br><span class="line"><span class="language-apache"><span class="attribute">token</span>=dZiga:APA91bGM5iYsrYjRUjjn4lpmLtEW6</span></span><br></pre></td></tr></table></figure><p>我们可以把请求和返回拆开来猜测一下：</p><ul><li><code>app</code>: <code>com.chrome.windows</code> 看样子指向了 Chrome 浏览器的 Windows 版本。</li><li>c3ad4ca2-cced-4d75-82f7-c2128563beda</li><li><code>X-subtype</code>: <code>wp:https://blog.hanlin.press/#5008CBF7-830C-43E6-9F8E-76ADF5AA4-V2</code> 是一个特意构造的 URL，应该是用于标识这个域名下的唯一订阅。</li><li><code>device</code>: <code>114514</code> 是一个设备 ID，与标头的 <code>authorization</code> 对应。</li><li><code>appid</code> 和 <code>token</code>：<strong>与上文返回的 JSON 里的 <code>endpoint</code> 完全对应</strong>。</li><li><code>sender</code>：<strong>与上文中提交的 VAPID 公钥完全对应</strong>。</li></ul><p>由此可见，Chrome 浏览器一定是从这个请求中获取到了与 Push Service 通信的必要信息，然后将这个信息进一步返回到 <code>PushSubscription</code> 对象中。</p><p>如果我们尝试翻翻 <a href="https://github.com/search?q=repo:chromium/chromium%20https://android.clients.google.com/c2dm/register3&type=code">Chromium 的源码</a> ，不难发现，这个地址 <code>https://android.clients.google.com/c2dm/register3</code> 是硬编码进去的：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241204214438534.png"/></div></div><p>继续搜索，会发现这是谷歌<a href="https://developers.google.com/android/c2dm?hl=zh-cn"><em>声明</em></a><strong>早已在 2015 年彻底弃用</strong>的 <code>C2DM</code> 推送服务所使用的端点，它甚至是 <code>GCM</code> 的前辈，可以被称作 <code>FCM</code> 的爷爷。可能出于历史遗留向下兼容的问题，在 2024 年运行的 Chrome 中还能看见这个地址。  </p><p>因为实在不想再去读一遍 Chromium 的源代码，我去 Wayback Archive 里找到了描述注册流程的文档：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241204220306907.png"/></div></div><p>这个文档里描述了 <code>app</code> 和 <code>sender</code> 两个必要的对象，接下来是时候看看 <a href="https://github.com/chromium/chromium/tree/0566a57167900639ecefbaea2dba64ff369c2a59/chrome/browser/push_messaging">Chromium 的源码</a> 了，关键的部分摘抄在下面：</p><figure class="highlight c++"><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></pre></td><td class="code"><pre><span class="line"><span class="comment">// -- chrome/browser/push_messaging/push_messaging_constants.cc</span></span><br><span class="line"></span><br><span class="line"><span class="type">const</span> <span class="type">char</span> kPushMessagingGcmEndpoint[] =</span><br><span class="line">    <span class="string">&quot;https://fcm.googleapis.com/fcm/send/&quot;</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// -- chrome/browser/push_messaging/push_messaging_service_impl.cc</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// Chrome does not yet support silent push messages, and requires websites to</span></span><br><span class="line"><span class="comment">// indicate that they will only send user-visible messages.</span></span><br><span class="line"><span class="type">const</span> <span class="type">char</span> kSilentPushUnsupportedMessage[] =</span><br><span class="line">    <span class="string">&quot;Chrome currently only supports the Push API for subscriptions that will &quot;</span></span><br><span class="line">    <span class="string">&quot;result in user-visible messages. You can indicate this by calling &quot;</span></span><br><span class="line">    <span class="string">&quot;pushManager.subscribe(&#123;userVisibleOnly: true&#125;) instead. See &quot;</span></span><br><span class="line">    <span class="string">&quot;https://goo.gl/yqv4Q4 for more details.&quot;</span>;</span><br></pre></td></tr></table></figure><ul><li>目前返回的推送 API 固定为 <code>https://fcm.googleapis.com/fcm/send/</code> 开头，指向上面说的 FCM 服务。</li><li>参见上文的强制规范：<strong>Chrome 强制网页声明 Push API 仅能用于用户可见的通知用途。</strong></li></ul><figure class="highlight c++"><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><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// -- chrome/browser/push_messaging/push_messaging_service_impl.cc</span></span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">PushMessagingServiceImpl::SubscribeFromWorker</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">const</span> GURL&amp; requesting_origin,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">int64_t</span> service_worker_registration_id,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">int</span> render_process_id,</span></span></span><br><span class="line"><span class="params"><span class="function">    blink::mojom::PushSubscriptionOptionsPtr options,</span></span></span><br><span class="line"><span class="params"><span class="function">    RegisterCallback register_callback)</span> </span>&#123;</span><br><span class="line">  render_process_id_ = render_process_id;</span><br><span class="line">  PushMessagingAppIdentifier app_identifier =</span><br><span class="line">      PushMessagingAppIdentifier::<span class="built_in">FindByServiceWorker</span>(</span><br><span class="line">          profile_, requesting_origin, service_worker_registration_id);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// If there is no existing app identifier for the given Service Worker,</span></span><br><span class="line">  <span class="comment">// generate a new one. This will create a new subscription on the server.</span></span><br><span class="line">  <span class="keyword">if</span> (app_identifier.<span class="built_in">is_null</span>()) &#123;</span><br><span class="line">    app_identifier = PushMessagingAppIdentifier::<span class="built_in">Generate</span>(</span><br><span class="line">        requesting_origin, service_worker_registration_id);</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (push_subscription_count_ + pending_push_subscription_count_ &gt;=</span><br><span class="line">      kMaxRegistrations) &#123;</span><br><span class="line">    <span class="built_in">SubscribeEndWithError</span>(std::<span class="built_in">move</span>(register_callback),</span><br><span class="line">                          blink::mojom::PushRegistrationStatus::LIMIT_REACHED);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (!<span class="built_in">IsPermissionSet</span>(requesting_origin, options-&gt;user_visible_only)) &#123;</span><br><span class="line">    <span class="built_in">SubscribeEndWithError</span>(</span><br><span class="line">        std::<span class="built_in">move</span>(register_callback),</span><br><span class="line">        blink::mojom::PushRegistrationStatus::PERMISSION_DENIED);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (!options-&gt;user_visible_only) &#123;</span><br><span class="line">    origins_requesting_user_visible_requirement_bypass.<span class="built_in">insert</span>(</span><br><span class="line">        app_identifier.<span class="built_in">origin</span>());</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="built_in">DoSubscribe</span>(std::<span class="built_in">move</span>(app_identifier), std::<span class="built_in">move</span>(options),</span><br><span class="line">              std::<span class="built_in">move</span>(register_callback),</span><br><span class="line">              <span class="comment">/* render_process_id= */</span> <span class="number">-1</span>, <span class="comment">/* render_frame_id= */</span> <span class="number">-1</span>,</span><br><span class="line">              blink::mojom::PermissionStatus::GRANTED);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// -- chrome/browser/push_messaging/push_messaging_app_identifier.cc</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">constexpr</span> <span class="type">char</span> kPushMessagingAppIdentifierPrefix[] = <span class="string">&quot;wp:&quot;</span>;</span><br><span class="line"><span class="keyword">constexpr</span> <span class="type">char</span> kInstanceIDGuidSuffix[] = <span class="string">&quot;-V2&quot;</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// sizeof is strlen + 1 since it&#x27;s null-terminated.</span></span><br><span class="line"><span class="keyword">constexpr</span> <span class="type">size_t</span> kPrefixLength = <span class="built_in">sizeof</span>(kPushMessagingAppIdentifierPrefix) - <span class="number">1</span>;</span><br><span class="line"><span class="keyword">constexpr</span> <span class="type">size_t</span> kGuidSuffixLength = <span class="built_in">sizeof</span>(kInstanceIDGuidSuffix) - <span class="number">1</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Ok to use &#x27;#&#x27; as separator since only the origin of the url is used.</span></span><br><span class="line"><span class="keyword">constexpr</span> <span class="type">char</span> kPrefValueSeparator = <span class="string">&#x27;#&#x27;</span>;</span><br><span class="line"><span class="keyword">constexpr</span> <span class="type">size_t</span> kGuidLength = <span class="number">36</span>;  <span class="comment">// &quot;%08X-%04X-%04X-%04X-%012llX&quot;</span></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment">// static</span></span><br><span class="line"><span class="function">PushMessagingAppIdentifier <span class="title">PushMessagingAppIdentifier::Generate</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">const</span> GURL&amp; origin,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">int64_t</span> service_worker_registration_id,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">const</span> std::optional&lt;base::Time&gt;&amp; expiration_time)</span> </span>&#123;</span><br><span class="line">  <span class="comment">// All new push subscriptions use Instance ID tokens.</span></span><br><span class="line">  <span class="keyword">return</span> <span class="built_in">GenerateInternal</span>(origin, service_worker_registration_id,</span><br><span class="line">                          <span class="literal">true</span> <span class="comment">/* use_instance_id */</span>, expiration_time);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// static</span></span><br><span class="line"><span class="function">PushMessagingAppIdentifier <span class="title">PushMessagingAppIdentifier::GenerateInternal</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">const</span> GURL&amp; origin,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">int64_t</span> service_worker_registration_id,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">bool</span> use_instance_id,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">const</span> std::optional&lt;base::Time&gt;&amp; expiration_time)</span> </span>&#123;</span><br><span class="line">  <span class="comment">// Use uppercase GUID for consistency with GUIDs Push has already sent to GCM.</span></span><br><span class="line">  <span class="comment">// Also allows detecting case mangling; see code commented &quot;crbug.com/461867&quot;.</span></span><br><span class="line">  std::string guid =</span><br><span class="line">      base::<span class="built_in">ToUpperASCII</span>(base::Uuid::<span class="built_in">GenerateRandomV4</span>().<span class="built_in">AsLowercaseString</span>());</span><br><span class="line">  <span class="keyword">if</span> (use_instance_id) &#123;</span><br><span class="line">    guid.<span class="built_in">replace</span>(guid.<span class="built_in">size</span>() - kGuidSuffixLength, kGuidSuffixLength,</span><br><span class="line">                 kInstanceIDGuidSuffix);</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="built_in">CHECK</span>(!guid.<span class="built_in">empty</span>());</span><br><span class="line">  std::string app_id = kPushMessagingAppIdentifierPrefix + origin.<span class="built_in">spec</span>() +</span><br><span class="line">                       kPrefValueSeparator + guid;</span><br><span class="line"></span><br><span class="line">  <span class="function">PushMessagingAppIdentifier <span class="title">app_identifier</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">      app_id, origin, service_worker_registration_id, expiration_time)</span></span>;</span><br><span class="line">  app_identifier.<span class="built_in">DCheckValid</span>();</span><br><span class="line">  <span class="keyword">return</span> app_identifier;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"></span><br></pre></td></tr></table></figure><p>结合之前的抓包，不难得出这一段展示了之前 <code>X-subtype</code> 的生成逻辑：<code>wp:</code>+网页的 Origin 段（通常是请求头带域名）+<code>#</code>+随机生成的 UUID （最后三位替换成<code>-V2</code>）</p><figure class="highlight c++"><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><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// -- chrome/browser/push_messaging/push_messaging_service_impl.cc</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// Message displayed in the console (as an error) when a GCM Sender ID is used</span></span><br><span class="line"><span class="comment">// to create a subscription, which is unsupported. The subscription request will</span></span><br><span class="line"><span class="comment">// have been blocked, and an exception will be thrown as well.</span></span><br><span class="line"><span class="type">const</span> <span class="type">char</span> kSenderIdRegistrationDisallowedMessage[] =</span><br><span class="line">    <span class="string">&quot;The provided application server key is not a VAPID key. Only VAPID keys &quot;</span></span><br><span class="line">    <span class="string">&quot;are supported. For more information check https://crbug.com/979235.&quot;</span>;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">PushMessagingServiceImpl::DoSubscribe</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    PushMessagingAppIdentifier app_identifier,</span></span></span><br><span class="line"><span class="params"><span class="function">    blink::mojom::PushSubscriptionOptionsPtr options,</span></span></span><br><span class="line"><span class="params"><span class="function">    RegisterCallback register_callback,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">int</span> render_process_id,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">int</span> render_frame_id,</span></span></span><br><span class="line"><span class="params"><span class="function">    blink::mojom::PermissionStatus permission_status)</span> </span>&#123;</span><br><span class="line">  <span class="keyword">if</span> (permission_status != blink::mojom::PermissionStatus::GRANTED) &#123;</span><br><span class="line">    <span class="built_in">SubscribeEndWithError</span>(</span><br><span class="line">        std::<span class="built_in">move</span>(register_callback),</span><br><span class="line">        blink::mojom::PushRegistrationStatus::PERMISSION_DENIED);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="function">std::string <span class="title">application_server_key_string</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">      options-&gt;application_server_key.begin(),</span></span></span><br><span class="line"><span class="params"><span class="function">      options-&gt;application_server_key.end())</span></span>;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// TODO(peter): Move this check to the renderer process &amp; Mojo message</span></span><br><span class="line">  <span class="comment">// validation once the flag is always enabled, and remove the</span></span><br><span class="line">  <span class="comment">// |render_process_id| and |render_frame_id| parameters from this method.</span></span><br><span class="line">  <span class="keyword">if</span> (!push_messaging::<span class="built_in">IsVapidKey</span>(application_server_key_string)) &#123;</span><br><span class="line">    content::RenderFrameHost* render_frame_host =</span><br><span class="line">        content::RenderFrameHost::<span class="built_in">FromID</span>(render_process_id, render_frame_id);</span><br><span class="line">    <span class="keyword">if</span> (base::FeatureList::<span class="built_in">IsEnabled</span>(</span><br><span class="line">            features::kPushMessagingDisallowSenderIDs)) &#123;</span><br><span class="line">      <span class="keyword">if</span> (render_frame_host) &#123;</span><br><span class="line">        render_frame_host-&gt;<span class="built_in">AddMessageToConsole</span>(</span><br><span class="line">            blink::mojom::ConsoleMessageLevel::kError,</span><br><span class="line">            kSenderIdRegistrationDisallowedMessage);</span><br><span class="line">      &#125;</span><br><span class="line">      <span class="built_in">SubscribeEndWithError</span>(</span><br><span class="line">          std::<span class="built_in">move</span>(register_callback),</span><br><span class="line">          blink::mojom::PushRegistrationStatus::UNSUPPORTED_GCM_SENDER_ID);</span><br><span class="line">      <span class="keyword">return</span>;</span><br><span class="line">    &#125; <span class="keyword">else</span> <span class="keyword">if</span> (render_frame_host) &#123;</span><br><span class="line">      render_frame_host-&gt;<span class="built_in">AddMessageToConsole</span>(</span><br><span class="line">          blink::mojom::ConsoleMessageLevel::kWarning,</span><br><span class="line">          kSenderIdRegistrationDeprecatedMessage);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="built_in">IncreasePushSubscriptionCount</span>(<span class="number">1</span>, <span class="literal">true</span> <span class="comment">/* is_pending */</span>);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// Set time to live for GCM registration</span></span><br><span class="line">  base::TimeDelta ttl = base::<span class="built_in">TimeDelta</span>();</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (base::FeatureList::<span class="built_in">IsEnabled</span>(</span><br><span class="line">          features::kPushSubscriptionWithExpirationTime)) &#123;</span><br><span class="line">    app_identifier.<span class="built_in">set_expiration_time</span>(</span><br><span class="line">        base::Time::<span class="built_in">Now</span>() + kPushSubscriptionExpirationPeriodTimeDelta);</span><br><span class="line">    <span class="built_in">DCHECK</span>(app_identifier.<span class="built_in">expiration_time</span>());</span><br><span class="line">    ttl = kPushSubscriptionExpirationPeriodTimeDelta;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="built_in">GetInstanceIDDriver</span>()</span><br><span class="line">      -&gt;<span class="built_in">GetInstanceID</span>(app_identifier.<span class="built_in">app_id</span>())</span><br><span class="line">      -&gt;<span class="built_in">GetToken</span>(</span><br><span class="line">          push_messaging::<span class="built_in">NormalizeSenderInfo</span>(application_server_key_string),</span><br><span class="line">          kGCMScope, ttl, &#123;&#125; <span class="comment">/* flags */</span>,</span><br><span class="line">          base::<span class="built_in">BindOnce</span>(&amp;PushMessagingServiceImpl::DidSubscribe,</span><br><span class="line">                         weak_factory_.<span class="built_in">GetWeakPtr</span>(), app_identifier,</span><br><span class="line">                         application_server_key_string,</span><br><span class="line">                         std::<span class="built_in">move</span>(register_callback)));</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">PushMessagingServiceImpl::DidSubscribe</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">const</span> PushMessagingAppIdentifier&amp; app_identifier,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">const</span> std::string&amp; sender_id,</span></span></span><br><span class="line"><span class="params"><span class="function">    RegisterCallback callback,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">const</span> std::string&amp; subscription_id,</span></span></span><br><span class="line"><span class="params"><span class="function">    InstanceID::Result result)</span> </span>&#123;</span><br><span class="line">  <span class="built_in">DecreasePushSubscriptionCount</span>(<span class="number">1</span>, <span class="literal">true</span> <span class="comment">/* was_pending */</span>);</span><br><span class="line"></span><br><span class="line">  blink::mojom::PushRegistrationStatus status =</span><br><span class="line">      blink::mojom::PushRegistrationStatus::SERVICE_ERROR;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">switch</span> (result) &#123;</span><br><span class="line">    <span class="keyword">case</span> InstanceID::SUCCESS: &#123;</span><br><span class="line">      <span class="type">const</span> GURL endpoint = push_messaging::<span class="built_in">CreateEndpoint</span>(subscription_id);</span><br><span class="line"></span><br><span class="line">      <span class="comment">// Make sure that this subscription has associated encryption keys prior</span></span><br><span class="line">      <span class="comment">// to returning it to the developer - they&#x27;ll need this information in</span></span><br><span class="line">      <span class="comment">// order to send payloads to the user.</span></span><br><span class="line">      <span class="built_in">GetEncryptionInfoForAppId</span>(</span><br><span class="line">          app_identifier.<span class="built_in">app_id</span>(), sender_id,</span><br><span class="line">          base::<span class="built_in">BindOnce</span>(</span><br><span class="line">              &amp;PushMessagingServiceImpl::DidSubscribeWithEncryptionInfo,</span><br><span class="line">              weak_factory_.<span class="built_in">GetWeakPtr</span>(), app_identifier, std::<span class="built_in">move</span>(callback),</span><br><span class="line">              subscription_id, endpoint));</span><br><span class="line">      <span class="keyword">return</span>;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">case</span> InstanceID::INVALID_PARAMETER:</span><br><span class="line">    <span class="keyword">case</span> InstanceID::DISABLED:</span><br><span class="line">    <span class="keyword">case</span> InstanceID::ASYNC_OPERATION_PENDING:</span><br><span class="line">    <span class="keyword">case</span> InstanceID::SERVER_ERROR:</span><br><span class="line">    <span class="keyword">case</span> InstanceID::UNKNOWN_ERROR:</span><br><span class="line">      <span class="built_in">DLOG</span>(ERROR) &lt;&lt; <span class="string">&quot;Push messaging subscription failed; InstanceID::Result = &quot;</span></span><br><span class="line">                  &lt;&lt; result;</span><br><span class="line">      status = blink::mojom::PushRegistrationStatus::SERVICE_ERROR;</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">    <span class="keyword">case</span> InstanceID::NETWORK_ERROR:</span><br><span class="line">      status = blink::mojom::PushRegistrationStatus::NETWORK_ERROR;</span><br><span class="line">      <span class="keyword">break</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="built_in">SubscribeEndWithError</span>(std::<span class="built_in">move</span>(callback), status);</span><br><span class="line">&#125;</span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">PushMessagingServiceImpl::DidSubscribeWithEncryptionInfo</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">const</span> PushMessagingAppIdentifier&amp; app_identifier,</span></span></span><br><span class="line"><span class="params"><span class="function">    RegisterCallback callback,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">const</span> std::string&amp; subscription_id,</span></span></span><br><span class="line"><span class="params"><span class="function">    <span class="type">const</span> GURL&amp; endpoint,</span></span></span><br><span class="line"><span class="params"><span class="function">    std::string p256dh,</span></span></span><br><span class="line"><span class="params"><span class="function">    std::string auth_secret)</span> </span>&#123;</span><br><span class="line">  <span class="keyword">if</span> (p256dh.<span class="built_in">empty</span>()) &#123;</span><br><span class="line">    <span class="built_in">SubscribeEndWithError</span>(</span><br><span class="line">        std::<span class="built_in">move</span>(callback),</span><br><span class="line">        blink::mojom::PushRegistrationStatus::PUBLIC_KEY_UNAVAILABLE);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  app_identifier.<span class="built_in">PersistToPrefs</span>(profile_);</span><br><span class="line"></span><br><span class="line">  <span class="built_in">IncreasePushSubscriptionCount</span>(<span class="number">1</span>, <span class="literal">false</span> <span class="comment">/* is_pending */</span>);</span><br><span class="line"></span><br><span class="line">  <span class="built_in">SubscribeEnd</span>(std::<span class="built_in">move</span>(callback), subscription_id, endpoint,</span><br><span class="line">               app_identifier.<span class="built_in">expiration_time</span>(),</span><br><span class="line">               std::<span class="built_in">vector</span>&lt;<span class="type">uint8_t</span>&gt;(p256dh.<span class="built_in">begin</span>(), p256dh.<span class="built_in">end</span>()),</span><br><span class="line">               std::<span class="built_in">vector</span>&lt;<span class="type">uint8_t</span>&gt;(auth_secret.<span class="built_in">begin</span>(), auth_secret.<span class="built_in">end</span>()),</span><br><span class="line">               blink::mojom::PushRegistrationStatus::SUCCESS_FROM_PUSH_SERVICE);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>更深的内置到 GCM 的推送服务细节无法追溯，但是此处的细节已经可以猜出个大概：Chrome 将 <code>sender</code> 设置为之前传入的 VAPID 公钥（参照 <a href="https://crbug.com/979235">CRBUG #979235</a>， 他们曾经还使用过需要注册账号的 GCM 服务进行推送），<code>app</code> 设置为 <code>com.chrome.windows</code> （在注释中还看到过 <code>com.chrome.macosx</code>），然后执行了一次较为标准的 GCM 通知注册流程。</p><p>这整个过程与面向移动应用的推送服务的流程非常相似，但是与后者比起来，Web Push 显得开放却又隐秘。</p><ul><li>从开放方面来说，Web Push 不需要经过发布商或是运营商的审核，只要获得了用户的许可和一个<em>正常的网络</em>，任何网站都能向用户推送消息。即使有 VAPID 这种机制，也仅仅是作为标识使用，它的生成本身并不需要任何审核，更不需要向任何一个组织递交申请书。</li><li>从隐秘方面来说，Web Push 的加密机制也在协议上保证了消息的隐私性。消息的加密和解密都是在 UA 和 Application Server 之间进行的，Push Service 无法窥探消息的内容。</li></ul><h3 id="Service-Worker-只要监听就好了"><a href="#Service-Worker-只要监听就好了" class="headerlink" title="Service Worker 只要监听就好了"></a>Service Worker 只要监听就好了</h3><blockquote><p>Web Push 是这样的，被注册的 Service Worker 仅需要监听 <code>push</code> 事件，然后做出对应的处理就好了，而我们浏览器 UA 所需要干的可就多了。</p></blockquote><p>研究了 Push Service 半天，我们还没来得及认真看一看我们注册了有一段时间的 Service Worker。                                                            </p><p>打开 F12 开发者选项，选择 Application 栏 → 左侧 Service Workers 菜单，可以查看这个 Worker 的注册状态。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241205150505273.png"/></div></div>         <p>顺带一提，在 Worker 右侧可以看见一个 Unregister 按钮，<strong>点击就可以卸载掉这个 Service Worker，顺带退订掉该 Worker 处理的通知</strong>。在抓包软件里也可能看到，Chrome 向 <code>https://android.clients.google.com/c2dm/register3</code> 再次发送了一次请求，只不过这次在大致相同的请求里追加了 <code>delete=true</code> 项。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241205151945157.png"/></div></div><p>这时注意到下方的 Push 栏，这里是预留的测试入口，仅需在框里输入内容，然后点击右侧的「Push」按钮，即可在无 Push Service 的情况下模拟一次推送事件。<br>这时切换到 Console 栏查看控制台输出，可以观测到成功触发了监听到的 <code>push</code> 事件。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241205153701436.png"/></div></div><p>可以看到，这里将测试栏里的 <code>Test push message from DevTools.</code> 和 <code>&#123;&#125;</code> 原样输出了出来。</p><p>接下来该试试真正的推送了，我们拿好上文生成的 VAPID 公钥和私钥，构造一个真正的 Application Server （前文所提及的都仅仅能称作前端），我无心运营真正的消息推送平台，所以我拿 Cloudflare Worker 模拟了一个：</p><figure class="highlight typescript"><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><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> &#123;</span><br><span class="line">    buildPushPayload,</span><br><span class="line">    <span class="keyword">type</span> <span class="title class_">PushSubscription</span>,</span><br><span class="line">    <span class="keyword">type</span> <span class="title class_">PushMessage</span>,</span><br><span class="line">    <span class="keyword">type</span> <span class="title class_">VapidKeys</span>,</span><br><span class="line">&#125; <span class="keyword">from</span> <span class="string">&#x27;@block65/webcrypto-web-push&#x27;</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 跨域请求</span></span><br><span class="line"><span class="keyword">const</span> corsHeaders = &#123;</span><br><span class="line">    <span class="string">&#x27;Access-Control-Allow-Origin&#x27;</span>: <span class="string">&#x27;*&#x27;</span>,</span><br><span class="line">    <span class="string">&#x27;Access-Control-Allow-Methods&#x27;</span>: <span class="string">&#x27;GET,HEAD,POST,OPTIONS&#x27;</span>,</span><br><span class="line">    <span class="string">&#x27;Access-Control-Allow-Headers&#x27;</span>: <span class="string">&#x27;Content-Type&#x27;</span></span><br><span class="line">&#125;;</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">handleOptions</span>(<span class="params"><span class="attr">request</span>: <span class="title class_">Request</span></span>): <span class="title class_">Promise</span>&lt;<span class="title class_">Response</span>&gt; &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">Response</span>(<span class="literal">null</span>, &#123;</span><br><span class="line">        <span class="attr">headers</span>: corsHeaders</span><br><span class="line">    &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">handleSubscribe</span>(<span class="params"><span class="attr">request</span>: <span class="title class_">Request</span>, <span class="attr">env</span>: <span class="built_in">any</span></span>): <span class="title class_">Promise</span>&lt;<span class="title class_">Response</span>&gt; &#123;</span><br><span class="line">    <span class="keyword">const</span> subscription_data = <span class="keyword">await</span> request.<span class="title function_">json</span>();</span><br><span class="line">    <span class="keyword">const</span> vapidKeys = env.<span class="property">VAPID_KEYS</span>;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 等待1秒</span></span><br><span class="line">    <span class="keyword">await</span> <span class="keyword">new</span> <span class="title class_">Promise</span>(<span class="function"><span class="params">resolve</span> =&gt;</span> <span class="built_in">setTimeout</span>(resolve, <span class="number">1000</span>));</span><br><span class="line"></span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">        <span class="comment">// 准备推送消息的payload</span></span><br><span class="line">        <span class="keyword">const</span> <span class="attr">message</span>: <span class="title class_">PushMessage</span> = &#123;</span><br><span class="line">            <span class="attr">data</span>: <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(&#123;</span><br><span class="line">                <span class="attr">title</span>: <span class="string">&#x27;订阅成功&#x27;</span>,</span><br><span class="line">                <span class="attr">body</span>: <span class="string">&#x27;恭喜，您已经成功触发了推送通知！本通知由 Cloudflare Worker 模拟，我们不会存储您的推送端点，更无法继续发送其他消息。&#x27;</span>,</span><br><span class="line">                <span class="attr">icon</span>: <span class="string">&#x27;https://blog.hanlin.press/uploads/Something-About-Web-Push-API/push-icon.png&#x27;</span>,</span><br><span class="line">                <span class="attr">link</span>: <span class="string">&#x27;https://blog.hanlin.press/2024/12/Something-About-Web-Push-API&#x27;</span></span><br><span class="line">            &#125;),</span><br><span class="line">        &#125;;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 发送推送通知</span></span><br><span class="line">        <span class="keyword">const</span> <span class="attr">vapid</span>: <span class="title class_">VapidKeys</span> = &#123;</span><br><span class="line">            <span class="attr">publicKey</span>: env.<span class="property">VAPID_PUB</span>,</span><br><span class="line">            <span class="attr">privateKey</span>: env.<span class="property">VAPID_PRIV</span>,</span><br><span class="line">            <span class="attr">subject</span>: <span class="string">&quot;https://blog.hanlin.press/2024/12/Something-About-Web-Push-API&quot;</span></span><br><span class="line">        &#125;;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">const</span> <span class="attr">subscription</span>: <span class="title class_">PushSubscription</span> = &#123;</span><br><span class="line">            <span class="attr">endpoint</span>: subscription_data.<span class="property">endpoint</span>,</span><br><span class="line">            <span class="attr">expirationTime</span>: <span class="literal">null</span>,</span><br><span class="line">            <span class="attr">keys</span>: &#123;</span><br><span class="line">                <span class="attr">p256dh</span>: subscription_data.<span class="property">keys</span>.<span class="property">p256dh</span>,</span><br><span class="line">                <span class="attr">auth</span>: subscription_data.<span class="property">keys</span>.<span class="property">auth</span></span><br><span class="line">            &#125;</span><br><span class="line">        &#125;;</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"></span><br><span class="line">        <span class="keyword">const</span> req_data = <span class="keyword">await</span> <span class="title function_">buildPushPayload</span>(message, subscription, vapid);</span><br><span class="line">        <span class="keyword">const</span> res = <span class="keyword">await</span> <span class="title function_">fetch</span>(subscription_data.<span class="property">endpoint</span>, req_data);</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">Response</span>(<span class="title class_">JSON</span>.<span class="title function_">stringify</span>(&#123; <span class="attr">success</span>: <span class="literal">true</span>, <span class="attr">result</span>: res.<span class="property">status</span> &#125;), &#123;</span><br><span class="line">            <span class="attr">headers</span>: &#123;</span><br><span class="line">                ...corsHeaders,</span><br><span class="line">                <span class="string">&#x27;Content-Type&#x27;</span>: <span class="string">&#x27;application/json&#x27;</span></span><br><span class="line">            &#125;</span><br><span class="line">        &#125;);</span><br><span class="line">    &#125; <span class="keyword">catch</span> (error) &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">Response</span>(<span class="title class_">JSON</span>.<span class="title function_">stringify</span>(&#123;</span><br><span class="line">            <span class="attr">success</span>: <span class="literal">false</span>,</span><br><span class="line">            <span class="attr">error</span>: error.<span class="property">message</span></span><br><span class="line">        &#125;), &#123;</span><br><span class="line">            <span class="attr">status</span>: <span class="number">500</span>,</span><br><span class="line">            <span class="attr">headers</span>: &#123;</span><br><span class="line">                ...corsHeaders,</span><br><span class="line">                <span class="string">&#x27;Content-Type&#x27;</span>: <span class="string">&#x27;application/json&#x27;</span></span><br><span class="line">            &#125;</span><br><span class="line">        &#125;);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 处理 GET 请求</span></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">handleGet</span>(<span class="params"><span class="attr">request</span>: <span class="title class_">Request</span>, <span class="attr">env</span>: <span class="built_in">any</span></span>): <span class="title class_">Promise</span>&lt;<span class="title class_">Response</span>&gt; &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">Response</span>(<span class="title class_">JSON</span>.<span class="title function_">stringify</span>(&#123; <span class="attr">about</span>: <span class="string">&quot;https://blog.hanlin.press/2024/12/Something-About-Web-Push-API&quot;</span>, <span class="attr">publicKey</span>: env.<span class="property">VAPID_PUB</span> &#125;), &#123;</span><br><span class="line">        <span class="attr">headers</span>: &#123;</span><br><span class="line">            ...corsHeaders,</span><br><span class="line">            <span class="string">&#x27;Content-Type&#x27;</span>: <span class="string">&#x27;application/json&#x27;</span></span><br><span class="line">        &#125;</span><br><span class="line">    &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> &#123;</span><br><span class="line">    <span class="keyword">async</span> <span class="title function_">fetch</span>(<span class="attr">request</span>: <span class="title class_">Request</span>, <span class="attr">env</span>: <span class="built_in">any</span>, <span class="attr">ctx</span>: <span class="built_in">any</span>): <span class="title class_">Promise</span>&lt;<span class="title class_">Response</span>&gt; &#123;</span><br><span class="line">        <span class="keyword">if</span> (request.<span class="property">method</span> === <span class="string">&#x27;OPTIONS&#x27;</span>) &#123;</span><br><span class="line">            <span class="keyword">return</span> <span class="title function_">handleOptions</span>(request);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">if</span> (request.<span class="property">method</span> === <span class="string">&#x27;POST&#x27;</span>) &#123;</span><br><span class="line">            <span class="keyword">return</span> <span class="title function_">handleSubscribe</span>(request, env);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">if</span> (request.<span class="property">method</span> === <span class="string">&#x27;GET&#x27;</span>) &#123;</span><br><span class="line">            <span class="keyword">return</span> <span class="title function_">handleGet</span>(request, env);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">return</span> <span class="keyword">new</span> <span class="title class_">Response</span>(<span class="string">&#x27;Method not allowed&#x27;</span>, &#123; <span class="attr">status</span>: <span class="number">405</span> &#125;);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">/*</span></span><br><span class="line"><span class="comment">For Wrangler Deploy</span></span><br><span class="line"><span class="comment">name = &quot;web-push-demo&quot;</span></span><br><span class="line"><span class="comment">compatibility_flags = [ &quot;nodejs_compat&quot; ]</span></span><br><span class="line"><span class="comment">compatibility_date = &quot;2024-09-23&quot;</span></span><br><span class="line"><span class="comment">main = &quot;index.ts&quot;</span></span><br><span class="line"><span class="comment">*/</span></span><br></pre></td></tr></table></figure><p>为了简化这一流程，我们此处选择使用已经公开使用的 <a href="https://github.com/block65/webcrypto-web-push">webcrypto-web-push</a> 库来完成消息推送的底层流程。这个库已经实现了上述的加密流程，我们只需要提供 VAPID 密钥对即可。    </p><p>什么，你问我为什么不用看上去用户更广泛的 <a href="https://github.com/web-push-libs/web-push">web-push</a> 库？<br>很遗憾，截至写稿测试时，Cloudflare Worker 尚未对 Crypto 提供完整的支持，使用 <code>web-push</code> 会提示 <code>crypto.createECDH is not implemented yet!&quot;</code> 错误，因此只能使用替代的 WebCrypto 实现。<br>与此相关的催更区：<a href="https://github.com/cloudflare/workerd/discussions/2692">https://github.com/cloudflare/workerd/discussions/2692</a>    </p><p>把这个干扰因素撇开，这段代码的意图非常简明：接收用户的订阅请求然后推送一条消息。<code>web-push</code> 的解析也没有多余流程，我们将上文获得的 <code>subscription</code> JSON 原路 POST 上去就好。   </p><hr><p>在 <code>wrangler.toml</code> 配置好 VAPID 密钥对后，我们便可以正式地将这个 Service Worker 注册到浏览器中，然后前端的配置就变成了这样：<br>（<del>有心的观众</del>可能会发现这就是篇首按钮对应的源代码）</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><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><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 如果按上面配置了模拟服务器，可以使用下面的代码获取公钥</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">getPublicKeyOnline</span>(<span class="params">serverUrl</span>) &#123;</span><br><span class="line">    <span class="keyword">try</span> &#123;</span><br><span class="line">        <span class="keyword">const</span> response = <span class="keyword">await</span> <span class="title function_">fetch</span>(serverUrl, &#123;</span><br><span class="line">            <span class="attr">method</span>: <span class="string">&#x27;GET&#x27;</span>,</span><br><span class="line">            <span class="attr">headers</span>: &#123;</span><br><span class="line">                <span class="string">&#x27;Content-Type&#x27;</span>: <span class="string">&#x27;application/json&#x27;</span></span><br><span class="line">            &#125;</span><br><span class="line">        &#125;);</span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> (!response.<span class="property">ok</span>) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">&#x27;Terrible Net :(&#x27;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">const</span> data = <span class="keyword">await</span> response.<span class="title function_">json</span>();</span><br><span class="line">        <span class="keyword">return</span> data.<span class="property">publicKey</span>;</span><br><span class="line">    &#125; <span class="keyword">catch</span> (error) &#123;</span><br><span class="line">        <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;Failed to fetch public key:&#x27;</span>, error);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">function</span> <span class="title function_">pushNotification</span>(<span class="params"></span>) &#123;</span><br><span class="line">    <span class="keyword">if</span> (<span class="string">&#x27;serviceWorker&#x27;</span> <span class="keyword">in</span> navigator &amp;&amp; <span class="string">&#x27;PushManager&#x27;</span> <span class="keyword">in</span> <span class="variable language_">window</span>) &#123;</span><br><span class="line">      <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;Service Worker and Push API is supported&#x27;</span>);</span><br><span class="line"></span><br><span class="line">      navigator.<span class="property">serviceWorker</span>.<span class="title function_">register</span>(<span class="string">&#x27;/sw.js&#x27;</span>)</span><br><span class="line">        .<span class="title function_">then</span>(<span class="keyword">function</span> (<span class="params">swReg</span>) &#123;</span><br><span class="line">          <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;Service Worker is registered&#x27;</span>, swReg);</span><br><span class="line">          swRegistration = swReg;</span><br><span class="line">          swRegistration.<span class="title function_">update</span>(); <span class="comment">// 强制更新 Service Worker</span></span><br><span class="line">          <span class="title function_">getPublicKeyOnline</span>(<span class="string">&#x27;配置好的 Cloudflare Worker 地址&#x27;</span>).<span class="title function_">then</span>(<span class="keyword">function</span> (<span class="params">publicKey</span>) &#123;</span><br><span class="line">            swRegistration.<span class="property">pushManager</span>.<span class="title function_">subscribe</span>(&#123;</span><br><span class="line">              <span class="attr">userVisibleOnly</span>: <span class="literal">true</span>,</span><br><span class="line">              <span class="attr">applicationServerKey</span>: <span class="title function_">base64UrlToUint8Array</span>(publicKey)</span><br><span class="line">            &#125;).<span class="title function_">then</span>(<span class="keyword">function</span> (<span class="params">subscription</span>) &#123;</span><br><span class="line">              <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;User is subscribed:&#x27;</span>, subscription);</span><br><span class="line">              <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="title class_">JSON</span>.<span class="title function_">stringify</span>(subscription));</span><br><span class="line">              <span class="title function_">fetch</span>(<span class="string">&#x27;配置好的 Cloudflare Worker 地址&#x27;</span>, &#123;</span><br><span class="line">                <span class="attr">method</span>: <span class="string">&#x27;POST&#x27;</span>,</span><br><span class="line">                <span class="attr">headers</span>: &#123;</span><br><span class="line">                  <span class="string">&#x27;Content-Type&#x27;</span>: <span class="string">&#x27;application/json&#x27;</span></span><br><span class="line">                &#125;,</span><br><span class="line">                <span class="attr">body</span>: <span class="title class_">JSON</span>.<span class="title function_">stringify</span>(subscription)</span><br><span class="line">              &#125;)</span><br><span class="line">                .<span class="title function_">then</span>(<span class="keyword">function</span> (<span class="params">response</span>) &#123;</span><br><span class="line">                  <span class="keyword">if</span> (!response.<span class="property">ok</span>) &#123;</span><br><span class="line">                    <span class="keyword">throw</span> <span class="keyword">new</span> <span class="title class_">Error</span>(<span class="string">&#x27;Request failed :(&#x27;</span>);</span><br><span class="line">                  &#125;</span><br><span class="line">                  <span class="variable language_">console</span>.<span class="title function_">log</span>(response);</span><br><span class="line">                  <span class="keyword">return</span> response.<span class="title function_">json</span>();</span><br><span class="line">                &#125;)</span><br><span class="line">                .<span class="title function_">then</span>(<span class="keyword">function</span> (<span class="params">data</span>) &#123;</span><br><span class="line">                  <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;Push notification sent successfully:&#x27;</span>, data);</span><br><span class="line">                &#125;)</span><br><span class="line">                .<span class="title function_">catch</span>(<span class="keyword">function</span> (<span class="params">error</span>) &#123;</span><br><span class="line">                  <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;Error sending push notification:&#x27;</span>, error);</span><br><span class="line">                &#125;);</span><br><span class="line">            &#125;).<span class="title function_">catch</span>(<span class="keyword">function</span> (<span class="params">error</span>) &#123;</span><br><span class="line">              <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;Failed to subscribe:&#x27;</span>, error);</span><br><span class="line">            &#125;);</span><br><span class="line">          &#125;)</span><br><span class="line">        &#125;)</span><br><span class="line">        .<span class="title function_">catch</span>(<span class="keyword">function</span> (<span class="params">error</span>) &#123;</span><br><span class="line">          <span class="variable language_">console</span>.<span class="title function_">error</span>(<span class="string">&#x27;Service Worker Error&#x27;</span>, error);</span><br><span class="line">        &#125;);</span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">      <span class="title function_">alert</span>(<span class="string">&quot;您的访问环境不支持 Service Worker 或 Push API :(&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>很快啊，点击完按钮后小卡一下，控制台就收到了响应：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241205174420759.png"/></div></div><p>由此亦可见，Web Push 给了 Service Worker 非常大的自由度，Service Worker 仅需要监听 <code>push</code> 事件，然后自己完成面向用户的推送逻辑即可。纯文本能推，Base64 也能推，消息相关的内容能推，消息之外添加点追踪信息也能推。只需要监听相关事件，然后完成对应的操作，Service Worker 就能在生命周期内完成推送的全部流程。</p><h3 id="最终的收尾"><a href="#最终的收尾" class="headerlink" title="最终的收尾"></a>最终的收尾</h3><p>迄今为止，<code>Application Server</code> 到 <code>Push Service</code> 的链条已经准备就绪，接下来该让 <code>Service Worker</code> 放弃摸鱼，开始真正工作了。<br>我们首先处理从收到通知到显示通知这一步的动作，这一步通过调用 <code>showNotification</code> 完成。</p><p>根据 <a href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification#parameters">MDN</a> 的参数描述，我们常用的用于显示通知的参数无非是 <code>title</code> 、<code>body</code> 、 <code>icon</code> 和 <code>badge</code> 这四种，分别为标题、内容、图标（与标题一同显示的图标）和徽标（显示不了标题时显示的图标）。<br>同时，如果需要考虑点击事件的话，还需要在通知对象上添加 <code>data</code> 对象，这个对象会在点击通知时传递给 <code>notificationclick</code> 事件。</p><p>然后我们就可以把 Service Worker 改写成这样：</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></pre></td><td class="code"><pre><span class="line">self.<span class="title function_">addEventListener</span>(<span class="string">&#x27;push&#x27;</span>, <span class="keyword">function</span> (<span class="params">event</span>) &#123;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;[Service Worker] Push message received&#x27;</span>, event);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">const</span> data = event.<span class="property">data</span>.<span class="title function_">json</span>();</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;[Service Worker] Push message data&#x27;</span>, data);</span><br><span class="line"></span><br><span class="line">    <span class="keyword">const</span> title = data.<span class="property">title</span>;</span><br><span class="line">    <span class="keyword">const</span> options = &#123;</span><br><span class="line">        <span class="attr">body</span>: data.<span class="property">body</span>,</span><br><span class="line">        <span class="attr">icon</span>: data.<span class="property">icon</span>,</span><br><span class="line">        <span class="attr">badge</span>: data.<span class="property">icon</span>,</span><br><span class="line">        <span class="attr">data</span>: data</span><br><span class="line">    &#125;;</span><br><span class="line"></span><br><span class="line">    event.<span class="title function_">waitUntil</span>(self.<span class="property">registration</span>.<span class="title function_">showNotification</span>(title, options));</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><p>注意此处的 <code>event.waitUntil</code> ，这个方法会让 Service Worker 保持活跃直到 <code>showNotification</code> 完成，这样可以保证整个通知的处理流程不会被中断。</p><p>这时再回头点击一下推送按钮：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241205182049768.png"/></div></div><p>Firefox 里也能正常触发推送：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241205182203249.png"/></div></div><p>接下来处理点击通知事件，这里需要添加一个新的 <code>notificationclick</code> 事件监听器。</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></pre></td><td class="code"><pre><span class="line">self.<span class="title function_">addEventListener</span>(<span class="string">&#x27;notificationclick&#x27;</span>, <span class="keyword">function</span> (<span class="params">event</span>) &#123;</span><br><span class="line">    <span class="variable language_">console</span>.<span class="title function_">log</span>(<span class="string">&#x27;[Service Worker] Notification click Received.&#x27;</span>);</span><br><span class="line"></span><br><span class="line">    event.<span class="property">notification</span>.<span class="title function_">close</span>();</span><br><span class="line">    event.<span class="title function_">waitUntil</span>(</span><br><span class="line">        clients.<span class="title function_">openWindow</span>(event.<span class="property">notification</span>.<span class="property">data</span>.<span class="property">link</span>)</span><br><span class="line">    ); <span class="comment">// data 是在 push 事件里传递过来的</span></span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><p>当然，根据 <a href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/notificationclick_event">MDN</a> 文档，可以继续实现一个正常 IM 都会做的功能：检测当前页面是否已经打开，如果已经打开则直接切换到该页面，而不是打开新的页面。此处不再赘述。</p><p>退订操作可调用 <code>pushManager.getSubscription()</code> 后调用 <code>unsubscribe()</code> 方法完成，此处不再重复实现，更建议读者在阅读完本篇文章后直接去 F12 点击 Unregister 按钮，给自己的浏览器少一些微小的负担。   </p><p>至此，整个 Web Push 的简易流程已经复现完毕，我们的 Service Worker 已经能够接收到推送消息并显示通知，同时也能够在点击通知时打开对应的页面。这些设计作为原型演示自然没有考虑边界情况，如果真的想在生产环境使用 Web Push 的话，可以去大厂如 <a href="https://github.com/OneSignal/OneSignal-Website-SDK/blob/dc853f9a85864e2d0bcd708066a0f633eba48485/src/sw/serviceWorker/ServiceWorker.ts">OneSignal</a> 的仓库抄作业，或者直接选择调用他们的 SDK。   </p><h2 id="理想是美好的"><a href="#理想是美好的" class="headerlink" title="理想是美好的"></a>理想是美好的</h2><p>作为一篇 Web Push 的入门文章，如果要达到科普用途的话，写到这里就足够了。但是读到这里的读者可能都有一些疑问：Web Push 这么好，为什么在我的周围的应用实例好像又不是很多呢？  </p><h3 id="房间里的大象们"><a href="#房间里的大象们" class="headerlink" title="房间里的大象们"></a>房间里的大象们</h3><p>首先是支持度问题，在 Chrome 与 Firefox 之外，一个绕不开的群体是使用 Safari 浏览器的苹果用户。<br>苹果在 <a href="https://developer.apple.com/videos/play/wwdc2022/10098/">WWDC22</a> 才正式宣布拥抱更通用的 Web Push 协议，这意味着什么呢？<br>在这个大会之前的版本，仍旧需要使用苹果自己的私有 API <code>window.safari​.pushNotification</code> 进行推送，而且按照<a href="https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/NotificationProgrammingGuideForWebsites/PushNotifications/PushNotifications.html">文档</a> ，苹果之前将这一段推送当作和苹果应用通知一样的推送看待，换句话说，就需要<strong>走苹果那套注册审核流程</strong>。<br>根据 <a href="https://caniuse.com/push-api">Can I use</a> 网站的介绍，macOS 13 之前的用户，Safari 16 之前的用户，不配享有 Web Push 大一统的服务。   </p><p>而另一头房间里的大象在抓包时就已经显现出来。<br>不少用户只要有过玩机经验，多少都会对 FCM 服务的不稳定有所耳闻。而倘若你是在一个<em>受限的网络环境</em>（你知道我在指哪里），你点击文首的 Demo 按钮后，<strong>即使点击了同意推送按钮，也并不会收到相关的推送通知</strong>。   </p><p>打开控制台，会发现通知推送处理流程停在了注册 Service Worker 一步：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241205192245825.png"/></div></div><p>这时候我们把目光投向抓包时发现的<code>android.clients.google.com</code> 域名，去一个<a href="https://www.boce.com/http/20241205_132ef30ae77373449dab6ab8e35b2d17.html">测速网站</a>测试，不难发现：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241205192431269.png"/></div></div><p>哈哈，这个域名<strong>整个大陆都上不去</strong>，最开始最开始的 <code>token</code> 申请都无法正常完成，更别谈之后的推送了。   </p><h3 id="看看其他浏览器"><a href="#看看其他浏览器" class="headerlink" title="看看其他浏览器"></a>看看其他浏览器</h3><p>由上述启发，我决定在 2024 年的终点，对现行流行的浏览器（包含不可不忽视的手机端）进行一个小调查。<br>并不是所有浏览器都能像 Chromium 一样可审查源代码，因此调查仅限于观察申请端点与推送端点，以及他们的可访问情况。   </p><p>笔者手头暂无苹果宣布开放之后的相关设备，故针对 Safari 浏览器的调查暂时无法进行，感兴趣的读者可在评论区提供反馈，可用于测试的 Demo 都在上面。  </p><ul><li><p>Firefox</p><ul><li>申请端点：<code>https://push.services.mozilla.com/</code> 大陆可访问但速度较慢，部分地区访问失败</li><li>推送端点：<code>https://updates.push.services.mozilla.com/wpush/v2/</code> 大陆可访问但速度较慢，部分地区访问失败</li></ul></li><li><p>Edge</p><ul><li>复用了 <a href="https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/windows-push-notification-services--wns--overview">Windows Push Notification</a> 服务(WNS)，未在抓包中直接发现申请端点。</li><li>推送端点：<code>https://wns2-sg2p.notify.windows.com/w/</code> 大陆访问性说得过去</li></ul></li><li><p>ungoogled-chromium<br>未提示浏览环境不支持，但是同样无法进行订阅操作，行为同大陆环境下的 Chrome 一致，推测是断掉与 GCM 的连接导致。</p></li><li><p>Yandex Browser</p><ul><li>申请端点：<code>https://android.clients.google.com/c2dm/register3</code> 大陆<strong>上不去</strong>。</li><li>推送端点：<code>https://fcm.googleapis.com/fcm/send/</code> </li><li>与 Chrome Windows 的区别：<code>app</code> 变成了 <code>com.yandex.windows</code></li><li>以及小细节：<code>https://android.clients.google.com/c2dm/register3</code> 被请求了两遍，第一次的 <code>app</code> 被设置成了 <code>com.google.android.gms</code> ，得到了 <code>Error=DEPRECATED_ENDPOINT</code> 的回应，第二次才和 Chrome 浏览器一样，然后改成了 <code>com.yandex.windows</code> ，不过 <code>X-subtype</code> 仍然是 <code>wp:</code> 开头。</li></ul></li><li><p>Opera</p><ul><li><strong>干了和 Yandex 一模一样的事情</strong>，申请端点和推送端点都和 Chrome 一样，只是 <code>app</code> 变成了 <code>com.opera.windows</code>， <code>X-subtype</code> 仍然是 <code>wp:</code> 开头。</li><li>也同样把 <code>https://android.clients.google.com/c2dm/register3</code> 请求了两遍，乐。</li></ul></li><li><p>360 安全浏览器</p><ul><li><strong>干了和 Yandex 和 Opera 一模一样的事情</strong>， <code>app</code> 变成了 <code>cn.360chrome.windows</code>， <code>X-subtype</code> 仍然是 <code>wp:</code> 开头。</li><li>也同样把 <code>https://android.clients.google.com/c2dm/register3</code> 请求了两遍，你的爱国情怀很差.jpg<div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241205210630673.png"/></div></div></li></ul></li><li><p>QQ 浏览器</p><ul><li>它的出厂默认设置<strong>禁止了所有网站发送通知</strong>。</li><li>手动去设置开启通知权限后可以弹框请求通知，但是<strong>无法进行订阅操作</strong>，即使网络环境没有问题。</li></ul></li><li><p><strong>Android</strong> Chrome</p><ul><li>与电脑端行为基本一致，除了 <code>app</code> 标签变成了 <code>com.android.chrome</code> </li><li>申请端点变成了 <code>https://android.apis.google.com/c2dm/register3</code> ，大陆<strong>依旧无法访问</strong>。请求还多来了这么几份参数：</li></ul><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/image-20241205202948772.png"/></div></div>   <ul><li>手机端的 Web Push 以浏览器为主体发送通知，大概长这样，还附赠一个退订按钮：</li></ul><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/c15f2d4fc072be73b7a20c83848be898.png"/></div></div>     </li><li><p>Android Firefox</p><ul><li>申请端点：<code>https://updates.push.services.mozilla.com/v1/fcm/boxwood-axon-825/registration</code> 大陆可访问性一如既往，看链接猜测申请了 FCM 服务</li><li>推送端点：<code>https://updates.push.services.mozilla.com/wpush/v2/</code> 大陆可访问但速度较慢，部分地区访问失败</li><li>推送来的通知长这样，没有退订按钮：<div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Something-About-Web-Push-API/663d4e4651c36a892d31bcfb919141e6.png"/></div></div></li></ul></li><li><p>Android Edge</p><ul><li>申请端点：<code>https://android.apis.google.com/c2dm/register3</code> <ul><li><strong>一开始觉得反常，后来寻思了下套壳很合理。<strong>毕竟 Edge 也是 Chromium 内核。但是大陆</strong>必定上不去</strong>。</li><li>与 Android Chrome 最大的不同之处在于， <code>X-subtype</code> 开头变成了 <code>edge-webpush:</code> ，以及 <code>app</code> 为 <code>com.microsoft.emmx</code></li></ul></li><li>推送端点：自然是 <code>https://fcm.googleapis.com/fcm/send/</code> </li><li>同样有退订按钮，但是翻译为「取消订阅」</li></ul></li><li><p>Android 夸克浏览器&#x2F;UC浏览器&#x2F;QQ浏览器&#x2F;小米浏览器<br><strong>提示当前浏览环境不支持 :(</strong></p></li><li><p>Android Samsung Browser (三星浏览器)<br>未提示当前浏览环境不支持，但是也同样无法进行订阅操作。</p></li></ul><h2 id="unregister"><a href="#unregister" class="headerlink" title="unregister();"></a>unregister();</h2><p>终于来到了真正的结语，我们有什么可以总结的呢？<br>理想是美好的，现实是非常残酷的。苹果、微软和 Firefox 自建了属于自己的服务器，但其中有些也仅在特定的平台启用。除此之外，其他厂商要么直接摆烂干掉相关的接口，要么逃不开套壳 Chromium 的本质，牢牢抱紧 GCM 的大腿，花样颇多，但都有一个共性：<strong>在大陆环境下根本无法访问</strong>。但即使退一万步讲，抛去网络环境的限制，目前各个厂商实现的参差不齐乃至不实现，也是一个绕不开的问题。    </p><p>综上所述，Web Push 的协议理想是好的，只是在 2024 年的终点，作为 PWA 应用的重要基建之一，它距离真正意义上的普及还有非常非常长的路要走，起码在大陆的网络环境下判了死刑，国产的浏览器也无法幸免。</p>]]></content>
    
    
    <summary type="html">从最底层的链条，到顶层房间里的大象，再到主流浏览器的亲手测试，本文尽可能地展现了 Web Push API 的基本流程，以及不太可观的未来愿景。</summary>
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="前端" scheme="https://blog.hanlin.press/tags/%E5%89%8D%E7%AB%AF/"/>
    
    <category term="Web" scheme="https://blog.hanlin.press/tags/Web/"/>
    
  </entry>
  
  <entry>
    <title>HTP 笑传：扔掉 UDP，试试并不特殊的低精度时间同步</title>
    <link href="https://blog.hanlin.press/2024/10/HTP-The-HTTP-Time-Protocol/"/>
    <id>https://blog.hanlin.press/2024/10/HTP-The-HTTP-Time-Protocol/</id>
    <published>2024-10-16T12:40:54.000Z</published>
    <updated>2024-10-17T01:02:54.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="万恶之源"><a href="#万恶之源" class="headerlink" title="万恶之源"></a>万恶之源</h2><p>众所周知，<strong>NTP</strong>（<strong>N</strong>etwork <strong>T</strong>ime <strong>P</strong>rotocol）是互联网上最古老的一个协议之一，以下是维基百科对这个条目的介绍：</p><div class="tag-plugin colorful note" ><div class="title">中文 Wikipedia 对于 NTP 协议的解释</div><div class="body"><p>网络时间协议（英语：Network Time Protocol，缩写：NTP）是在数据网络潜伏时间可变的计算机系统之间通过分组交换进行时钟同步的一个网络协议，位于OSI模型的应用层。自1985年以来，NTP是目前仍在使用的最古老的互联网协议之一。NTP由特拉华大学的大卫·米尔斯（David L. Mills）设计。<br> NTP意图将所有参与计算机的协调世界时（UTC）时间同步到几毫秒的误差内。它使用Marzullo算法的修改版来选择准确的时间服务器，其设计旨在减轻可变网络延迟造成的影响。NTP通常可以在公共互联网保持几十毫秒的误差，并且在理想的局域网环境中可以实现超过1毫秒的精度。不对称路由和拥塞控制可能导致100毫秒（或更高）的误差。<br> 该协议通常描述为一种主从式架构，但它也可以用在点对点网络中，对等体双方可将另一端认定为潜在的时间源。<strong>发送和接收时间戳采用用户数据报协议（UDP）的端口123实现</strong>。这也可以使用广播或多播，其中的客户端在最初的往返校准交换后被动地监听时间更新。NTP提供一个即将到来闰秒调整的警告，但不会传输有关本地时区或夏时制的信息。</p></div></div><p>注意观察如上加粗的一行字：NTP 基于 UDP 协议实现。  </p><p>而众所周知，国内对于 UDP 协议的劣化又不是一天两天了，因为网管不会配网&#x2F;防火墙&#x2F;故意等原因，在网络栈中直接干掉 UDP 协议包的情况也大有人在。</p><p>于是在今天午后：TUNA 技术群里出现了这样一幕：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/HTP-The-HTTP-Time-Protocol/image-20241016205828316.png"/></div></div><p>拿这个当引子：</p><ul><li><p>如果仅讨论不用 UDP 实现时间同步的方法，议题范围未免有些过大。最常见的流派是「新协议派」，能想到的方式放在文末。</p></li><li><p>不妨从更简单又 Dirty 的方面出发，把 NTP 改名为 <strong>HTP</strong>（<strong>H</strong>TTP <strong>T</strong>ime <strong>P</strong>rotocol），梳理一下通过 HTTP 同步时间的几种脏玩法。</p></li></ul><p>在此预先再放个引子，在下述方法的说明中不再赘述：</p><ul><li>因为在进行时间同步时，我们应当默认被操作服务器的时钟是爆炸的，由此也能推断：<strong>HTTPS 里的证书有效期验证多半也是爆炸的</strong>。</li><li>理论上我们可以通过 <code>curl -k</code> 等方式绕过证书验证正常发送请求，但这对于某些<em>关心请求纯洁性</em>的人可能就有些抵触。</li><li>但又换个角度来想，通过 HTTPS 协议，其中的握手和加密时间无形中也是产生延迟的损耗，这么看可能还是 HTTP 更低延迟些。到服务器的 Ping 应该也要考虑在内。</li><li>但话又说回来，这就是篇整活的文章，还是不想 HTTP 和 HTTPS 的事了吧。</li></ul><h2 id="Header-里的-Date"><a href="#Header-里的-Date" class="headerlink" title="Header 里的 Date"></a>Header 里的 Date</h2><p><code>Date</code> Header 作为一个上古规范，被定义在了 <a href="https://httpwg.org/specs/rfc9110.html#field.date">RFC9110</a> 里，并被赋予了较严格的实践规范：</p><ul><li>在 Fetch 里，它被定义成<a href="https://fetch.spec.whatwg.org/#terminology-headers">禁止修改的标头</a> ，人工构建的 Date 标头在符合规范的客户端中会被丢弃。</li><li>在 RFC 规范里，它被要求设置为服务器所获取的最为精确的时间，且不允许人工生成。</li></ul><p>由此来看，从 HTTP Header 中拿时间是一种很容易想到的方式。</p><p>这个方案适用于互联网世界上的大部分服务器，而且无关乎 HTTP 和 HTTPS，以本站为例，就连强制转 HTTPS 的 301 报文里都有 <code>Date</code> ：</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><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><span class="line">$ curl -vvv http://blog.hanlin.press</span><br><span class="line">* Host blog.hanlin.press:80 was resolved.</span><br><span class="line">* Connected to blog.hanlin.press port 80</span><br><span class="line">&gt; GET / HTTP/1.1</span><br><span class="line">&gt; Host: blog.hanlin.press</span><br><span class="line">&gt; User-Agent: curl/8.9.1</span><br><span class="line">&gt; Accept: */*</span><br><span class="line">&gt;</span><br><span class="line">* Request completely sent off</span><br><span class="line">&lt; HTTP/1.1 301 Moved Permanently</span><br><span class="line">&lt; Server: nginx</span><br><span class="line">&lt; Date: Wed, 16 Oct 2024 16:40:17 GMT</span><br><span class="line">&lt; Content-Type: text/html</span><br><span class="line">&lt; Content-Length: 162</span><br><span class="line">&lt; Connection: keep-alive</span><br><span class="line">&lt; Location: https://blog.hanlin.press/</span><br></pre></td></tr></table></figure><p>继续以本站为例，使用如下指令就可以提取 <code>Date</code> 标头里的时间：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">curl --<span class="built_in">head</span> -s http://blog.hanlin.press | grep -i <span class="string">&quot;Date: &quot;</span> | <span class="built_in">cut</span> -d<span class="string">&#x27; &#x27;</span> -f2-</span><br></pre></td></tr></table></figure><p>而 <code>date -s</code> 可以用于从字符串设置时间：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">-s, --<span class="built_in">set</span>=STRING           <span class="built_in">set</span> time described by STRING</span><br></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><span class="line"><span class="built_in">date</span> -s <span class="string">&quot;`curl --head -s http://blog.hanlin.press | grep -i &quot;</span>Date: <span class="string">&quot; | cut -d&#x27; &#x27; -f2-`&quot;</span></span><br></pre></td></tr></table></figure><hr><p>针对这个奇葩需求，GitHub 上还有人搞了个叫 <a href="https://github.com/twekkel/htpdate"><code>htpdate</code></a> 的项目，把上述命令可能会出现的问题（比如说原服务不可用）擦了个屁股，还支持多个服务器，README 最后还有一串衍生项目，可以关注一下。</p><h2 id="借力-Cloudflare"><a href="#借力-Cloudflare" class="headerlink" title="借力 Cloudflare"></a>借力 Cloudflare</h2><p>本段灵感同样来自上文截图。</p><p>凡是使用了 Cloudflare 的 CDN，都会有一个 <code>/cdn-cgi/trace</code> 的接口，从他们自家的 <code>1.1.1.1</code> DNS，到他们的合作产品，通通都有覆盖。</p><p>根据 <a href="https://github.com/fawazahmed0/cloudflare-trace-api">GitHub 上的分析</a> ，  <code>/cdn-cgi/trace</code> 所包含的信息主要有如下这些：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><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><span class="line">fl=Cloudflare WebServer Instance</span><br><span class="line">h=WebServer Hostname</span><br><span class="line">ip=IP Address of client</span><br><span class="line">ts=Epoch Time in seconds.millis (Similar to `date +%s.%3N` in bash)</span><br><span class="line">visit_scheme=https or http</span><br><span class="line">uag=User Agent</span><br><span class="line">colo=IATA location identifier</span><br><span class="line">sliver=Whether the request is splitted</span><br><span class="line">http=HTTP Version</span><br><span class="line">loc=Country Code</span><br><span class="line">tls=TLS or SSL Version</span><br><span class="line">sni=Whether SNI encrypted or plaintext</span><br><span class="line">warp=Whether client over Cloudflares Wireguard VPN</span><br><span class="line">gateway=Whether client over Cloudflare Gateway</span><br><span class="line">rbi=Whether client over Cloudflares Remote Browser Isolation</span><br><span class="line">kex=Key exchange method for TLS</span><br></pre></td></tr></table></figure><p>在这里我们要使用的，便是上面的 <code>ts</code> 选项，如上所述，他提供了毫秒级的时间戳，也可以喂给 <code>date -s</code> 校准用（时间戳的话前面需要加个 <code>@</code>）。</p><p>通过这个方法，大部分情况下就要 HTTPS 派了，因为这个需要获取实际内容，而套了 Cloudflare 的网站一般都伴随着强制 HTTPS 的，但是别急，采用了 Cloudflare 的网站一抓一大把，我们来在北方移动的网络下一个个溜一下：</p><ul><li>Cloudflare DNS：<ul><li><a href="http://1.1.1.1/cdn-cgi/trace"><code>1.1.1.1</code></a> ：平均延迟 86ms，有强制 HTTPS。</li><li><a href="http://1.0.0.1/cdn-cgi/trace"><code>1.0.0.1</code></a> ：平均延迟 202ms，有强制 HTTPS。</li></ul></li><li>采用了 Cloudflare 的典型大客户 <a href="http://japan.com/cdn-cgi/trace"><code>japan.com</code></a>：平均延迟 162ms，<strong>无强制 HTTPS</strong>。</li><li>Cloudflare 官网 <a href="http://www.cloudflare.com/cdn-cgi/trace"><code>www.cloudflare.com</code></a> ：平均延迟 171ms，<strong>无强制 HTTPS</strong>。</li><li><strong>Cloudflare 与京东云合作的京东云星盾</strong>：<ul><li>Cloudflare 中国官网  <a href="http://www.cloudflare-cn.com/cdn-cgi/trace"><code>www.cloudflare-cn.com</code></a> ：<strong>平均延迟 22ms</strong>，<strong>无强制 HTTPS</strong>。</li><li>Cloudflare <a href="https://blog.cloudflare.com/zh-cn/upgrading-the-cloudflare-china-network/">China Network</a> 接入所用 NS  <a href="http://www.cf-ns.com/cdn-cgi/trace"><code>www.cf-ns.com</code></a> ：<strong>平均延迟 17ms</strong>，<strong>无强制 HTTPS</strong>。<ul><li>在备案号<code>京ICP备2020045912号</code>下还有一车神奇域名，请举一反三。</li><li>值得注意的是， <code>cf-ns.com</code> 没接入 Cloudflare。</li></ul></li><li>京东云方的<em>京东云星盾</em>业务：<ul><li>京东云自有「京美建站」<a href="http://manage.jdcloudsite.com/cdn-cgi/trace"><code>manage.jdcloudsite.com</code></a>：平均延迟 48ms，<strong>无强制 HTTPS</strong>。</li></ul></li></ul></li></ul><p>由此可见，在国内使用 Cloudflare 系进行时间同步的话，使用其国内业务是最优之选。</p><p>以上面最快的 <code>www.cf-ns.com</code> 为例，运行这个命令便可以得到一个毫秒级的时间戳：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">curl -s www.cf-ns.com/cdn-cgi/trace | grep -oP <span class="string">&#x27;(?&lt;=ts=)\d+\.\d+&#x27;</span></span><br></pre></td></tr></table></figure><p>前加 <code>@</code> 即可完成时间更新：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">date</span> -s <span class="string">&quot;@`curl -s www.cf-ns.com/cdn-cgi/trace | grep -oP &#x27;(?&lt;=ts=)\d+\.\d+&#x27;`&quot;</span></span><br></pre></td></tr></table></figure><p>这时候有的观众可能就要问了，Cloudflare 的上一个合作商百度云加速现在是什么状态呢？</p><p>很不幸，他们似乎自己搓了一套：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><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><span class="line">curl -vvv http://su.baidu.com/cdn-cgi/trace</span><br><span class="line">* Host su.baidu.com:80 was resolved.</span><br><span class="line">* Request completely sent off</span><br><span class="line">&lt; HTTP/1.1 200 OK</span><br><span class="line">&lt; Server: yunjiasu</span><br><span class="line">&lt; Date: Wed, 16 Oct 2024 18:49:27 GMT</span><br><span class="line">&lt; Content-Type: text/plain</span><br><span class="line">&lt; Transfer-Encoding: chunked</span><br><span class="line">&lt; Connection: keep-alive</span><br><span class="line">&lt;</span><br><span class="line">s=12702</span><br><span class="line">h=su.baidu.com</span><br><span class="line">t=2024-10-17 02:49:27</span><br><span class="line">v=HTTP/1.1</span><br><span class="line">ua=curl/8.9.1</span><br></pre></td></tr></table></figure><h2 id="还是看看购物网站们吧"><a href="#还是看看购物网站们吧" class="headerlink" title="还是看看购物网站们吧"></a>还是看看购物网站们吧</h2><p>本段的灵感在于我看完楼上的消息后，打开了<a href="http://www.ntsc.cas.cn/">中国科学院国家授时中心</a>的官网，发现页面下方有个显示「北京时间」的区域：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/HTP-The-HTTP-Time-Protocol/image-20241017021515454.png"/></div></div><p>俺寻思：这里的北京时间应该不是从本地获取的吧？说不定授时中心自己有套返回时间的 API 呢？</p><p>然后使用 F12 打开页面看源码，看一眼后当场去世：</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><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></pre></td><td class="code"><pre><span class="line"><span class="comment">// var ws = new WebSocket(&quot;ws://223.0.12.10/time&quot;);</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// ws.onopen = function(evt) &#123;  //绑定连接事件</span></span><br><span class="line"><span class="comment">// 　　console.log(&quot;Connection open ...&quot;); </span></span><br><span class="line"><span class="comment">// &#125;;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// ws.onmessage = function(evt) &#123;//绑定收到消息事件</span></span><br><span class="line"><span class="comment">// $(&quot;#cTime&quot;).html(&#x27;&lt;span class=&quot;bdr4&quot;&gt;&#x27;+evt.data.substring(0,2)+&#x27;&lt;/span&gt;:&lt;span class=&quot;bdr4&quot;&gt;&#x27;+evt.data.substring(3,5)+&#x27;&lt;/span&gt;:&lt;span class=&quot;bdr4&quot;&gt;&#x27;+evt.data.substring(6,8)+&#x27;&lt;/span&gt;&#x27;);</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// &#125;;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// ws.onclose = function(evt) &#123; //绑定关闭或断开连接事件</span></span><br><span class="line"><span class="comment">// 　　console.log(&quot;Connection closed.&quot;);</span></span><br><span class="line"><span class="comment">// &#125;;</span></span><br><span class="line">    </span><br><span class="line">&#125;)</span><br><span class="line"><span class="keyword">function</span> <span class="title function_">getTime</span>(<span class="params"></span>)&#123;</span><br><span class="line">  $.<span class="title function_">ajax</span>(&#123;</span><br><span class="line">    <span class="attr">url</span>: <span class="string">&#x27;//quan.suning.com/getSysTime.do&#x27;</span>,</span><br><span class="line">    <span class="attr">type</span>: <span class="string">&#x27;GET&#x27;</span>,</span><br><span class="line">    <span class="attr">async</span>:<span class="literal">false</span>,</span><br><span class="line">    <span class="attr">dataType</span>: <span class="string">&#x27;json&#x27;</span>,</span><br><span class="line">  &#125;)</span><br><span class="line">  .<span class="title function_">done</span>(<span class="keyword">function</span>(<span class="params">res</span>) &#123;</span><br><span class="line">    <span class="keyword">var</span> date = <span class="keyword">new</span> <span class="title class_">Date</span>(res.<span class="property">sysTime2</span>);</span><br><span class="line">    <span class="keyword">var</span> hours = date.<span class="title function_">getHours</span>()&gt;<span class="number">9</span>?date.<span class="title function_">getHours</span>():(<span class="string">&#x27;0&#x27;</span>+date.<span class="title function_">getHours</span>());</span><br><span class="line">    <span class="keyword">var</span> minutes = date.<span class="title function_">getMinutes</span>()&gt;<span class="number">9</span>?date.<span class="title function_">getMinutes</span>():(<span class="string">&#x27;0&#x27;</span>+date.<span class="title function_">getMinutes</span>());</span><br><span class="line">    <span class="keyword">var</span> seconds = date.<span class="title function_">getSeconds</span>()&gt;<span class="number">9</span>?date.<span class="title function_">getSeconds</span>():(<span class="string">&#x27;0&#x27;</span>+date.<span class="title function_">getSeconds</span>());</span><br><span class="line">    $(<span class="string">&quot;#cTime&quot;</span>).<span class="title function_">html</span>(<span class="string">&#x27;&lt;span class=&quot;bdr4&quot;&gt;&#x27;</span>+hours+<span class="string">&#x27;&lt;/span&gt;:&lt;span class=&quot;bdr4&quot;&gt;&#x27;</span>+minutes+<span class="string">&#x27;&lt;/span&gt;:&lt;span class=&quot;bdr4&quot; id=&quot;seconds&quot;&gt;&#x27;</span>+seconds+<span class="string">&#x27;&lt;/span&gt;&#x27;</span>);</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;</span><br><span class="line"><span class="title function_">getTime</span>();</span><br><span class="line"><span class="keyword">var</span> seconds = <span class="string">&quot;&quot;</span>;</span><br><span class="line"><span class="keyword">var</span> thisSeconds =<span class="string">&quot;&quot;</span>;</span><br><span class="line"><span class="keyword">function</span> <span class="title function_">timeAdd</span>(<span class="params"></span>)&#123;</span><br><span class="line">  thisSeconds = <span class="title class_">Number</span>($(<span class="string">&quot;#seconds&quot;</span>).<span class="title function_">text</span>())+<span class="number">1</span>;</span><br><span class="line">  <span class="keyword">if</span>(thisSeconds==<span class="number">60</span>)&#123;</span><br><span class="line">    <span class="title function_">getTime</span>();</span><br><span class="line">  &#125;<span class="keyword">else</span>&#123;</span><br><span class="line">    seconds = thisSeconds&gt;<span class="number">9</span>?<span class="attr">thisSeconds</span>:<span class="string">&#x27;0&#x27;</span>+thisSeconds;</span><br><span class="line">    $(<span class="string">&quot;#seconds&quot;</span>).<span class="title function_">html</span>(seconds);</span><br><span class="line">  &#125;</span><br></pre></td></tr></table></figure><p>从源码中可以看出，它注释了一个看上去已经不可用的 WebSocket API（这个 IP 反查后为 <code>gdlhz.ac.cn</code> ，打开后是「先进能源科学与技术广东省实验室」），转而使用了一个来自于<strong>苏宁商城</strong>的 API <code>quan.suning.com/getSysTime.do</code>。</p><p>为白天发的推送校正一下，这个 API 并不吃 Referer 校验，他只是限流策略狠了一些，一秒访问一次。</p><p>它同样<strong>不强制 HTTPS</strong>，访问返回回来的信息长这样：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span><span class="attr">&quot;sysTime2&quot;</span><span class="punctuation">:</span><span class="string">&quot;2024-10-17 02:20:06&quot;</span><span class="punctuation">,</span><span class="attr">&quot;sysTime1&quot;</span><span class="punctuation">:</span><span class="string">&quot;20241017022006&quot;</span><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>然后授时中心的网站就用这玩意儿来显示时间，世界就是一个巨大的草台班子。</p><p>这就给我一个启示：购物网站类的秒杀类是最强调时间精确的，何不从这些领域找返回时间的 API 呢？</p><p>由此从搜索引擎和人工派整理了一些，供参考：</p><ul><li><p>苏宁的另一个 API：<code>http://f.m.suning.com/api/ct.do</code> <strong>平均延迟 10ms</strong>，秒级，<strong>无强制 HTTPS</strong>。</p></li><li><p>小米商城：<code>http://time.hd.mi.com/gettimestamp</code> <strong>平均延迟 15ms</strong>，秒级，<strong>无强制 HTTPS</strong>。</p></li><li><p>华为商城：<code>http://openapi.vmall.com/serverTime.json</code>  <strong>平均延迟 15ms</strong>，秒级，<strong>无强制 HTTPS</strong>。</p></li><li><p>拼多多：<code>http://api.pinduoduo.com/api/server/_stm</code>  <strong>平均延迟 21ms</strong>，<strong>毫秒级</strong>，<strong>无强制 HTTPS</strong>。</p></li><li><p>VIVO 商城：<code>http://mshopact.vivo.com.cn/tool/config</code> <strong>平均延迟 3ms</strong>，<strong>毫秒级</strong>，<strong>无强制 HTTPS</strong>。</p></li><li><p>腾讯视频：<code>http://vv.video.qq.com/checktime?otype=json</code> <strong>平均延迟 3ms</strong>，秒级，<strong>无强制 HTTPS</strong>。</p></li></ul><p>我们以上面看上去表现最好的 VIVO 商城为例，它的 API 格式大约长这样：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span><span class="attr">&quot;code&quot;</span><span class="punctuation">:</span><span class="number">200</span><span class="punctuation">,</span><span class="attr">&quot;msg&quot;</span><span class="punctuation">:</span><span class="string">&quot;正常!&quot;</span><span class="punctuation">,</span><span class="attr">&quot;data&quot;</span><span class="punctuation">:</span><span class="punctuation">&#123;</span><span class="attr">&quot;tempCookies&quot;</span><span class="punctuation">:</span><span class="string">&quot;&#123;\&quot;cookieId\&quot;:\&quot;xxx\&quot;,\&quot;fakeSessionId\&quot;:\&quot;xxx\&quot;&#125;&quot;</span><span class="punctuation">,</span><span class="attr">&quot;imgHostUrl&quot;</span><span class="punctuation">:</span><span class="string">&quot;https://shopact-vivofs.vivo.com.cn/campaign/&quot;</span><span class="punctuation">,</span><span class="attr">&quot;vivoShopUrlPrefix&quot;</span><span class="punctuation">:</span><span class="string">&quot;//shop.vivo.com.cn/wap&quot;</span><span class="punctuation">,</span><span class="attr">&quot;callAppSwitch&quot;</span><span class="punctuation">:</span><span class="string">&quot;0&quot;</span><span class="punctuation">,</span><span class="attr">&quot;nowTime&quot;</span><span class="punctuation">:</span><span class="number">1729104354677</span><span class="punctuation">,</span><span class="attr">&quot;prdHost&quot;</span><span class="punctuation">:</span><span class="string">&quot;//mshopact.vivo.com.cn&quot;</span><span class="punctuation">&#125;</span><span class="punctuation">,</span><span class="attr">&quot;success&quot;</span><span class="punctuation">:</span><span class="literal"><span class="keyword">true</span></span><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>我们所需要的是 <code>data</code> 里的 <code>nowTime</code> ，而这可以通过正则或是 <code>jq</code> 提取，我们在这里选简单的后者：</p><p>运行这个指令就可以得到一个毫秒级的时间戳：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">curl -s http://mshopact.vivo.com.cn/tool/config | jq .data.nowTime</span><br></pre></td></tr></table></figure><p><code>date -s</code> 不支持毫秒形式的时间戳，需要转换成秒级。直接按这种方式转换会丢失小数部分：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$[$(curl -s http://mshopact.vivo.com.cn/tool/config | jq .data.nowTime) / 1000]</span><br></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><span class="line">tms=$(curl -s http://mshopact.vivo.com.cn/tool/config | jq .data.nowTime); <span class="built_in">echo</span> <span class="variable">$&#123;tms:0:$((<span class="variable">$&#123;#tms&#125;</span>-3))&#125;</span>.<span class="variable">$&#123;tms:$((<span class="variable">$&#123;#tms&#125;</span>-3))&#125;</span></span><br></pre></td></tr></table></figure><p>合并上之前的 <code>date -s @</code>：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">date</span> -s <span class="string">&quot;@`tms=<span class="subst">$(curl -s http://mshopact.vivo.com.cn/tool/config | jq .data.nowTime)</span>; echo <span class="variable">$&#123;tms:0:$((<span class="variable">$&#123;#tms&#125;</span>-3))&#125;</span>.<span class="variable">$&#123;tms:$((<span class="variable">$&#123;#tms&#125;</span>-3))&#125;</span>`&quot;</span></span><br></pre></td></tr></table></figure><p>顺利在理论上的最短延迟内设置时间。</p><h2 id="Bonus"><a href="#Bonus" class="headerlink" title="Bonus"></a>Bonus</h2><p>回到文章最头的问题：如果我们不使用 HTTP 整活，有没有方法能够通过 TCP 的方式同步时间？</p><p>那肯定是有。</p><p><a href="https://datatracker.ietf.org/doc/html/rfc867">RFC 867 Daytime Protocol</a> 和 <a href="https://datatracker.ietf.org/doc/html/rfc868">RFC 868 Time Protocol</a> 都支持采用 TCP 来同步时间（但他们都只<strong>支持到秒</strong>），而 <a href="https://github.com/njh/rdate">rdate</a> 就是实现了后者 RFC868 的一个客户端。</p><p>目前最广为人知的公共服务是 <a href="https://www.nist.gov/pml/time-and-frequency-division/time-distribution/internet-time-service-its"><code>NIST.GOV</code></a> ，故可以通过 <code>time.nist.gov</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><span class="line">rdate -p time.nist.gov</span><br></pre></td></tr></table></figure><p>RFC868 运行在 37 端口，但很不幸，目前中文互联网上公开的服务少之又少，如果有了解相关信息的读者可以在评论区留言。</p><p>最后，感谢 「TUNA 技术群」集思广益提供的灵感。</p>]]></content>
    
    
    <summary type="html">从京东和购物网站出发，本文章真正的主题在于对互联网至今仍有的低成本拿时间的 API 进行一个抛砖引玉。</summary>
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="API" scheme="https://blog.hanlin.press/tags/API/"/>
    
    <category term="后端" scheme="https://blog.hanlin.press/tags/%E5%90%8E%E7%AB%AF/"/>
    
    <category term="NTP" scheme="https://blog.hanlin.press/tags/NTP/"/>
    
    <category term="时间同步" scheme="https://blog.hanlin.press/tags/%E6%97%B6%E9%97%B4%E5%90%8C%E6%AD%A5/"/>
    
  </entry>
  
  <entry>
    <title>切割，与世界上的另一个我</title>
    <link href="https://blog.hanlin.press/2024/09/Cutting-off-and-Another-ME/"/>
    <id>https://blog.hanlin.press/2024/09/Cutting-off-and-Another-ME/</id>
    <published>2024-09-28T17:07:56.000Z</published>
    <updated>2024-12-31T06:58:56.000Z</updated>
    
    <content type="html"><![CDATA[<ul><li>Updated on 2024&#x2F;10&#x2F;11 and 2024&#x2F;12&#x2F;31: 添加了 DLC 章节 <a href="#%E4%B8%80%E4%BA%9B%E9%A2%9D%E5%A4%96%E7%9A%84%E7%89%87%E6%AE%B5">一些额外的片段</a></li></ul><p>转眼间，2024已经过了一大半。本来这篇文章的命运是成为2024年的年终总结，但是最近发生的种种事又让我怀疑，到了年底是否还能对这些琐碎的碎片记得如此清晰。<br>而距离我写下上面这句话，又过去了整整三周时间，在这期间情绪得以降温，更多的思考也在和友人的聊天中产生。</p><p>那么下面，就是一些纯粹的关于「切割」的情绪废料。</p><h2 id="切割——社交的时尚单品"><a href="#切割——社交的时尚单品" class="headerlink" title="切割——社交的时尚单品"></a>切割——社交的时尚单品</h2><p>在 LLM 时代，我们只需要把「切割」这个词喂给大语言模型，就能得到一个比较全面的解释：</p><blockquote><p><strong>社交网络中的「切割」通常指什么意义？</strong></p><p><strong>Claude-3.5-Sonnet</strong>：</p><p>在社交网络语境中,「切割」通常指的是一个人或群体与另一个人或群体公开划清界限,表明不再有关联的行为。这个词在网络用语中有特定的含义:</p><ol><li>撇清关系：主动与某人或某事划清界限,表明自己与之无关。</li><li>断绝联系：终止与某人的社交关系,可能包括取消关注、删除好友等行为。</li><li>公开表态：通常通过公开声明或社交媒体发布来实现,让其他人知道自己的立场。</li><li>保护自己：有时是为了避免卷入争议或负面影响,保护自己的声誉和利益。</li><li>价值观分歧：可能源于对某事件、观点的不同看法,导致关系破裂。</li><li>群体行为：在一些群体事件中,可能会出现集体”切割”某个成员的情况。</li></ol><p>这种行为在现代社交网络中较为常见,尤其是在名人、公众人物或网络红人之间。”切割”往往反映了复杂的人际关系动态和社交网络的特性。</p><p>如果您想了解更多关于这个话题的细节或具体例子,我可以进一步解释。</p></blockquote><p>不知道是自己的错觉还是形势使然（亦或是年龄段使然），在这两年以来，「切割」行为似乎正愈来愈常见地发生在我的身边。    </p><p>撇去在网友之间众所周知的那次惊天动地的「切割」（You know what I mean），按时间顺序，我印象深刻的切割行为主要可分为如下几例：</p><ul><li>升学工作季，QQ 空间里留下一串微信加好友二维码，附字「本号不再使用，请加微信联系」，仿佛上岸后换用微信是成熟的标志。</li><li>老友的博客不再更新，再次访问时发现已经关闭评论，首页标了一行「Read only」，留言板上的留言也被清空，仿佛这个空间已经被封存。</li><li>深夜，QQ 插件弹出通知说「好友已被双向删除」，顺路搜过去QQ号，发现已经改昵称换头像，似乎这个人从未出现在我的生活之中一样。</li></ul><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>一个是切割本身，它可以是经年累月的积累，也可以是瞬时爆发的拐点，但是目的都是相同的：让纽带断裂。</li></ul><p>从空间上看，「切割」是一个个体主动将自己从某个群体中剥离的行为，它的出现就代表着这个个体对于这个群体的态度发生了变化，反之亦然。  </p><hr><p>造成「切割」行为的一大主要缘由，是位置的不相配。</p><p>如果按照现代俗语所说，「上岸第一剑，先斩XXX」的话，虽然这种行为不值得提倡，但也可以说是自己处境上升后，想要摆脱原有群体的一个正常反应。而一个人在发生位置变动后想要切割原有群体，亦可以用这个心态来解释。</p><p>但实际上，在我所经历的情况里，更多是因为自身能力和位置不匹配而选择的切割，而这在我「不会读空气」（指在某些情况下不会看脸色说话）的叠加之下，显得更为严重。</p><p>说白了还是一句话：技不如人，甘拜下风。</p><p>如果一个人强行处在一个他不应该在的圈子，那他便就有可能对这个圈子所谓的共有语言认知甚少，进而便有可能踩掉他原本踩不了的一些雷。在这种情况下，人也是会自然而然选择切割的，因为固执地处在那个环境下维护脆弱的关系，只会使自己变成笑料。</p><hr><p>「切割」的另一大诱因，是精力的衰竭。而这也是我上半年亲身经历到的。</p><p>2024 上半年，我曾被卷入一场赛博环境的拉帮结派中，而我却因为和其中的两方都有利益关系，被扔在了一个两边不是人的境地。而当我真正打开黑名单，把两方扔入黑名单，不参加任何社交活动，然后在个性签名上挂上「如有三次元急事请电话联系」后，我发现没有赛博社交的生活确实美好。「切割」这一行为实实在在地治疗了我的精神内耗。</p><hr><p>从另一个角度讲，我觉得「切割」行为在近两年<em>看上去</em>发生频率高的原因，是后疫情时代社交关系的回正。由于封锁在家，人们赛博社交的强度大大增加，而现在现实生活回归正常，切割<em>对自己而言</em>不正常不健康的社交关系也是必然的事。</p><h2 id="切割，与另一个我"><a href="#切割，与另一个我" class="headerlink" title="切割，与另一个我"></a>切割，与另一个我</h2><p>关于如何正确地切割，或许每个人都没法给出一个完全正确且合理的参考指南。   </p><p>众所周知的某位网友在自己的个人博客上给出了一篇切割指南，但是可惜没有什么显性的许可协议，故也不好引用。</p><p>直到今年，我遇到了几例「之前在其他群和我活跃聊天的群友和这个群的群友是同一人」的事件，这种「盒子」的合并让我重新思考起了「身份隔离」这一行为的必要。</p><p>身份隔离并不少见，最常见且合规的隔离方式是将自己的工作号与生活号分开，在朋友圈呈现不同的形态，但这样本质上也不算切割，因为总有人可以将你这两个身份建立联系。真正的身份隔离，应当是让两个身份之间无法建立联系。</p><p>身份隔离的好处自不必讲，最为明显的好处是，你再也不用苦心经营自己的公共形象，在新形象下你像 VTuber 一样获得转生，可以卸掉负担换个地方释放你的表达欲。至于那些因为你更换了身份就不接纳你的「小群」，本身就不是你新身份找归属感的地方。</p><p>由此来看，身份隔离作为切割的善后工作，如何优美地进行无副作用的身份切割，也是一门艺术。</p><p>在这里我想到了 Kench，关于ta的描写已被拆分至 <a href="https://blog.hanlin.press/2024/09/Kench-Outside-the-Timeline/">这篇博文</a> ，没看过的读者可直接去看 Cover 的部分。   </p><p>在这里我们把上篇文章延展开来讨论，不难发现：原来ta就是身份切割的高手。</p><p>虽然不值得提倡，他的身份切割确确实实是毁灭式的：ta通过完全毁掉自己的上一身份，断绝了上一个圈层尝试寻找ta的尝试。换言之，现在的ta在公共视野里已经可以算是消失的状态，谁也不知道ta以什么身份开启了新生活。</p><hr><p>让世界上出现「另一个我」这件事很酷，但确确实实是一般人所不容易实现的。仅就切割而言，虽然我的赛博星座连续多年都是 ENFP-T，但也不得不赞同你需要在这件事上有一些 I 的心态：</p><p>专注于自己，不要苛求与别人过度共情的自我感动。必要时使用新的身份，能大大降低自己的精神内耗。</p><p>在这里摘编一句今天中午看到的空间朋友发表的感想，尽管不知道因何而发：</p><blockquote><p>永远不要多管闲事；跟别人合作不要主动揽活；不要在一个团队里显得过度积极；不要抱有太多责任感，那多半是自我感动；能给别人画饼叫别人做就不需要以身作则；不要想着过度的共情，因为你可能什么都做不到。也许放弃掉那份本不该产生的，令人有些反感的热情才是infp最好的自我救赎。</p></blockquote><hr><p>社交关系的演变可以被构画成一条曲线，有升温必然也有拐点，当拐点下降到一个点时，它就和前文中不再坚韧的纽带一样，拥有了被切割的前序条件。一位友人曾和我说过：我现在想想我被多少人切割了其实我都数不清了，但是仔细想想被切割的那些人我基本都没感觉到被切割的，大概是圈子本来就慢慢淡掉了。</p><div class="tag-plugin ds-music" style=" --lrc-display:block;" metingargs="IGF1dG89Imh0dHBzOi8vbXVzaWMuMTYzLmNvbS9zb25nP2lkPTE5MTA1ODA1ODciIGFwaT0iaHR0cHM6Ly9hcGkuaW5qYWhvdy5jbi9tZXRpbmcvP3NlcnZlcj06c2VydmVyJnR5cGU9OnR5cGUmaWQ9OmlkJmF1dGg9OmF1dGgmcj06ciI="></div><p>本篇文章的定位为纯粹的情绪废料，前前后后断断续续写了有整整一个月，其中的逻辑早已因情绪的落潮变得混乱，感谢您的倾听。</p><p>本篇 Cover 为友人的印象画。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Cutting-off-and-Another-ME/cover.jpg"/></div></div><h2 id="一些额外的片段"><a href="#一些额外的片段" class="headerlink" title="一些额外的片段"></a>一些额外的片段</h2><p>这些琐碎的文字为上文发布后所产生，为了不破坏之前读者的阅读体验，故单独发表在下方。</p><h3 id="升迁后的-GC"><a href="#升迁后的-GC" class="headerlink" title="升迁后的 GC"></a>升迁后的 GC</h3><p>摘编一则来自 TechCiel 的读者评论 ：</p><blockquote><p>对我而言感觉更为明显的是，每过一个阶段比如升学，把那些因为种种原因不得不加到各种软件里的人删掉是非常爽的一件事<br>我可能并不觉得这算是一种切割，因为从一开始我就知道这段关系有过期时间，我就是gc一下</p></blockquote><h3 id="VTB-与虚拟感"><a href="#VTB-与虚拟感" class="headerlink" title="VTB 与虚拟感"></a>VTB 与虚拟感</h3><p>如果说前文所述的「切割」大致可概括为一种主动的行为，那么 VTuber(VTB) 这个群体的出现则是伴随了客观意义上的「切割」。<br>我的 VTB 史大约是从始祖「Kizuna AI」开始，然后观察到，如果不按所谓「企业势」与「个人势」进行分类的话，这几年来，VTuber 的诞生大约可分为两类：</p><ul><li>一类是现有职业跟随潮流转型成「VTuber」，他们往往已经是声优或生产型 UP 主等成熟职业并拥有了固定的粉丝群体，转型后粉丝一般也会建立所谓「VTuber形象」与其较为真实的原身份关联起来。  </li><li>而另一类则和上文中「另一个我」类似，他们从一出生就是「VTuber」形象，账号正式运营发出来的第一则动态就是自己的设定卡。他们往往会由公司进行运营，但也有个人从零开始进行运营的情况。</li></ul><p>如上两种，相信读者自己都能举出对应的例子，我在这里就不赘述了。</p><p>而需要重点讨论的，是后面的这一类。<br>由于这类身份并无法和真实世界的身份建立一对一的可信关联，因此我们可以认为他们之间存在着客观层面的「切割」。这就是「VTuber」中真正「V」(Virtual) 的地方。和日本盛行的偶像行业一样，他们所做的每一个行为，都有可能会被加上「在营业」的标签。企业势的公司主体如果需要，也可以替换掉虚拟形象与真实身份的映射关系（俗称中之人替换），比如前些年沸沸扬扬的新科娘事件。<br>对于后面这一类，保持「切割」是自然而然加上去的标签，对他们而言：<strong>破坏次元壁一般是犯规的。</strong><br>因为一方面，这可能破坏了粉丝心中的「VTuber」形象。而从另一方面思考就更有意思：粉丝哪知道到真实生活的映射是真是假。</p><p>对于第一类「VTuber」需要担心的较少，因为「我就是我」，不少人早就知道了你的皮下是谁，也换不了（比如说菜菜子）。而对于第二类，这些是人设本人&#x2F;运营方在「营业」的全流程需要关注的事情。</p><h3 id="历史的碎片"><a href="#历史的碎片" class="headerlink" title="历史的碎片"></a>历史的碎片</h3><p>今年上半年，有一件事掀起了小小的水花：一位以「我卸载了XXX,退掉了作业共享群」爆火的衡水学生，在毕业后开始高强度发 COS 与音游类内容，引起热议，被指称与他的人设不符。<br>我无意评价他这个人本身，但就他这个「符号」而言，我当时是这么评价的：</p><div class="tag-plugin poetry"><div class="content"><div class="body"><p>一日上镜头，终生公共人物，「互联网是有记忆的」在后疫情时代已经成为公理。</p><p>现在看来，想要自己不被几年前非本愿的回旋镖击中，最好的方式或许不是切割成分，而是切割身份——不是立马摆清这是非本愿的任务，而是假装镜头中发生的事与赛博世界的自己毫不相干。</p><p>「人不能有自己的生活吗」之类的辩白在互联网「大串门」运动下显得是如此苍白无力：毕竟在这种情况下，ta不是一个实体，而已经变成了一个符号。</p></div></div></div>]]></content>
    
    
    <summary type="html">从上一篇文章继续说起，本篇是一次对于社交关系的反思，更是一篇纯粹的关于「切割」的情绪废料。</summary>
    
    
    
    <category term="杂记" scheme="https://blog.hanlin.press/categories/%E6%9D%82%E8%AE%B0/"/>
    
    
    <category term="倒垃圾" scheme="https://blog.hanlin.press/tags/%E5%80%92%E5%9E%83%E5%9C%BE/"/>
    
  </entry>
  
  <entry>
    <title>时间线之外的 Kench</title>
    <link href="https://blog.hanlin.press/2024/09/Kench-Outside-the-Timeline/"/>
    <id>https://blog.hanlin.press/2024/09/Kench-Outside-the-Timeline/</id>
    <published>2024-09-24T14:55:24.000Z</published>
    <updated>2024-09-24T18:10:24.000Z</updated>
    
    <content type="html"><![CDATA[<p>本篇作为一篇躺在草稿箱很久的情绪废料的一个章节，因为不自觉地越写越长，所以选择独立发布。</p><p>九月初，我在某个小群里看到了一个博客链接（原作者的<a href="https://zhuanlan.zhihu.com/p/699852973">知乎分流</a>），是在讲「可橙」的冷饭。<br>点开一看，并没有什么比较新鲜的信息，事情还是那些破烂事，我几年前就已经知道过的破烂事。  </p><p>但介于他在没有通知的情况下切割了和我的全部联系方式，我就当这个身份已经「死亡」了吧。<br>既然这样，我就从回忆录的角度，尽量不带感情色彩地，记载一些零碎的、我所能回忆到的往事。     </p><p>很不幸，我并没有过多存档聊天记录的习惯，甚至部分聊天记录是远程到家里的旧电脑寻找，对于某些质疑我的表述真实性的读者深感抱歉，<strong>您就当这是篇虚构写作就好。</strong></p><h2 id="Kench"><a href="#Kench" class="headerlink" title="Kench"></a>Kench</h2><p>我和 Kench 应该最早是在 NOIP 认识的，那时候他是个很爱水群的人，我最深刻的加上 QQ 之后，我对他的印象就是：一个略微懂些技术的 OIer.   </p><p>从 QQ 再来到 TG，他就像我之前结识的任何一个比较熟的网友一样，聊普通天，说日常事。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240924005820389.png"/></div></div><p>一切的话题都是那么的平常，你能看到他炫耀自己拿 scrcpy 投屏自己的那台 Redmi 5 Plus，也能看到他拿不知道哪里来的 Azure 账户开服务器，也能看到他在要成年时打算分期买手机。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240924010122777.png"/></div></div><p>当然，他最终买了红米 K20 Pro，时间告诉了我这是正确的决定。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240924010325444.png"/></div></div><p>时间快进到疫情期间，所有网友的赛博关系都得到了史诗级的增强。我们聊天的范围也相应扩了许多，从转发频道消息到私聊并发表评论，到研究网课开小差技巧，基本上无所不谈。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240924010612246.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240924010928464.png"/></div></div><p>哦对，还有去小米之家把「小米演示」应用偷回来装自己手机上，结果不得不把自己手机刷机的糗事。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240924235409138.png"/></div></div><p>他和我的私聊聊天截止在 2020 年 8 月 6 号，那些天晨风喝茶了，酷Q跑路了，miraiAndroid 删库了，大家都在寻思，玩 QQ 机器人的是否还有光明的未来。   </p><p>再之后学校开学了，我收拾了东西回学校开始封闭住宿，那时候都在忙各自的，谁也没有再去管对方。</p><h2 id="Kenchiu-aka-可橙"><a href="#Kenchiu-aka-可橙" class="headerlink" title="Kenchiu aka 可橙"></a>Kenchiu aka 可橙</h2><p>等到我下一次接触消息源后，我惊讶地发现：「Kench」变成「可橙」了。</p><p>混乱的信息流并没有允许我整理太多的有效信息，人们只是在纷纷讨论：为什么变成「可橙」了，「可橙」又去哪里了。</p><p>从零乱的信息中，我只知道：Kench 比较能“搞事”。他在被抓走的时候使用手表向外发着消息，随即庞大的消息攻势冲刷着全网。间性症，网戒所等一系列名词充斥在我的信息流，刷新了我在临沂🐏永信后的版本认知。<br>再之后，便是<a href="https://m.thepaper.cn/newsDetail_forward_10446063">媒体报道</a>，<a href="https://zhuanlan.zhihu.com/p/323504466">救援行动</a>，对于这些我只能一笑，然后继续去忙别的。<br>我并没有参与救援行动，我在这里知道的并不比互联网的公开信息更多。</p><p>我只知道，从我和他的线下接触以及小米之家（这是我们会面的主要地点）的搞事经历看来，我当时没有认识到任何他身上的有关 MtF 的特征，日常出装也并没有女性特征。<br>突然想起来，就在去小米之家偷「小米演示」的那次，<strong>他给我指指手上的彩虹色表带，说他要出柜了</strong>。</p><p>当时他也没详细说，当时我也没当回事。而在这一段我也更不知道怎么回事。<br>然后还有进了 THUPC 题面，也是我后来才得知的事。</p><h2 id="沐子"><a href="#沐子" class="headerlink" title="沐子"></a>沐子</h2><p>时间一口气拨到半年后。2021 年 7 月，ta通过「Kench」这个号（是的，ta变成「可橙」后有了一个新号，但这次）和我取得了联系，说ta已经回来了。见我不相信，还说可以线下再见一面。</p><p>随后就是视频电话，对我而言就是无尽的信息确认环节。具体的详情早已不清楚，我只记得，ta说是被家长接回来的。</p><p>家长说，OI 圈的没事，谨慎和药圈交往。也正因为此，ta暂时不打算复活自己的身份。</p><p>从那天之后，ta变成了「沐子Moozae」，开始了赛博空间的潜伏生活。而他本人的确和传言所说，去了昌乐某所学校复读。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240925013843731.png"/></div></div><p>如此这般，潜伏了约有一年，尽管在一般人看来「Kench」依旧是去向不明的状态，你却已经可以看到ta的身份出现在 TG 的大频道里。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240925003829569.png"/></div></div><p>就这样过去了差不多一年，2022 年高考结束，「Kenchiu aka 可橙」这个身份回来了。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240925005705724.png"/></div></div><p>之后就是大家喜闻乐见的章节：先是暗示，再是双簧戏的方式，ta把两个身份合并在了一起。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240925005941712.png"/></div></div><h2 id="看那大厦倒塌"><a href="#看那大厦倒塌" class="headerlink" title="看那大厦倒塌"></a>看那大厦倒塌</h2><p>2023 年 5 月 22 日，我发现我自己变成了 GitHub 组织 <code>excited-bilibili</code> 的 owner。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240925010558574.png"/></div></div><p>这个组织是 Kench 在 2019 年拉我进来的，当时的目的是学那些流行的油猴和 Stylish 代码，做一个增强B站体验的新轮子。但众所周知，我的前端能力到现在仍然是个谜，当时的条件也不支持我去写这么样的一个大项目。</p><p>第二天我来到了「沐子」的号去询问「发生甚么事了」，得到的回答着实让我大吃一惊：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240925010903950.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240925010948728.png"/></div></div><p>我自始至终都和这个圈子毫不相关，听了ta的讲述后，我当时在小群发了一条消息：还是声量大的更占理啊。</p><p>自从「圈」这个字出现在我的互联网记忆之后，与之出现的往往还有一句话：「___不混圈，快活似神仙」。</p><p>因此当时从他的朋友的关系出发，我给他的建议是，少掺和点圈子里的烂事，过点正常人的生活。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240925011453427.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240925011550839.png"/></div></div><p>再往后几天，「可橙」这个身份和我断开了联系，留在我眼前的只有两个「账号已删除」。</p><p>与之相关的一些圈子的事，我因为<strong>根本不在相关群里</strong>，自然也不知道事情的全貌。</p><h2 id="Cover"><a href="#Cover" class="headerlink" title="Cover"></a>Cover</h2><div class="tag-plugin ds-music" style=" --lrc-display:block;" metingargs="IGF1dG89Imh0dHBzOi8vbXVzaWMuMTYzLmNvbS9zb25nP2lkPTI2MTk2NDU4MTciIGFwaT0iaHR0cHM6Ly9hcGkuaW5qYWhvdy5jbi9tZXRpbmcvP3NlcnZlcj06c2VydmVyJnR5cGU9OnR5cGUmaWQ9OmlkJmF1dGg9OmF1dGgmcj06ciI="></div><p>这篇文章在外面是有一个封面的，这个封面由一张 2019 年的合照经我处理而成，先 PS 提取出了黑白边缘，然后通过 SDXL 二次生成，最后用 PS 进行后处理。而原图里面包含了我和「Kench」。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/cover.png" alt="不想点出去看的话，就是下面这张"/></div><div class="image-meta"><span class="image-caption center">不想点出去看的话，就是下面这张</span></div></div><p>不少吃瓜看戏的人读到这里之后可能会失望，因为我只是补齐了一些「大众时间线之外」的东西，你们所期待的、劲爆的，我一概不知道，因为我压根就不是这个圈里的，我所分享的也只是他于我记忆里较为完整的一些片段。</p><p>而关于要不要展示一些聊天记录，我在不同时间段也作过不同的思想斗争，最终在老电脑上翻旧资料时看到了这句。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240925014226519.png"/></div></div><p>互联网上是不缺「神的随波逐流」活动的。人可以被造成神，神也可以陨落回人。而一个人一旦被造成神之后，在我这里，我认为他就已经不是他个体了，他成为了一种符号。</p><p>那正如文章片头所说的，既然这个符号在纷乱中盛行，又在纷乱中被宣告死亡，那么作为一个符号，ta的存在就已经可以被解构。你完全可以把我全部如上的叙述<strong>看作是虚构创作</strong>，它存在的意义是提供了一个案例，一个值得研究的案例。 这是我思想斗争之后的结果。</p><p>那么，对我来说呢？</p><p>都说了嘛，这篇博客本来是我更大的情绪废料中的一个章节，因为越写越多才打算拆出一篇文章，有些今年的观察和思考我还会在之后的博文继续探讨。毕竟，不是每个人都能和所有网友维护长达多年的、紧密的赛博关系。每年总会有几个，因为心态转变或是飞黄腾达，聊天关系疏远乃至直接断开了联系，被时间渐渐淡忘的网友，如果一切都正常的话，「可橙」原本也可能是其中之一。</p><p>尽管带聊天记录的截图是第一次被正经公开，但「大众认知」中的事件线，我已经在线下反复说过多次。 每当现实生活中的同学跟我谈起网络上 MtF 群体的事情后，我就会把这一整件事讲给他们听，告诉他们：作为一个并不属于这个圈层的，被迫卷入这整个事件某些环节的人，我曾目睹过一个前站长，在「大众的时间线」里，做出了这么一些事。说直白且可能不尊重点，在经历了这件事后，我本人对这个团体以后所发生的事彻底脱敏了，再发生什么事也都不奇怪了。</p><hr><p>最后的最后，通过搜索手段我再次找到了 Steam 上的 Kench，发现上面居然还有《黑神话：悟空》的游戏记录。而在之前整理文章去找消息截图的时候，我惊讶地发现：在我们去小米之家偷走「小米演示」推送的下面，<strong>恰好就是《黑神话：悟空》的预告片视频</strong>。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Kench-Outside-the-Timeline/image-20240925020057039.png"/></div></div><p>严谨地讲，我对ta的认知时间线发生改变，就是ta在小米之家向我展示彩虹色手表带的时候。而现在似乎万物都又化为平静，倘若没有文初的炒冷饭的话这件事可能也早已在大众视野里被翻篇。时间线从黑神话的预告片开始，又一路穿到了黑神话的游玩记录上面。  </p><p>那么，对于正在江苏游玩《黑神话：悟空》的他，这么几年的起起伏伏，又何尝不是一些「时间线之外」的事？</p><p>而这些，大约就是我对这一整个事件的思考吧。</p>]]></content>
    
    
    <summary type="html">可能是一些零碎的，发生在时间线之外的，一点细碎的往事。</summary>
    
    
    
    <category term="杂记" scheme="https://blog.hanlin.press/categories/%E6%9D%82%E8%AE%B0/"/>
    
    
    <category term="倒垃圾" scheme="https://blog.hanlin.press/tags/%E5%80%92%E5%9E%83%E5%9C%BE/"/>
    
  </entry>
  
  <entry>
    <title>BF-0505 官方驱动无法在高 DPI 下扫描 TIFF 文件的解决方案</title>
    <link href="https://blog.hanlin.press/2024/09/BF0505-High-DPI-TIFF/"/>
    <id>https://blog.hanlin.press/2024/09/BF0505-High-DPI-TIFF/</id>
    <published>2024-09-19T15:53:58.000Z</published>
    <updated>2024-09-19T19:53:58.000Z</updated>
    
    <content type="html"><![CDATA[<p>叠甲与背景：Avision BF-0505 （坊间简称 BF0505）是一款发布于十多年前的平板扫描仪，目前走经销商的采购价格仍为三位数往上（但到底是谁还会全新买啊喂）。因其在采用了 CCD 传感器的同时在二手市场拥有着低于 100 块的低廉售价，在扫图社区中占据着独特的生态位。   </p><p>尽管如此，由于该扫描仪在早期的使用十分广泛，二手市场如闲鱼上成色品质可谓鱼龙混杂，购买 BF-0505 可定义为纯粹的看脸行为。<strong>本文仅为保存解决方案与给群友一键解答使用</strong>，该文章的存在并不是为了让你在 2024 年还去捡成色抽奖且无技术支持的垃圾用的。</p><h2 id="关于官方驱动"><a href="#关于官方驱动" class="headerlink" title="关于官方驱动"></a>关于官方驱动</h2><p>对于驱动，该款扫描仪的<a href="http://www.avision.net.cn/techsupport/driver.php">官网</a>提供了下载链接：<a href="http://www.avision.net.cn/techsupport/driver/BF-0505.rar">http://www.avision.net.cn/techsupport/driver/BF-0505.rar</a> ，版本 6.20，标称支持到 Win10 ，但在 Win11 下基本使用也没问题。</p><p>通过启动官方软件，可以看出使用的驱动协议是 TWAIN。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/BF0505-High-DPI-TIFF/image-20240920011322624.png"/></div></div><p>在官方软件左上角的设置页面，默认提供的 DPI 选项最高到 600 ，对于一般扫图用途可以设置到最高 2400 DPI（<strong>实际上超过 1200 DPI 两边都会报错</strong>），对于大部分彩色扫图需求而言，1200 DPI 已经足够。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/BF0505-High-DPI-TIFF/image-20240920014013434.png"/></div></div><p>但此处普遍存在的问题是：在使用 TIFF 格式时，使用高 DPI 扫图会提示**「内存不足」**，而 BMP 格式却能正常扫描（但在软件内预览时也会报内存不足）。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/BF0505-High-DPI-TIFF/image-20240920014834222.png"/></div></div><p>因为 BMP 的扫描并无问题，由此怀疑是官方软件的实现问题。</p><h2 id="换用第三方软件-NAPS2"><a href="#换用第三方软件-NAPS2" class="headerlink" title="换用第三方软件 NAPS2"></a>换用第三方软件 NAPS2</h2><p><a href="https://github.com/cyanfish/naps2">NAPS2</a> 是一个 GitHub 上开源的第三方扫描仪软件，支持 WIA 协议与 TWAIN 协议，同时也可以通过 ESCL 协议将该扫描仪共享。</p><p>该软件经实践可以解决如上问题，多余的故事不讲，仅讲关键的操作部分。</p><p>因本人并无专业的扫描仪用校色卡，因此下文中的例图说明<strong>仅为参考学习意义</strong>。</p><h3 id="官方驱动端设置"><a href="#官方驱动端设置" class="headerlink" title="官方驱动端设置"></a>官方驱动端设置</h3><p>换用该第三方软件<strong>仍需要你在官方驱动软件里完成相关设置</strong>，因为部分设置在第三方软件里仍然生效。</p><p>具体体现在如下几个方面：</p><ol><li><p>「图像」标签页的「色彩校正」设置。我一般会选择「图片」模式，四种模式对比度差别较大，对于四种模式的对比可见我如下制作的对比图，以选择符合您喜好的模式：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/BF0505-High-DPI-TIFF/color-correction-diff.png"/></div></div></li></ol><p>​亮度、对比度跟随 NAPS2，原先那里不用管。<strong>DPI 建议设定为你需要的数值，尽管从实际扫描速度来看生效的好像不是这里</strong>。</p><ol start="2"><li><p>「纸张」页的「裁切」选项，这里面可用的选项<strong>会根据上一步官方驱动里设定的数值变化</strong>（实际上是项数依次变少），但是<strong>务必选择「固定尺寸」→最大扫描范围</strong>。</p><p>此处的裁切对下级 NAPS2 也有效，选「原稿尺寸」就自作聪明给你开切，选「自动多张图像」就真给 NAPS2 喂自己裁切过的多张图像。</p><p>选择好最大扫描范围后，<strong>关注下面的长宽数值，备用</strong>。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/BF0505-High-DPI-TIFF/image-20240920033908117.png"/></div></div></li><li><p>把设置依次溜一圈，把可选能够关掉的功能全部关掉，并不要使用彩色侦测功能。</p></li></ol><h3 id="NAPS2-端设置"><a href="#NAPS2-端设置" class="headerlink" title="NAPS2 端设置"></a>NAPS2 端设置</h3><p>安装完 NAPS2 后，新建配置文件，在设备选择处选择「TWAIN」栏，此时理论上已经能看见 BF-0505。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/BF0505-High-DPI-TIFF/image-20240920034115829.png"/></div></div><p>之后选择「预定义设置」，然后<strong>修改纸张大小</strong>，保证此处的数<strong>大于上一步所看到的值</strong>，一般情况下如果上面步骤开启的是最大扫描范围，此处设置 300*300 都可以：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/BF0505-High-DPI-TIFF/image-20240920034402231.png"/></div></div><p>在高级设置一栏，勾选「最佳质量（大文件）」，其余设置可保证默认，看你喜好。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/BF0505-High-DPI-TIFF/image-20240920034502396.png"/></div></div><p>勾选上「显示本地 TWAIN 进程」的话，可以看到官方驱动的扫描过程：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/BF0505-High-DPI-TIFF/image-20240920034628994.png"/></div></div><p>在图像设定里设置保存文件格式为 TIFF 结尾即可，我设置了 <code>$(YYYY)-$(MM)-$(DD)-$(nnnn).tiff</code> 。</p><h2 id="更多的事"><a href="#更多的事" class="headerlink" title="更多的事"></a>更多的事</h2><p>NAPS2 胜在对于原生驱动的兼容性较好，但是<strong>后处理功能较少</strong>。另外一个第三方商业软件 VueScan 宣称逆向了很多厂商的驱动程序，后处理功能也较多，但<strong>偏偏不支持 BF-0505</strong>。</p><p>因此一种比较容易想到的工作流是，使用 NAPS2 完成扫描整页到 TIFF，再导入到 VueScan 进行多幅面切割，倾斜校正等后处理工作。</p><p>但在使用 VueScan 时，谨慎使用其自带的 「发现扫描仪」操作，因为<strong>观测到其有干掉官方驱动的行为</strong>。如果使用 VueScan 使得你在 NAPS2 找不到扫描仪了，可以重新运行一遍官方的驱动安装程序，点击一下「修复」。</p>]]></content>
    
    
    <summary type="html">尽管如此，由于该扫描仪在早期的使用十分广泛，二手市场如闲鱼上成色品质可谓鱼龙混杂，购买 BF-0505 可定义为纯粹的看脸行为。本文仅为保存解决方案与给群友一键解答使用，该文章的存在并不是为了让你在 2024 年还去捡成色抽奖且无技术支持的垃圾用的。</summary>
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="扫描仪" scheme="https://blog.hanlin.press/tags/%E6%89%AB%E6%8F%8F%E4%BB%AA/"/>
    
    <category term="扫图" scheme="https://blog.hanlin.press/tags/%E6%89%AB%E5%9B%BE/"/>
    
  </entry>
  
  <entry>
    <title>博客现已支持手动切换深浅色模式</title>
    <link href="https://blog.hanlin.press/2024/09/Manual-Darkmode/"/>
    <id>https://blog.hanlin.press/2024/09/Manual-Darkmode/</id>
    <published>2024-09-03T17:32:42.000Z</published>
    <updated>2024-09-04T05:20:42.000Z</updated>
    
    <content type="html"><![CDATA[<p>从现在开始，博客已经支持手动切换深浅色模式了。仅需点击页面左下角的最后一个按钮（移动端可拉到最下方点「🔁 切换主题」），或是试试点击这几个链接：<a href="javascript:applyTheme('dark')">深色模式</a>，<a href="javascript:applyTheme('light')">浅色模式</a>，<a href="javascript:applyTheme('auto')">跟随系统</a></p><p>本来这里打了一长串字想解释来龙去脉的，直到后来我发现 <a href="https://github.com/xaoxuu/hexo-theme-stellar/pull/449/files">hexo-theme-stellar</a> 已有了一个比较粗暴的实现。所以这次仅提供修改时的流水账，供其他人魔改参考。<br>源码可参见 <a href="https://github.com/abc1763613206/hexo-theme-stellaris/commit/fa8e5d658e549ad777d3985f5f2da8cd11e5782b">fa8e5d6</a> 和 <a href="https://github.com/abc1763613206/hexo-theme-stellaris/commit/4277bc65bb93b8a4ba1bd892fcd6b12816d89c34">4277bc6</a> .</p><h2 id="样式注入"><a href="#样式注入" class="headerlink" title="样式注入"></a>样式注入</h2><p>定义 <code>theme.style.darkmode == &#39;auto-switch&#39;</code> 为开关。<br>整体上通过写入 <code>data-theme</code> 选择器来实现，因为当前的逻辑是一启动就向 <code>html</code> 注入 data 属性，索性把 <code>auto</code> 也复制了一份情况。</p><figure class="highlight stylus"><figcaption><span>source/css/_defines/theme.styl</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> <span class="built_in">hexo-config</span>(<span class="string">&#x27;style.darkmode&#x27;</span>) == <span class="string">&#x27;auto-switch&#x27;</span></span><br><span class="line">  <span class="selector-pseudo">:root</span><span class="selector-attr">[data-theme=<span class="string">&quot;dark&quot;</span>]</span></span><br><span class="line">    <span class="built_in">set_darkmode</span>()</span><br><span class="line">  <span class="selector-pseudo">:root</span><span class="selector-attr">[data-theme=<span class="string">&quot;auto&quot;</span>]</span></span><br><span class="line">    <span class="keyword">@media</span> (<span class="attribute">prefers-color-scheme</span>: dark)</span><br><span class="line">      set_darkmode()</span><br></pre></td></tr></table></figure><p>评论区组件仅测试了 Waline，通过变量定义配色板非常方便。</p><figure class="highlight stylus"><figcaption><span>source/css/_plugins/comments/waline.styl</span></figcaption><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><span class="line"><span class="keyword">if</span> <span class="built_in">hexo-config</span>(<span class="string">&#x27;style.darkmode&#x27;</span>) == <span class="string">&#x27;auto-switch&#x27;</span></span><br><span class="line">  <span class="selector-pseudo">:root</span><span class="selector-attr">[data-theme=<span class="string">&quot;dark&quot;</span>]</span> <span class="selector-class">.wl-count</span></span><br><span class="line">    <span class="attribute">padding</span>: .<span class="number">375em</span>;</span><br><span class="line">    <span class="attribute">font-weight</span>: bold;</span><br><span class="line">    <span class="attribute">font-size</span>: <span class="number">1.25em</span>;</span><br><span class="line">    <span class="attribute">color</span>: <span class="number">#fff</span>;</span><br><span class="line"></span><br><span class="line">  <span class="selector-pseudo">:root</span><span class="selector-attr">[data-theme=<span class="string">&quot;dark&quot;</span>]</span> <span class="selector-class">.cmt-body</span><span class="selector-class">.waline</span></span><br><span class="line">    <span class="attr">--waline-white</span>: <span class="number">#000</span>;</span><br><span class="line">    <span class="attr">--waline-light-grey</span>: <span class="number">#666</span>;</span><br><span class="line">    <span class="attr">--waline-dark-grey</span>: <span class="number">#999</span>;</span><br><span class="line">    <span class="comment">/* 布局颜色 */</span></span><br><span class="line">    <span class="attr">--waline-color</span>: <span class="number">#fff</span>;</span><br><span class="line">    <span class="attr">--waline-bg-color</span>: <span class="number">#2b2f33b2</span>;</span><br><span class="line">    <span class="attr">--waline-bg-color-light</span>: <span class="number">#272727</span>;</span><br><span class="line">    <span class="attr">--waline-border-color</span>: <span class="number">#333</span>;</span><br><span class="line">    <span class="attr">--waline-disable-bg-color</span>: <span class="number">#444</span>;</span><br><span class="line">    <span class="attr">--waline-disable-color</span>: <span class="number">#272727</span>;</span><br><span class="line">    <span class="comment">/* 特殊颜色 */</span></span><br><span class="line">    <span class="attr">--waline-bq-color</span>: <span class="number">#272727</span>;</span><br><span class="line">    <span class="comment">/* 其他颜色 */</span></span><br><span class="line">    <span class="attr">--waline-info-bg-color</span>: <span class="number">#272727</span>;</span><br><span class="line">    <span class="attr">--waline-info-color</span>: <span class="number">#666</span>;</span><br></pre></td></tr></table></figure><h2 id="修改逻辑"><a href="#修改逻辑" class="headerlink" title="修改逻辑"></a>修改逻辑</h2><p>修改了配置中 footer 的部分，仅需配置三个状态下的图标，方便不想把图标放在最后一位的用户。</p><figure class="highlight yaml"><figcaption><span>_config.yml</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">footer:</span></span><br><span class="line">  <span class="attr">social:</span></span><br><span class="line">    <span class="attr">darkmode:</span> <span class="comment"># 键值为 darkmode 时配置为黑暗模式切换按钮（darkmode 须设置为 auto-switch），且此处仅按如下格式配置三个模式的图标</span></span><br><span class="line">        <span class="attr">auto:</span> <span class="string">&#x27;&lt;img no-lazy width=&quot;24&quot; height=&quot;24&quot; src=&quot;/images/sun-moon.svg&quot;/&gt;&#x27;</span></span><br><span class="line">        <span class="attr">light:</span> <span class="string">&#x27;&lt;img no-lazy width=&quot;24&quot; height=&quot;24&quot; src=&quot;/images/sun-fill.svg&quot;/&gt;&#x27;</span></span><br><span class="line">        <span class="attr">dark:</span> <span class="string">&#x27;&lt;img no-lazy width=&quot;24&quot; height=&quot;24&quot; src=&quot;/images/moon-fill.svg&quot;/&gt;&#x27;</span></span><br></pre></td></tr></table></figure><p>预处理后的按钮直接绑定 <code>switchTheme()</code> 事件。</p><figure class="highlight jsx"><figcaption><span>layout/components/sidebar/sidebar.jsx</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">&lt;div className=<span class="string">&quot;darkmode-switch&quot;</span></span><br><span class="line">    id=<span class="string">&quot;darkmode-switch-light&quot;</span></span><br><span class="line">    title=&#123;<span class="title function_">__</span>(<span class="string">&#x27;message.theme_switched.light&#x27;</span>)&#125;</span><br><span class="line">    data-on-click=<span class="string">&quot;switchTheme()&quot;</span></span><br><span class="line">&gt;</span><br><span class="line">    &#123;<span class="title function_">parse</span>(item.<span class="property">light</span>)&#125;</span><br><span class="line">&lt;/div&gt;</span><br></pre></td></tr></table></figure><p><strong>为了防止页面加载闪屏</strong>，在加载 <code>main.css</code> 之前，就需要做好属性注入。</p><figure class="highlight jsx"><figcaption><span>layout/components/head.jsx</span></figcaption><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><span class="line"><span class="keyword">const</span> <span class="title function_">ImportDarkMode</span> = (<span class="params">props</span>) =&gt; &#123;</span><br><span class="line">    <span class="keyword">const</span> &#123;theme&#125; = props</span><br><span class="line">    <span class="keyword">const</span> <span class="title class_">DarkModePreset</span> = <span class="string">`</span></span><br><span class="line"><span class="string">        const themeModeList = [&#x27;light&#x27;, &#x27;dark&#x27;, &#x27;auto&#x27;];</span></span><br><span class="line"><span class="string">        var ThemeChange = (theme) =&gt; &#123;</span></span><br><span class="line"><span class="string">          if(theme == null)&#123;</span></span><br><span class="line"><span class="string">            theme = &#x27;auto&#x27;;</span></span><br><span class="line"><span class="string">          &#125;</span></span><br><span class="line"><span class="string">          document.documentElement.setAttribute(&#x27;data-theme&#x27;, theme)</span></span><br><span class="line"><span class="string">          window.localStorage.setItem(&#x27;Stellaris.theme&#x27;, theme);</span></span><br><span class="line"><span class="string">        &#125;</span></span><br><span class="line"><span class="string">        ThemeChange(window.localStorage.getItem(&#x27;Stellaris.theme&#x27;));</span></span><br><span class="line"><span class="string">    `</span></span><br><span class="line">    <span class="keyword">if</span> (theme.<span class="property">style</span>.<span class="property">darkmode</span> == <span class="string">&#x27;auto-switch&#x27;</span>) &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="language-xml"><span class="tag">&lt;<span class="name">script</span> <span class="attr">type</span>=<span class="string">&quot;text/javascript&quot;</span> <span class="attr">data-no-instant</span>=<span class="string">&quot;true&quot;</span> <span class="attr">dangerouslySetInnerHTML</span>=<span class="string">&#123;&#123;__html:</span> <span class="attr">DarkModePreset</span>&#125;&#125;/&gt;</span></span></span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="language-xml"><span class="language-handlebars"><span class="language-xml"><span class="tag">&lt;&gt;</span><span class="tag">&lt;/&gt;</span></span></span></span>;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 注意最后放在 ImportCSS 之前</span></span><br><span class="line">&lt;<span class="title class_">ImportDarkMode</span> &#123;...props&#125;/&gt;</span><br><span class="line"><span class="language-xml"><span class="language-handlebars"><span class="language-xml"><span class="tag">&lt;<span class="name">ImportCSS</span> &#123;<span class="attr">...props</span>&#125;/&gt;</span></span></span></span></span><br></pre></td></tr></table></figure><h2 id="动态按钮"><a href="#动态按钮" class="headerlink" title="动态按钮"></a>动态按钮</h2><p>本来想着动态插拔 class 属性，晚上又突然想到可以使用 <code>data-theme</code> 来实现。<br>按照约定好的顺序，依次在当前状态下展示下一个按钮即可。</p><figure class="highlight stylus"><figcaption><span>source/css/_components/darkmode.styl</span></figcaption><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><span class="line"><span class="selector-class">.darkmode-switch</span></span><br><span class="line">    <span class="attribute">display</span>: none</span><br><span class="line">    <span class="attribute">cursor</span>: pointer</span><br><span class="line"><span class="selector-pseudo">:root</span><span class="selector-attr">[data-theme=<span class="string">&quot;dark&quot;</span>]</span> <span class="selector-id">#darkmode-switch-auto</span> </span><br><span class="line">    <span class="attribute">display</span>: inline-block </span><br><span class="line"><span class="selector-pseudo">:root</span><span class="selector-attr">[data-theme=<span class="string">&quot;auto&quot;</span>]</span> <span class="selector-id">#darkmode-switch-light</span></span><br><span class="line">    <span class="attribute">display</span>: inline-block </span><br><span class="line"><span class="selector-pseudo">:root</span><span class="selector-attr">[data-theme=<span class="string">&quot;light&quot;</span>]</span> <span class="selector-id">#darkmode-switch-dark</span></span><br><span class="line">    <span class="attribute">display</span>: inline-block </span><br></pre></td></tr></table></figure><p>在页尾再加个监听 <code>prefers-color-scheme</code> 的事件，以便在系统切换时自动切换。最后把前文绑定的函数实现一下即可。</p><figure class="highlight jsx"><figcaption><span>layout/components/scripts.jsx</span></figcaption><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title function_">ImportDarkModeListener</span> = (<span class="params">props</span>) =&gt; &#123;</span><br><span class="line">  <span class="keyword">const</span> &#123;theme, __&#125; = props</span><br><span class="line">  <span class="keyword">const</span> <span class="title class_">DarkModeListener</span> = <span class="string">`</span></span><br><span class="line"><span class="string">    const applyTheme = (theme) =&gt; &#123;</span></span><br><span class="line"><span class="string">      document.documentElement.setAttribute(&#x27;data-theme&#x27;, theme);</span></span><br><span class="line"><span class="string">      window.localStorage.setItem(&#x27;Stellaris.theme&#x27;, theme);</span></span><br><span class="line"><span class="string">      const messages = &#123;</span></span><br><span class="line"><span class="string">        light: &#x27;<span class="subst">$&#123;__(<span class="string">&#x27;message.theme_switched.light&#x27;</span>)&#125;</span>&#x27;,</span></span><br><span class="line"><span class="string">        dark: &#x27;<span class="subst">$&#123;__(<span class="string">&#x27;message.theme_switched.dark&#x27;</span>)&#125;</span>&#x27;,</span></span><br><span class="line"><span class="string">        auto: &#x27;<span class="subst">$&#123;__(<span class="string">&#x27;message.theme_switched.auto&#x27;</span>)&#125;</span>&#x27;,</span></span><br><span class="line"><span class="string">      &#125;</span></span><br><span class="line"><span class="string">      hud?.toast?.(messages[theme]);</span></span><br><span class="line"><span class="string">    &#125;</span></span><br><span class="line"><span class="string">    const switchTheme = () =&gt; &#123;</span></span><br><span class="line"><span class="string">      const currentTheme = document.documentElement.getAttribute(&#x27;data-theme&#x27;);</span></span><br><span class="line"><span class="string">      let nextTheme = themeModeList[(themeModeList.indexOf(currentTheme) + 1) % themeModeList.length];</span></span><br><span class="line"><span class="string">      applyTheme(nextTheme);</span></span><br><span class="line"><span class="string">    &#125;</span></span><br><span class="line"><span class="string">    var OSTheme = window.matchMedia(&#x27;(prefers-color-scheme: dark)&#x27;);</span></span><br><span class="line"><span class="string">    OSTheme.addEventListener(&#x27;change&#x27;, e =&gt; &#123;</span></span><br><span class="line"><span class="string">      if (document.documentElement.getAttribute(&#x27;data-theme&#x27;) === &#x27;auto&#x27;) &#123;</span></span><br><span class="line"><span class="string">        ThemeChange(&#x27;auto&#x27;);</span></span><br><span class="line"><span class="string">      &#125;</span></span><br><span class="line"><span class="string">    &#125;)</span></span><br><span class="line"><span class="string">  `</span></span><br><span class="line">  <span class="keyword">if</span> (theme.<span class="property">style</span>.<span class="property">darkmode</span> == <span class="string">&#x27;auto-switch&#x27;</span>) &#123;</span><br><span class="line">      <span class="keyword">return</span> <span class="language-xml"><span class="tag">&lt;<span class="name">script</span> <span class="attr">type</span>=<span class="string">&quot;text/javascript&quot;</span> <span class="attr">data-no-instant</span>=<span class="string">&quot;true&quot;</span> <span class="attr">dangerouslySetInnerHTML</span>=<span class="string">&#123;&#123;__html:</span> <span class="attr">DarkModeListener</span>&#125;&#125;/&gt;</span></span></span><br><span class="line">  &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">      <span class="keyword">return</span> <span class="language-xml"><span class="language-handlebars"><span class="language-xml"><span class="tag">&lt;&gt;</span><span class="tag">&lt;/&gt;</span></span></span></span>;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>]]></content>
    
    
    <summary type="html">尝试在已有的基础上造了一个更好的轮子，期望找到更优的做法。</summary>
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="Hexo" scheme="https://blog.hanlin.press/tags/Hexo/"/>
    
    <category term="博客" scheme="https://blog.hanlin.press/tags/%E5%8D%9A%E5%AE%A2/"/>
    
  </entry>
  
  <entry>
    <title>Migrate Dream! It&#39;s MyHexo!!!!!</title>
    <link href="https://blog.hanlin.press/2024/08/Migrate-to-Hexo/"/>
    <id>https://blog.hanlin.press/2024/08/Migrate-to-Hexo/</id>
    <published>2024-08-26T16:02:38.000Z</published>
    <updated>2024-08-27T05:21:38.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="缘起"><a href="#缘起" class="headerlink" title="缘起"></a>缘起</h2><h3 id="改变"><a href="#改变" class="headerlink" title="改变"></a>改变</h3><p>故事还得从某天我登上 WordPress 后台，进行友链维护时的红温说起：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Migrate-to-Hexo/image-20240827010820222.png"/></div></div><p>在登入 WP 后台后，没了前台 Cache 的保护，执行与查询操作的时间上升到了惊人的 30s 以上。</p><p>经过分析后发现，后端的卡顿和其请求 <code>api.wordpress.org</code> 获取更新应该脱不开干系：国内阿里云到 wp.org 的访问质量本来就差，又因为阿里云 Tailscale 入网把默认的 DNS 炸掉了，一环扣一环下去，请求到 Timeout 也是必然会发生的事件。</p><p>而众所周知，PHP 并不是一个特别异步的语言。从上到下依次执行，卡更新就是卡更新了，其他操作站一边等着去。</p><p>之前遇到这类事件，我的做法一般是去找个插件或者人工 patch 掉相关请求完事。但也许是红温过了头吧，这次打算直接狠狠心，流浪到下一个博客生成器去。</p><p>在小群里求大伙推荐，以及思想斗争后，发现迁移的想法主要基于以下几点：</p><ul><li>准备迁移时，原博客所用主题 <a href="https://github.com/yrccondor/mdx">mdx</a> 已有接近3年没发过新的 release. （尽管写到这里的时候去确认了下，发现居然在三周前发布了 <code>2.0.4</code> 版本，但也只是修复致命 bug 而无新特性更新）</li><li>之前写 WordPress 都用 <a href="https://github.com/LuRenJiasWorld/WP-Editor.md">WP-Editor.md</a> 编辑器，中间毕竟还是过了一次 Markdown 到 WP 格式的转换，能找个裸处理 Markdown 内容的当然最好。</li><li>主机上的 PHP 旧账负载过于严重，尽量迁移到一个不基于 PHP 的博客系统（Typecho 几年前早就玩过），更极端点，动态生成的比例越少越好，防止给服务器上太大强度。</li><li>接触网络安全领域后发现：任何一个欠更新的系统&#x2F;程序到最后都会变成攻击面。</li><li>曾经进入候选圈的有 Ghost，<a href="https://www.ruanx.net/">@ruanxingzhi</a>  老师推荐的理由包括 SEO 友好，以及可以根据访问设备的情况自适应资源以保证最优加载速度。以上确实是动态站点独有的优点，也确实没有 PHP 的技术债。但是小溜了一圈后发现，Ghost 还是更想把自己做成一个纯 CMS，官方配套的主题都有一股新闻站风格。（尽管如此，不得不承认还是吊打 WP 默认主题一大圈）</li><li>也有群友拿 Mkdocs Material 写博客，但毕竟还是太文档站了一些，少了很多应该有的交互。</li><li>群友S：我还是选择静态，db-less 只用拿着一堆 md 想润哪里润哪里就很安心，生成完了也是一堆静态文件随便起个服务器就能跑，能 oob 满足95+%的需求，不能的时候可以自由嵌入 html &#x2F; js，我觉得在甜点上</li></ul><p>综合下来，主流的博客生成器我就知道 Hexo 和 Hugo 两种，考虑到之前还没整过 Hexo 博客，先从 Hexo 开始吧🤪</p><p>还有 Astro 什么的高端科技，后续再说。</p><h3 id="研究"><a href="#研究" class="headerlink" title="研究"></a>研究</h3><p>在 GitHub 上以 <code>hexo theme</code> 为关键词进行搜索，然后按 Recently updated 进行筛选，能够筛选出过去半年到一年还有活跃更新的主题。</p><p>我当时筛选出了下面这些：</p><ul><li><a href="https://github.com/ppoffice/hexo-theme-icarus">https://github.com/ppoffice/hexo-theme-icarus</a></li><li><a href="https://github.com/blinkfox/hexo-theme-matery">https://github.com/blinkfox/hexo-theme-matery</a></li><li><a href="https://github.com/jerryc127/hexo-theme-butterfly">https://github.com/jerryc127/hexo-theme-butterfly</a></li><li><a href="https://github.com/fluid-dev/hexo-theme-fluid">https://github.com/fluid-dev/hexo-theme-fluid</a></li><li><a href="https://github.com/everfu/hexo-theme-solitude">https://github.com/everfu/hexo-theme-solitude</a></li><li><a href="https://github.com/anzhiyu-c/hexo-theme-anzhiyu">https://github.com/anzhiyu-c/hexo-theme-anzhiyu</a></li><li><a href="https://github.com/theme-shoka-x/hexo-theme-shokaX">https://github.com/theme-shoka-x/hexo-theme-shokaX</a></li><li><a href="https://github.com/EvanNotFound/hexo-theme-redefine">https://github.com/EvanNotFound/hexo-theme-redefine</a></li><li><a href="https://github.com/chiyuki0325/hexo-theme-stellaris">https://github.com/chiyuki0325/hexo-theme-stellaris</a></li><li><a href="https://github.com/lyxofficial/hexo-theme-acryple">https://github.com/lyxofficial/hexo-theme-acryple</a> </li><li><a href="https://github.com/Candinya/Kratos-Rebirth">https://github.com/Candinya/Kratos-Rebirth</a></li><li><a href="https://github.com/D-Sketon/hexo-theme-reimu">https://github.com/D-Sketon/hexo-theme-reimu</a></li><li><a href="https://github.com/Lhcfl/hexo-theme-anatolo">https://github.com/Lhcfl/hexo-theme-anatolo</a></li><li><a href="https://github.com/xaoxuu/hexo-theme-stellar">https://github.com/xaoxuu/hexo-theme-stellar</a></li></ul><p>有些主题落选的原因比较简单：整体设计太过简洁了，有股 Typecho 早期主题的美感。</p><p>然后除掉 NexT 这类烂大街的主题，又有很多主题基于 <a href="https://butterfly.zhheo.com/">Butterfly</a> 衍生，像 anzhiyu 和 solitude 这类基于 <a href="https://blog.zhheo.com/">张洪 Heo</a> 设计，美观也是确实美观，但也突出了一个重要问题：这类博客<strong>吃封面设计</strong>。认真设计的封面和普通随机图的平铺相比，完全就是两个效果，不信的话可以去对比一下 <a href="https://solitude.js.org/preview/">这个演示站</a> 。</p><p><a href="https://github.com/fluid-dev/hexo-theme-fluid">hexo-theme-fluid</a> 秉承了 MD 设计风格，观感上非常像我的旧 mdx 主题；<a href="https://github.com/Candinya/Kratos-Rebirth">Kratos-Rebirth</a> 成功地复刻了我曾经没买这个域名之前就在用的 Kratos 主题，我也非常喜欢。但毕竟也都有些烂大街了。</p><p>到最后，把主题锁定在了 <a href="https://github.com/chiyuki0325/hexo-theme-stellaris">hexo-theme-stellaris</a> ，主要是它的 <a href="https://blog.chyk.ink/">preview</a> 打开第一眼就惊艳到了我，整体下来就两个字：优雅。属于是小众宝藏主题了。</p><hr><h2 id="修改"><a href="#修改" class="headerlink" title="修改"></a>修改</h2><p>目前博客在运行的是我的 Fork 版：<a href="https://github.com/abc1763613206/hexo-theme-stellaris">https://github.com/abc1763613206/hexo-theme-stellaris</a></p><p>主要所做的修改如下：</p><ul><li>迁移主体资源到国内 CDN 上<ul><li>然后中间因为<a href="https://blog.hanlin.press/2024/07/From-Shanxi-to-Qiniu/">七牛的小插曲</a> ，目前大部分静态资源暂居国内。</li><li>在公共库方面，除了已经死半截的 jsDelivr 和饿了么的 unpkg 镜像，<a href="https://www.yuque.com/egg/cnpm/files">npmmirror</a> 也提供了一个可以直接访问前端文件的接口，在阿里云 CDN 的加持下全球访问速度都比较可观。（尽管因为被大文件滥用开启了审核机制，但已有的白名单足以覆盖大部分前端直接调用的库）</li></ul></li><li>迁移 Waline 到 v3，该修改已提交到上游</li><li>同步了上游 <code>gallery</code> 标签和 <code>header</code> 标签</li><li>还同步了个视频标签</li></ul><p>下面着重说明一下这两个标签：</p><p><code>gallery</code> 标签的使用方法可以参照 <a href="https://xaoxuu.com/blog/20231223/">上游的介绍</a> ，但因为和 <code>parse_markdown</code> 功能冲突（解析图片为 img 标签以供 fancybox 等使用，默认启用），于是砍掉了不那么重要的 desc 块，直接在标签内一行一个链接即可，参数部分设定同上游。</p><p>使用例：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><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><span class="line">&#123;% gallery layout:flow %&#125;</span><br><span class="line">https://images.unsplash.com/photo-1565992441121-4367c2967103?w=800&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8c3BvcnR8ZW58MHx8MHx8fDA%3D</span><br><span class="line">https://images.unsplash.com/photo-1568112505030-08084a1b5215?w=800&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHx0b3BpYy1mZWVkfDh8Ym84alFLVGFFMFl8fGVufDB8fHx8fA%3D%3D</span><br><span class="line">https://images.unsplash.com/photo-1582550945154-66ea8fff25e1?w=800&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHx0b3BpYy1mZWVkfDQyfGJvOGpRS1RhRTBZfHxlbnwwfHx8fHw%3D</span><br><span class="line">https://images.unsplash.com/photo-1703100941710-3b860fa37415?w=800&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHxzZWFyY2h8MjcwfHwyMDI0fGVufDB8fDB8fHww</span><br><span class="line">https://images.unsplash.com/photo-1533274221104-015a584a1005?w=800&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHx0b3BpYy1mZWVkfDEzMHxibzhqUUtUYUUwWXx8ZW58MHx8fHx8</span><br><span class="line">https://images.unsplash.com/photo-1702906221006-97bc40420704?w=800&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHw4MXx8fGVufDB8fHx8fA%3D%3D</span><br><span class="line">https://images.unsplash.com/photo-1703081397398-6156621d25ce?w=800&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHw4OHx8fGVufDB8fHx8fA%3D%3D</span><br><span class="line">https://images.unsplash.com/photo-1539604214100-ab860d9082e0?w=800&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHx0b3BpYy1mZWVkfDEzM3xibzhqUUtUYUUwWXx8ZW58MHx8fHx8</span><br><span class="line">https://images.unsplash.com/photo-1634962453938-85e70143f61d?w=800&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHx0b3BpYy1mZWVkfDQ2fGJvOGpRS1RhRTBZfHxlbnwwfHx8fHw%3D</span><br><span class="line">https://images.unsplash.com/photo-1702952084675-b5489e589251?w=800&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHwxMTF8fHxlbnwwfHx8fHw%3D</span><br><span class="line">&#123;% endgallery %&#125;</span><br></pre></td></tr></table></figure><div class="tag-plugin gallery flow-box" size="mix" ratio="square"><div class="flow-cell"><img src="https://images.unsplash.com/photo-1565992441121-4367c2967103?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8c3BvcnR8ZW58MHx8MHx8fDA%3D" fancybox="true"/></div><div class="flow-cell"><img src="https://images.unsplash.com/photo-1568112505030-08084a1b5215?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHx0b3BpYy1mZWVkfDh8Ym84alFLVGFFMFl8fGVufDB8fHx8fA%3D%3D" fancybox="true"/></div><div class="flow-cell"><img src="https://images.unsplash.com/photo-1582550945154-66ea8fff25e1?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHx0b3BpYy1mZWVkfDQyfGJvOGpRS1RhRTBZfHxlbnwwfHx8fHw%3D" fancybox="true"/></div><div class="flow-cell"><img src="https://images.unsplash.com/photo-1703100941710-3b860fa37415?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MjcwfHwyMDI0fGVufDB8fDB8fHww" fancybox="true"/></div><div class="flow-cell"><img src="https://images.unsplash.com/photo-1533274221104-015a584a1005?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHx0b3BpYy1mZWVkfDEzMHxibzhqUUtUYUUwWXx8ZW58MHx8fHx8" fancybox="true"/></div><div class="flow-cell"><img src="https://images.unsplash.com/photo-1702906221006-97bc40420704?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHw4MXx8fGVufDB8fHx8fA%3D%3D" fancybox="true"/></div><div class="flow-cell"><img src="https://images.unsplash.com/photo-1703081397398-6156621d25ce?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHw4OHx8fGVufDB8fHx8fA%3D%3D" fancybox="true"/></div><div class="flow-cell"><img src="https://images.unsplash.com/photo-1539604214100-ab860d9082e0?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHx0b3BpYy1mZWVkfDEzM3xibzhqUUtUYUUwWXx8ZW58MHx8fHx8" fancybox="true"/></div><div class="flow-cell"><img src="https://images.unsplash.com/photo-1634962453938-85e70143f61d?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHx0b3BpYy1mZWVkfDQ2fGJvOGpRS1RhRTBZfHxlbnwwfHx8fHw%3D" fancybox="true"/></div><div class="flow-cell"><img src="https://images.unsplash.com/photo-1702952084675-b5489e589251?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHwxMTF8fHxlbnwwfHx8fHw%3D" fancybox="true"/></div></div><p><code>banner</code> 就没什么好说的，直接参照上游用法就行。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">&#123;% banner abc1763613206 这是个人简介 avatar:https://hanlin.press/images/logo/avatar.png bg:https://images.unsplash.com/photo-1702952084675-b5489e589251?w=800&amp;auto=format&amp;fit=crop&amp;q=60&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHwxMTF8fHxlbnwwfHx8fHw%3D %&#125;</span><br><span class="line">&#123;% endbanner %&#125;</span><br></pre></td></tr></table></figure><div class="tag-plugin banner"><img class="bg" src="https://images.unsplash.com/photo-1702952084675-b5489e589251?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHwxMTF8fHxlbnwwfHx8fHw%3D"><div class="content"><div class="top">    <button class="back cap" onclick="window.history.back()">      <svg aria-hidden="true" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M7.78 12.53a.75.75 0 01-1.06 0L2.47 8.28a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L4.81 7h7.44a.75.75 0 010 1.5H4.81l2.97 2.97a.75.75 0 010 1.06z"></path></svg>    </button>    </div><div class="bottom"><img class="avatar" src="https://hanlin.press/images/logo/avatar.png"><div class="text-area"><div class="text title">abc1763613206</div><div class="text subtitle">这是个人简介</div></div></div></div></div><h2 id="一些其他的事"><a href="#一些其他的事" class="headerlink" title="一些其他的事"></a>一些其他的事</h2><h3 id="持续部署与修改时间"><a href="#持续部署与修改时间" class="headerlink" title="持续部署与修改时间"></a>持续部署与修改时间</h3><p>静态博客的一个特点是可以在 GitHub 进行同步，然后让 Actions 部署完传上去。</p><p>主题的 config 里预留了一个 <code>footer.more</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><span class="line"><span class="attr">footer:</span></span><br><span class="line"><span class="attr">more:</span> <span class="string">&#x27;我是一行&#x27;</span></span><br><span class="line"><span class="comment"># 或者</span></span><br><span class="line"><span class="attr">more:</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">&#x27;我是第一行&#x27;</span></span><br><span class="line">    <span class="bullet">-</span> <span class="string">&#x27;**我是第二行**&#x27;</span> <span class="comment"># 我修改了下主题让它按 Markdown 解析</span></span><br></pre></td></tr></table></figure><p>利用这个特性可以在博客最下方显示构建信息，我是在 GitHub Actions 中这么设定的：</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></pre></td><td class="code"><pre><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Generate</span> <span class="string">Footer</span></span><br><span class="line">      <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line">        <span class="string">MESSAGEFOOTER=&quot;当前博客版本</span> <span class="string">$(git</span> <span class="string">rev-parse</span> <span class="string">--short=7</span> <span class="string">HEAD)</span> <span class="string">，构建于</span> <span class="string">$(date)&quot;</span> <span class="string">yq</span> <span class="string">-i</span> <span class="string">&quot;.footer.more=strenv(MESSAGEFOOTER)&quot;</span> <span class="string">_config.stellaris.yml</span></span><br></pre></td></tr></table></figure><p>而另一方面，使用 GitHub Actions 每次重新拉取文件，会使文件的修改时间改变，<strong>从而导致每次 Hexo 都认为所有文章在构建时都修改了一遍</strong>。对于这个问题暂时无解，目前通过在 frontmatter 人工追加一个 <code>updated</code> 解决。</p><h3 id="后续的想法"><a href="#后续的想法" class="headerlink" title="后续的想法"></a>后续的想法</h3><ul><li>原主题上游还有一些比较好的更新，比如将前端加载的脚本进一步拆分，只在调用到对应标签时才加载；又比如统一图标到一个 <code>icons.yml</code> 中去。这个首先看看原主题作者的适配意愿（毕竟从 ejs 到 jsx 跨了一个技术栈），然后再看看有没有开发余力。</li><li><del><strong>左边栏的目录层级明显是坏的</strong>，抽空再看看。</del></li><li><a href="https://app.haikei.app/">Haikei</a> 真的很适合生成背景，本文章头图背景采用这玩意生成。</li><li>终究还是走了一遍 <a href="https://blog.hanlin.press/2022/06/bikeshedding-joy/#02-%E6%B8%94%E5%85%B7%E7%96%B2%E5%8A%B3">鱼塘之乐</a> 的回圈。</li></ul><p>upd：醒来拆了一下目录部分的源码，豁然开朗：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Migrate-to-Hexo/image-20240827132256361.png"/></div></div><p>本意上是想拿掉 Hexo 内置 tocHelper 的外层 toc 标签再自己套上一层，然后<strong>传奇 <code>&lt;/ol&gt;</code> 替换高手直接干掉了所有的 <code>&lt;/ol&gt;</code> 闭合标签</strong>：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Migrate-to-Hexo/image-20240827132443794.png"/></div></div><p>于是从第一层之后的目录层级全部爆炸，被逆天替换玩法气晕。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Migrate-to-Hexo/image-20240827132836906.png"/></div></div><p>拿 <code>html-react-parser</code> 解析一下然后直接取孩子好了，PR 交在了 <a href="https://github.com/chiyuki0325/hexo-theme-stellaris/pull/16">https://github.com/chiyuki0325/hexo-theme-stellaris/pull/16</a> 。</p><p>另外调试时发现：在 Markdown 中使用跨层级引用（如 <code>h3</code> 套 <code>h5</code>）会直接炸掉 Hexo 自带的 tocHelper，这个问题在向上游贡献前暂时无解。<br>这也是陈年老问题了，见 <a href="https://github.com/hexojs/hexo/issues/2137">https://github.com/hexojs/hexo/issues/2137</a> 。</p>]]></content>
    
    
    <summary type="html">一些迁移的背景，博客生成器的讨论，以及迁移过程中的一些流水账。</summary>
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="运维" scheme="https://blog.hanlin.press/tags/%E8%BF%90%E7%BB%B4/"/>
    
    <category term="Hexo" scheme="https://blog.hanlin.press/tags/Hexo/"/>
    
    <category term="博客" scheme="https://blog.hanlin.press/tags/%E5%8D%9A%E5%AE%A2/"/>
    
    <category term="技术" scheme="https://blog.hanlin.press/tags/%E6%8A%80%E6%9C%AF/"/>
    
  </entry>
  
  <entry>
    <title>验证：所幸，七牛云还是能「防护」住 XFF 的（附后续）</title>
    <link href="https://blog.hanlin.press/2024/07/Apologize-to-Qiniu/"/>
    <id>https://blog.hanlin.press/2024/07/Apologize-to-Qiniu/</id>
    <published>2024-07-26T03:48:23.000Z</published>
    <updated>2024-07-31T11:09:11.000Z</updated>
    
    <content type="html"><![CDATA[<ul><li>upd at 2024&#x2F;7&#x2F;31: 请查看 <a href="#%E5%90%8E%E7%BB%AD">七牛回应的后续</a></li></ul><h2 id="验证"><a href="#验证" class="headerlink" title="验证"></a>验证</h2><p>在 <a href="https://blog.hanlin.press/2024/07/From-Shanxi-to-Qiniu/#%E6%8A%80%E6%9C%AF%E6%80%BB%E7%BB%93">上一篇博文</a> 中，我添加了一个「第三天醒来的事」的章节。原因是发现了 v2ex 上的一个帖子，说阿里云早在2个月前就有了类似的问题。</p><p>我在上文中的表述一直是：<strong>有可能</strong>进一步影响 IP 黑白名单配置。为什么强调是「有可能」呢？因为在那个域名配置卡到今天的情况下，我并没有机会进一步测试。</p><p>目前确定已知的问题是：七牛云（现在又包括阿里云）的日志功能会记下 X-Forwarded-For 伪造的 IP 地址。</p><hr><p>鉴于我上一个域名的配置跨越了一天还未完成。我去借了一个新七牛号把自己的IP段加进了黑名单，好消息是：七牛可以防住。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Apologize-to-Qiniu/image-20240726115755949.png"/></div></div><p>至此，七牛能不能被打穿的问题已经盖棺定论：在<strong>配置好黑名单</strong>的情况下可以防住。由此，对因上一篇博文可能造成的困扰向七牛诚挚致歉。</p><h2 id="后日谈"><a href="#后日谈" class="headerlink" title="后日谈"></a>后日谈</h2><p>尽管这一件事可以让大伙暂时喘口气，但是这件事暴露了更多的问题。</p><ul><li>一部分问题已经在 <a href="https://blog.hanlin.press/2024/07/From-Shanxi-to-Qiniu/#%E6%8A%80%E6%9C%AF%E6%80%BB%E7%BB%93">上一篇博文</a> 有说，阿里云也有相关的问题，从最外层看不见从用户端到 CDN 的全部 IP。</li><li>这个行为保底自 2 个月前就开始了，为什么迟迟防不住？</li><li>一切的一切，背后还有一个可能延宕了数年的问题：这次能加黑名单是因为这个团伙的行为太猖獗，IP 情报早已共享，把一大串复制加上就行；但要是以后，真正的个体使用一台境外服务器对你进行 XFF 刷量，你一边关停着 CDN，一边看着日志里「0.114.5.14」的 IP 暗自发闷。<strong>不对日志机制加以改造，你永远（在前台）拿不到攻击者的真实IP</strong>。</li></ul><p>目前已知阿里云和七牛云存在这个问题了，其他的呢？</p><p>也正因此，七牛（也可能包括一众其他 CDN）在这件事上可能甩不掉锅。</p><h2 id="最后"><a href="#最后" class="headerlink" title="最后"></a>最后</h2><ul><li><p>喜报：七牛的黑名单还是能防住 XFF 伪造的攻击，防护山西联通的当前攻势可能足够。</p></li><li><p>悲报：以后被单一个体攻击的时候，你可要注意日志里显示的究竟是不是真实的 IP 了。因为正如上文所言，分散在一堆正常用户里的零散请求也有可能是单一机器发出来的。</p></li></ul><p>在这件事得到改善之前，我都不建议大家相信后台给出来的任何 IP，原因已经阐述了一次又一次。</p><p>不知道的还以为我是什么大站呢。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Apologize-to-Qiniu/image-20240726122739866.png"/></div></div><p>另外，考虑到攻击多会在非工作日时段进行，务必记得配好告警，然后找个黑名单配置下发没那么慢的 CDN。</p><h2 id="后续"><a href="#后续" class="headerlink" title="后续"></a>后续</h2><p>感谢七牛的商务老师，为本次盗刷申请了一个 500G 的补偿流量包，拿来冲抵本次损失已然足够。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Apologize-to-Qiniu/image-20240731190818018.png"/></div></div><p>工单那边，我的工单已经被标记为 「resolved」，工程师的回复如下：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/Apologize-to-Qiniu/image-20240731190911617.png"/></div></div><blockquote><p>您好，</p><p>非常抱歉之前的日志记录问题，给您带来了困惑和不好的体验。</p><p>日志client_ip输出异常的问题当前已修复，会记录真实客户端请求ip，而不再是由<strong>xxf</strong>伪造的第一段ip，</p><p>可以通过CDN日志看到真实客户端ip，从而配置黑名单对访问进行拦截</p><p>您可以再次验证下，有问题您再反馈这边</p></blockquote><p>考虑到先前的经历，我将在一段时间后启用 CDN 并配置低阈值告警， 查看一下修复后的效果，希望他真的修了吧。</p>]]></content>
    
    
    <summary type="html">幸好幸好。但也并不意味着七牛完全不粘锅了。</summary>
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="杂谈" scheme="https://blog.hanlin.press/tags/%E6%9D%82%E8%B0%88/"/>
    
    <category term="七牛" scheme="https://blog.hanlin.press/tags/%E4%B8%83%E7%89%9B/"/>
    
    <category term="运维" scheme="https://blog.hanlin.press/tags/%E8%BF%90%E7%BB%B4/"/>
    
  </entry>
  
  <entry>
    <title>从山西联通到组播IP：七牛云的奇怪视角（附分析和后日谈）</title>
    <link href="https://blog.hanlin.press/2024/07/From-Shanxi-to-Qiniu/"/>
    <id>https://blog.hanlin.press/2024/07/From-Shanxi-to-Qiniu/</id>
    <published>2024-07-25T12:44:32.000Z</published>
    <updated>2024-07-26T14:34:02.000Z</updated>
    
    <content type="html"><![CDATA[<hr><p>在本文发布时，作者已经在工单渠道反馈过该问题。</p><p>对于任何想要迅速获取事件详情的读者，请直接拉到最后看<a href="#%E6%8A%80%E6%9C%AF%E6%80%BB%E7%BB%93">技术总结</a>。<br>想要看热闹获取更多细节的读者，请继续往下阅读。</p><ul><li>upd at 2024&#x2F;7&#x2F;26: 更新了 <a href="#%E7%AC%AC%E4%B8%89%E5%A4%A9%E9%86%92%E6%9D%A5%E7%9A%84%E4%BA%8B">第三天醒来的事</a></li><li>upd at 2024&#x2F;7&#x2F;26: 请关注 <a href="https://blog.hanlin.press/2024/07/Apologize-to-Qiniu/">后续验证</a></li></ul><hr><h2 id="起因"><a href="#起因" class="headerlink" title="起因"></a>起因</h2><blockquote><p>他只需略微出手，就能让你 CDN 里的一个小文件一天之内被「五湖四海」访问破万次。<br>再给他一个神仙管理端，他能让你的 CDN 配置下发延迟近一倍，享受字(liu)节(liang)跳动的快感。</p><p>不是代理 IP 买不起，而是伪造 Header 更有性价比。<br>对此，五星上将麦克阿瑟曾这样评价：「早知道七牛云有这样的科技，我何必去各个注册局购买稀缺 IP。」<br>大型纪录片《七牛云传奇》持续为您播出。</p></blockquote><p>时间重新倒转到两天前。早就听说有山西联通的 PCDN 团伙为了均衡上传下载量，在搞恶意刷量的行为，从高校镜像站刷到大网站，从大网站刷到小网站。</p><p>对于这事尚不明晰的读者，可以看看下面几个链接：</p><ul><li><p><a href="https://luotianyi.vc/8335.html">【杂谈】近期本站CDN受到流量CC攻击的情况整理</a></p></li><li><p>[<a href="https://www.landiannews.com/archives/105108.html">更新] 山西&#x2F;江苏&#x2F;安徽联通疑似PCDN刷量网段 建议站长及时拉黑这些IP段</a></p></li><li><p><a href="https://www.v2ex.com/t/1055510?p=1">每天晚上 20~23 时准点被山西联通 IP 刷流量现象的分析和猜测</a></p></li></ul><p>然后不出意外地，前天收到告警，去七牛云一看，刷流量的风还是吹到了我身上。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725211223544.png"/></div></div><p>本来寻思是加个黑名单就完事的运维，没想到成了延续到现在的奇葩事。</p><h2 id="并不成功的封堵尝试"><a href="#并不成功的封堵尝试" class="headerlink" title="并不成功的封堵尝试"></a>并不成功的封堵尝试</h2><p>上线之后，看了看被刷的 IP，是我博客之前的一个插图，约 500kB 大。</p><p>这个 CDN 后面连着的是七牛自己的一个存储桶，这个文件并不是里面最大的，但确实可能是公开途径下能找到的，相对较大的文件。</p><p>被刷了怎么办，先学上面几篇博文，加了这么些 IP：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><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><span class="line">221.205.168.0/23</span><br><span class="line">211.90.146.0/24</span><br><span class="line">122.195.22.0/24</span><br><span class="line">118.81.184.0/23</span><br><span class="line">124.163.207.0/24</span><br><span class="line">124.163.208.0/24</span><br><span class="line">183.185.14.0/24</span><br><span class="line">36.35.38.0/24</span><br><span class="line">60.221.195.0/24</span><br><span class="line">60.220.182.0/24</span><br><span class="line">61.160.233.0/24</span><br><span class="line">60.221.231.0/24</span><br></pre></td></tr></table></figure><p>加完之后页面的提示大约是这样的：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725212807858.png"/></div></div><p>然后，15分钟过去了， 这个条依旧没有动的迹象，回去统计一看，又被多刷了约 20G。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725213105216.png"/></div></div><p>后续去查了下系统日志中两个域名的配置执行时间，好家伙，一个比一个重量级：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725215550655.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725215604892.png"/></div></div><p>无奈，只能交个工单催了，然后就出现了以下一幕。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725213021836.png"/></div></div><p>好了，第一个前端看不到的功能出现了。</p><p>因为现在的流量好像根本没拦住，所以我便让技术直接下线了 CDN，然后去 DNS 那里停掉了解析。</p><p>另一方面的补偿，技术说等商务上班再同步，至此工单一环暂时结束。</p><p>封堵基本上是做到了止损状态，可以说是刚好把余额带资源包消耗地差不多，没房子倒是不至于，可以安心跑路。</p><h2 id="日志研究"><a href="#日志研究" class="headerlink" title="日志研究"></a>日志研究</h2><p>因为可以限制 QPS，我去后台统计分析下了份日志，打算研究一下。</p><p>却发现，按照 QPS 来限制根本不可能。</p><table><thead><tr><th>Source IP</th><th>Status</th><th>Time</th></tr></thead><tbody><tr><td>53.165.228.176</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:10</td></tr><tr><td>53.165.228.176</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:10</td></tr><tr><td>53.165.228.176</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:10</td></tr><tr><td>81.13.198.177</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:10</td></tr><tr><td>53.165.228.176</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:10</td></tr><tr><td>53.165.228.176</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:10</td></tr><tr><td>81.13.198.177</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:11</td></tr><tr><td>81.13.198.177</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:11</td></tr><tr><td>81.13.198.177</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:11</td></tr><tr><td>81.13.198.177</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:11</td></tr><tr><td>81.13.198.177</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:11</td></tr><tr><td>39.93.235.12</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:11</td></tr><tr><td>81.13.198.177</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:11</td></tr><tr><td>39.93.235.12</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:12</td></tr><tr><td>39.93.235.12</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:12</td></tr><tr><td>39.93.235.12</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:12</td></tr><tr><td>132.224.75.244</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:12</td></tr><tr><td>39.93.235.12</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:12</td></tr><tr><td>39.93.235.12</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:12</td></tr><tr><td>39.93.235.12</td><td>HIT</td><td>[23&#x2F;Jul&#x2F;2024:21:00:12</td></tr></tbody></table><p>以上是日志的部分切片，可以看到，日志中的每个IP基本上是1s之内发送约10次请求，之后立即轮换到别的 IP.</p><p>而这个 CDN 主要用作网页的静态资源加载功能，按照 1s 10次 的策略进行限制的话，百分百会误杀到正常访问网站的客户。以暂时搬出 CDN 的本站为例，全新渲染一次，便要加载十个以上的 js 和 css（仅包含不方便放在公共静态 CDN 上的魔改文件）。</p><p>分析完之后有些好奇，去纯真 IP 库查了查这几个 IP 的来源：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">53.165.228.176 - 德国 奔驰汽车</span><br><span class="line">81.13.198.177 - 瑞士</span><br><span class="line">39.93.235.12 - 山东省临沂市 联通</span><br></pre></td></tr></table></figure><p>我此刻的内心已经是：哈？</p><p>继续翻看日志，一条 IP 映入我的眼帘：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">0.158.142.12 HIT 60 [23/Jul/2024:21:11:37 +0800] &quot;GET https://cdn./wp-content/uploads/2022/12/ HTTP/1.1&quot; 200 561208 &quot;https://cdn./wp-content/uploads/2022/12/&quot; &quot;Mozilla/5.0 (Macintosh; U; Intel Mac OS X; en) AppleWebKit/419.3 (KHTML, like Gecko) Safari/419.3&quot;</span><br></pre></td></tr></table></figure><p>一下就把晚上十点的我干醒了，于是用 Excel 的数据透视表功能去了个重，发现了如下货色：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><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><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br></pre></td><td class="code"><pre><span class="line">0.111.196.55</span><br><span class="line">0.116.60.85</span><br><span class="line">0.144.42.232</span><br><span class="line">0.154.158.185</span><br><span class="line">0.158.142.12</span><br><span class="line">0.177.35.80</span><br><span class="line">0.182.166.156</span><br><span class="line">0.234.236.74</span><br><span class="line">0.252.205.185</span><br><span class="line">0.34.82.77</span><br><span class="line">0.40.251.18</span><br><span class="line">0.52.122.102</span><br><span class="line">0.60.116.133</span><br><span class="line">0.75.211.6</span><br><span class="line">0.8.192.236</span><br><span class="line">0.84.207.198</span><br><span class="line">0.92.183.218</span><br><span class="line">0.93.164.77</span><br><span class="line">1.103.194.250</span><br><span class="line">...</span><br><span class="line">10.1.14.148</span><br><span class="line">10.104.45.33</span><br><span class="line">10.126.42.144</span><br><span class="line">10.164.106.0</span><br><span class="line">10.18.220.123</span><br><span class="line">10.181.176.62</span><br><span class="line">10.182.9.167</span><br><span class="line">10.197.17.132</span><br><span class="line">10.20.104.168</span><br><span class="line">10.238.149.150</span><br><span class="line">10.249.26.29</span><br><span class="line">10.34.112.50</span><br><span class="line">10.48.72.0</span><br><span class="line">10.60.61.114</span><br><span class="line">10.71.43.19</span><br><span class="line">10.8.220.210</span><br><span class="line">10.82.138.113</span><br><span class="line">10.9.178.252</span><br><span class="line">10.96.71.60</span><br><span class="line">100.153.101.189</span><br><span class="line">100.154.41.99</span><br><span class="line">100.184.125.18</span><br><span class="line">100.215.147.100</span><br><span class="line">100.242.101.30</span><br><span class="line">100.245.117.202</span><br><span class="line">100.249.21.122</span><br><span class="line">100.27.239.17</span><br><span class="line">100.39.93.28</span><br><span class="line">100.59.203.6</span><br><span class="line">100.70.141.101</span><br><span class="line">101.111.3.65</span><br><span class="line">101.128.82.159</span><br><span class="line">101.130.240.252</span><br><span class="line">101.133.124.76</span><br><span class="line">101.169.35.8</span><br><span class="line">101.207.76.136</span><br><span class="line">101.215.66.131</span><br><span class="line">101.220.92.197</span><br><span class="line">101.223.179.49</span><br><span class="line">101.231.99.114</span><br><span class="line">101.238.124.1</span><br><span class="line">101.254.211.166</span><br><span class="line">101.40.171.194</span><br><span class="line">101.46.48.217</span><br><span class="line">101.74.86.45</span><br><span class="line">101.80.115.130</span><br><span class="line">102.131.48.103</span><br><span class="line">102.156.58.209</span><br><span class="line">102.162.93.243</span><br><span class="line">102.176.161.142</span><br><span class="line">102.19.9.164</span><br><span class="line">102.233.108.173</span><br><span class="line">102.235.74.147</span><br><span class="line">102.247.40.244</span><br><span class="line">102.251.141.14</span><br><span class="line">102.29.115.58</span><br><span class="line">102.74.71.209</span><br><span class="line">102.88.215.31</span><br><span class="line">103.115.169.89</span><br><span class="line">103.143.139.146</span><br><span class="line">103.171.252.70</span><br><span class="line">103.179.245.11</span><br><span class="line">103.197.205.178</span><br><span class="line">103.218.249.175</span><br><span class="line">103.244.158.232</span><br><span class="line">103.4.114.171</span><br><span class="line">103.44.100.29</span><br><span class="line">103.45.21.188</span><br><span class="line">103.75.54.127</span><br><span class="line">104.108.66.143</span><br><span class="line">104.153.174.161</span><br><span class="line">104.155.134.65</span><br><span class="line">104.190.29.135</span><br><span class="line">104.191.130.191</span><br><span class="line">104.220.130.105</span><br></pre></td></tr></table></figure><p>好家伙，0开头的IP，内网IP，保留IP齐上阵，一度让我觉得学的网络原理知识还给老师了。</p><p>从上到下看，发现IP段从 100 一路轮到了 254.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">254.121.107.22</span><br><span class="line">254.129.171.195</span><br><span class="line">254.135.153.178</span><br><span class="line">254.141.129.153</span><br><span class="line">254.147.194.225</span><br><span class="line">254.192.123.127</span><br><span class="line">254.192.234.110</span><br><span class="line">254.20.230.168</span><br></pre></td></tr></table></figure><p>从任何角度出发，我的理智都已经告诉我这些不是正常的 IP，首先的猜想便是：Hackergame 2021 的题目 <a href="https://github.com/USTC-Hackergame/hackergame2021-writeups/tree/master/official/FLAG%20%E5%8A%A9%E5%8A%9B%E5%A4%A7%E7%BA%A2%E5%8C%85">FLAG 助力大红包</a> 竟然照进了现实。</p><p>在此引入该题题解部分内容：</p><blockquote><p>即便没有上面这一步，应当可以很快意识（也许很难）到使用好友助力或使用代理池是不可行的，这是由于不是全部 IPv4 &#x2F;8 地址块是可用的。这包括：</p><ol><li><a href="https://datatracker.ietf.org/doc/html/rfc1700">RFC1700</a> 定义的 <strong>0.0.0.0&#x2F;8</strong> 和回环地址 <strong>127.0.0.0&#x2F;8</strong>。</li><li><a href="https://datatracker.ietf.org/doc/html/rfc1918">RFC1918</a> 定义的私有地址 <strong>10.0.0.0&#x2F;8</strong>、192.168.0.0&#x2F;16 ，172.16.0.0&#x2F;12</li><li><a href="https://datatracker.ietf.org/doc/html/rfc3171">RFC3171</a> 定义的组播地址 <strong>224.0.0.0&#x2F;4</strong></li><li><a href="https://datatracker.ietf.org/doc/html/rfc1700">RFC1700</a> 定义的保留地址 <strong>240.0.0.0&#x2F;4</strong></li><li>用于特殊网络的 <strong>14.0.0.0&#x2F;8</strong> , <strong>24.0.0.0&#x2F;8</strong> 以及 <strong>39.0.0.0&#x2F;8</strong></li><li>美国国防部宣告的大量 IPv4 &#x2F;8 网段，例如 <strong>6.0.0.0&#x2F;8</strong> ， <strong>7.0.0.0&#x2F;8</strong> 等等 十几个 &#x2F;8 网段</li></ol><p>详细信息可参考 <a href="https://datatracker.ietf.org/doc/html/rfc3330">RFC3330</a>.</p></blockquote><p>此时七牛后台的日志分析长这样：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725220306705.png"/></div></div><p>我把我的疑问发在工单里，得到了技术这样的回复：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725220409293.png"/></div></div><p>七牛的后台里，黑白名单数量只能添加500条，如果真的把这些「轮段」IP按技术所说的加入黑名单，先不说是不是还会被卡住，保底也得把整个互联网完全封掉。</p><p>就在此时，伟大的技术工程师掏出了前端没有的第二个功能。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725221758357.png"/></div></div><p>这明显无法解决问题的根源。我要求将问题上报，技术回复等明天上班时间。加之 CDN 的实际消耗需要等第二天8点才能出账，无奈只好先放下这事，睡觉。</p><h2 id="对线？"><a href="#对线？" class="headerlink" title="对线？"></a>对线？</h2><p>时间拨到第二天：没睡好。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725220947989.png"/></div></div><p>醒来之后看了看用量统计，还好，没破产。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725221112784.png"/></div></div><p>但此时的问题转向了：先前的日志统计里绝大部分都是海外IP，为什么在这里抵扣的却是中国大陆资源包呢？</p><p>晚些时候，商务的电话打过来了，我把问题重新和他理了一遍，他表示会同步给技术，然后就收到了技术这样的回复。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725221618592.png"/></div></div><p>好吧，第三个前端看不到的功能出现了。</p><p>此时此刻，技术明显没有理解我的意思，麻烦了商务老师，我要求和他重新同步我的疑问。</p><p>之后便是包括工单和400电话的对线垃圾时间，来回太过冗长，此处仅展示七牛云方面的信息：</p><ol><li>页面标记的「配置8-15分钟」<strong>只是预估</strong>。实际无法为此提供任何担保。当发现配置超时时用户须提交工单让人工介入。</li><li>日志里标记海外但是却消耗国内资源包，是因为海外访问到了国内的IP。</li></ol><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725222043372.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725222335065.png"/></div></div><p>从道理上这些都能理解，但是这一切的一切显然都是建立在 IP 来源统计正确的情况下。</p><p><strong>我没受过严格的训练，我不知道他看到「未知」来源消耗了10多G流量以及日志中0.开头的IP时，怎么能忍住不笑的。</strong></p><h2 id="真相大白"><a href="#真相大白" class="headerlink" title="真相大白"></a>真相大白</h2><p>后来，我发现工单技术从后台导出的日志似乎比我前台下到的详细得多，就让他导出了一份23号的日志，就是昨天0.开头IP现场的日志。</p><p>拿到日志后，我发现一切真相大白。</p><h3 id="详细的日志分析"><a href="#详细的日志分析" class="headerlink" title="详细的日志分析"></a>详细的日志分析</h3><p>此部分希望能成为其他站长防御的参考。</p><p>首先来看 UA 部分：</p><table><thead><tr><th>ua</th></tr></thead><tbody><tr><td>Mozilla&#x2F;5.0 (Macintosh; U; PPC Mac OS X;  en) AppleWebKit&#x2F;125.5.6 (KHTML, like Gecko) Safari&#x2F;125.12</td></tr><tr><td>Mozilla&#x2F;5.0 (iPad; CPU OS 10_3_2 like Mac  OS X) AppleWebKit&#x2F;603.2.4 (KHTML, like Gecko) Mobile&#x2F;14F89</td></tr><tr><td>Mozilla&#x2F;5.0 (Windows; U; Windows NT 6.1;  en-US) AppleWebKit&#x2F;534.17 (KHTML, like Gecko) Chrome&#x2F;10.0.649.0 Safari&#x2F;534.17</td></tr><tr><td>Mozilla&#x2F;5.0 (iPad; CPU OS 9_3 like Mac OS  X) AppleWebKit&#x2F;601.1.46 (KHTML, like Gecko) Version&#x2F;9.0 Mobile&#x2F;13E230  Safari&#x2F;601.1</td></tr><tr><td>Mozilla&#x2F;5.0 (iPhone; CPU iPhone OS 12_0  like Mac OS X) AppleWebKit&#x2F;605.1.15 (KHTML, like Gecko) Mobile&#x2F;16A366  Instagram 65.0.0.12.86 (iPhone7,2; iOS 12_0; es_CO; es-CO; scale&#x3D;2.00;  gamut&#x3D;normal; 750x1334; 125889668)</td></tr><tr><td>Mozilla&#x2F;5.0 (iPhone; CPU iPhone OS 12_0_1  like Mac OS X) AppleWebKit&#x2F;604.1.34 (KHTML, like Gecko) GSA&#x2F;60.0.215960477  Mobile&#x2F;16A404 Safari&#x2F;604.1</td></tr></tbody></table><p>能够看到基本上是随机的 UA ，除基本特征外与浏览器和设备相关的数字都经过了随机化，基本不可能拦截。</p><p>Referer 部分和原 CDN 链接相同，设置「空 Referer」拦截并不能成功避免，设置泛域名更不行。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725223234305.png"/></div></div><p>这些都是小菜，直到看到了 <code>X-Forwarded-For</code> 列后，我释然了：</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725223402349.png"/></div></div><p>最右边谁啊，<code>60.221</code> 开头，这不就是臭名昭著的山西联通 IP 段嘛。</p><p>很明显，此处通过伪造 XFF 的请求头，实现了伪造 IP 的目的，并进一步欺骗到了七牛的系统。</p><h3 id="最后"><a href="#最后" class="headerlink" title="最后"></a>最后</h3><p>省去一大截沟通流程，有效信息如下：</p><ul><li>七牛的策略认后面的 <code>client_ip</code> ，即被伪造的 IP。（IP黑白名单可能也是认这个 <code>client_ip</code> ，但我没有确认*）</li><li>七牛方面依旧是建议使用 CPS 、限速限制、或者封堵海外访问。对于 <code>X-Forwarded-For</code> 请求头这个问题，七牛方面暂无解决方案，建议我使用回源鉴权功能。</li></ul><p>*最后的最后，可能有读者会有疑问：说的那么玄乎，为什么不亲手验证下呢？</p><p>说得好，我也想。本着报道要讲事实的原则，我把自己的 IP 段扔进了另一个·域名的黑名单，想要实地模拟一下享用 0. IP的感觉。</p><p>我从开始写这篇文章的时候加上了请求，截至我写到现在，系统的自动配置仍未完成。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725225014920.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725224939530.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240725224950040.png"/></div></div><h2 id="技术总结"><a href="#技术总结" class="headerlink" title="技术总结"></a>技术总结</h2><ul><li>可能因为 XFF 欺骗漏洞，七牛 CDN 对于客户端 IP 的判定有<strong>非常大</strong>的问题，这会导致日志中的 IP 信息可被轻易伪造，<del>并有可能进一步影响 IP 黑白名单配置</del>。</li><li>发现 CDN 配置超出页面显示的时间后，务必去工单催一下。</li><li>七牛 CDN 有些限流功能并未在前台展现，包括基于 QPS 的封禁、全站带宽限速、阻断境外访问，这些功能需要通过工单申请。</li></ul><p>*标注<em>可能</em>的原因是我不了解真实的情况，或者不方便验证，不对相关言论负责。</p><h2 id="第三天醒来的事"><a href="#第三天醒来的事" class="headerlink" title="第三天醒来的事"></a>第三天醒来的事</h2><p>博客文章被转出去，溜 v2ex 的过程中看到了这么一篇：<a href="https://v2ex.com/t/1045318">公司的阿里云 CDN 每晚都在被偷偷刷量</a>。</p><p>几乎拿了完全一样的剧本：奇怪的IP、工单的顾左右而言他。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240726111605816.png"/></div></div><p>这篇文章导向了阿里云的一个帮助文档：<a href="https://help.aliyun.com/zh/cdn/user-guide/download-logs">https://help.aliyun.com/zh/cdn/user-guide/download-logs</a></p><p>该文章里直球表示了，我们日志里拿 IP 就是取 XFF 第一个。</p><div class="tag-plugin image"><div class="image-bg"><img src="/uploads/From-Shanxi-to-Qiniu/image-20240726111502261.png"/></div></div><p>这里想要拿到真实IP，按原帖的说法，只能使用远程鉴权和实时日志功能，人力成本不可谓不高。</p><p>上回中已经详细分析了，只要 XFF 是伪造的，该种判断逻辑就<strong>没有任何意义</strong>。</p><p>从 v2ex 的发布时间来看，这次攻击从两个月前就开始了，因此如果阿里云&#x2F;七牛云的防护是通过判断<strong>完整的</strong> XFF 完成的，那也是<strong>可以防护</strong>的。</p><p>一切的一切，还是建议给CDN上个告警防护，阈值设低点早发现比什么都强。</p><p>另外都到这一步那必须得测测了，楼上的那个测试域名到现在还没配置好，让我去借个号再回来。</p>]]></content>
    
    
    <summary type="html">对此，五星上将麦克阿瑟曾这样评价：「早知道七牛云有这样的科技，我何必去各个注册局购买稀缺 IP。」大型纪录片《七牛云传奇》持续为您播出。</summary>
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="杂谈" scheme="https://blog.hanlin.press/tags/%E6%9D%82%E8%B0%88/"/>
    
    <category term="七牛" scheme="https://blog.hanlin.press/tags/%E4%B8%83%E7%89%9B/"/>
    
    <category term="运维" scheme="https://blog.hanlin.press/tags/%E8%BF%90%E7%BB%B4/"/>
    
  </entry>
  
  <entry>
    <title>消失的 2023 B站百大报告</title>
    <link href="https://blog.hanlin.press/2024/01/2023bpu-gugugu/"/>
    <id>https://blog.hanlin.press/2024/01/2023bpu-gugugu/</id>
    <published>2024-01-03T06:18:41.000Z</published>
    <updated>2024-01-03T06:18:41.000Z</updated>
    
    <content type="html"><![CDATA[<p>今年 B 站为查 UP 主投稿视频的 API 加入了强风控校验，除了 link 多了一堆缺一不行的奇妙参数外，浏览器正常访问多了都会跳滑动验证码。 与此同时，LKs 那边不知道从哪边渠道（运营？第三方数据站？）薅来了一组差不多的数据，出了个不痛不痒视频： 【首次揭秘：B站百大是怎么选出来的？-哔哩哔哩】 <a href="https://www.bilibili.com/video/BV1Ec411k7Yj/">https://www.bilibili.com/video/BV1Ec411k7Yj/</a> 今年的版本答案就不忙了，看这边吧。</p><hr><p>频道评论区里有几个解决方案，但我遇到的更多的还是访问次数多了跳滑动验证码窗口。 累了，等其他人来搞了 顺道一提，风控模型应该是早就迭代了，我现在改个 wts 都给我爆访问权限不足，更别提什么 w_rid </p><p>补充一句，上面那个 b23.tv 都在查我 Referer ，流汗黄豆.webp</p>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;今年 B 站为查 UP 主投稿视频的 API 加入了强风控校验，除了 link 多了一堆缺一不行的奇妙参数外，浏览器正常访问多了都会跳滑动验证码。 与此同时，LKs 那边不知道从哪边渠道（运营？第三方数据站？）薅来了一组差不多的数据，出了个不痛不痒视频： 【首次揭秘：B站百</summary>
      
    
    
    
    <category term="杂记" scheme="https://blog.hanlin.press/categories/%E6%9D%82%E8%AE%B0/"/>
    
    
    <category term="B站" scheme="https://blog.hanlin.press/tags/B%E7%AB%99/"/>
    
    <category term="爬虫" scheme="https://blog.hanlin.press/tags/%E7%88%AC%E8%99%AB/"/>
    
  </entry>
  
  <entry>
    <title>2023：走在参差的影子之下</title>
    <link href="https://blog.hanlin.press/2024/01/2023-annual-report/"/>
    <id>https://blog.hanlin.press/2024/01/2023-annual-report/</id>
    <published>2023-12-31T17:12:52.000Z</published>
    <updated>2023-12-31T17:12:52.000Z</updated>
    
    <content type="html"><![CDATA[<p>23.12.31 晚的岛城，零下一度，风有点大，偏冷。关于跨年夜的行迹，宿舍里鲜明地分成了三派：「约会通宵派」、「自习室内卷派」、「宿舍躺平派」。<br>今年的我没有像去年一样走在回老家的高速公路上，而可能属于折中派：摇了中学同学出门吃了自助小火锅，然后因为怕冷没去海边看烟花秀。    </p><p>关于今年年终总结的关键词，我认为可以定为**「参差」**。    </p><p>参差是一词多义的。<br>从小家里人便有一个教诲：不要活在别人的影子之下。但回望今年，我感受到的是一种鲜明的「参差」。何谓「参差」，水平不一罢了。大场合下的社恐，真正接手项目时感受到的技不如人，汇成了一片影子，2023 年的基调，可能可以概括成走在了参差的影子之下。<br>另一方面，参差的影子实际上已经有了另外一层含义，你所遇到的其实并不全是「影子」。</p><h2 id="告别"><a href="#告别" class="headerlink" title="告别"></a>告别</h2><h3 id="柯伟德（Covid）时代"><a href="#柯伟德（Covid）时代" class="headerlink" title="柯伟德（Covid）时代"></a>柯伟德（Covid）时代</h3><p>2023 年迎来了疫情的全面放开，因此去年年末这种一路把课推到19周的情况大抵是不会再存在了。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/12/wp_editor_md_b729612182dad90da042212f975e75da.jpg"/></div></div>   <p>而我也终于在 2023 年 1 月 1 日，顶着当时热门的「阳后洗澡得肺炎」的论调，在确实有些喘的情况下洗了本年的第一次热水澡。</p><p>时间转到 2023 年下半年，支原体与病毒性肺炎混合传播，一时间班级的到课率反而有新冠疫情绝赞复刻的味儿，不过对我而言，也就是吃了四天的左氧氟沙星后继续上课。  </p><p>学校里的核酸检测亭一个都没撤，隔着窗户能看到里面的箱子落了灰。   </p><h3 id="老服务器"><a href="#老服务器" class="headerlink" title="老服务器"></a>老服务器</h3><p>2023 年年中，阿里云「云翼计划」续的便宜学生机终于到期，于是换到了别的服务器上。然后想写年度总结的时候发现，搬服务器前的大日志丢了。<br>于是当看到阿里云下半年搞了个大活动打算从腾讯云手里重新抢回大学生的时候，感觉还挺好笑的。  </p><p>CFRating 服务器逐渐不堪重负（以及国内连 Codeforces）越来越难以言说，机缘巧合下看到蔡队（<a href="https://cubercsl.site/">@cubercsl</a>）搞了个 cf worker ，于是决定直接用 302 的方式把存量迁移过去。<br>然后某日发现 jiangly 老师的洛谷首页挂了我的 API，然后摁开 F12 发现请求跳了三层：</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/12/wp_editor_md_a584e7a89e9566d71a6ec968d19da7b3.jpg"/></div></div><p>问了问蔡队，才发现他那里也锅了，请求被扔到了 baoshuo 那里，我何德何能，哈哈。      </p><h3 id="逝去的热点与往事和回旋镖"><a href="#逝去的热点与往事和回旋镖" class="headerlink" title="逝去的热点与往事和回旋镖"></a>逝去的热点与往事和回旋镖</h3><p>翻了翻频道，快速过一下我脑海里还有参与感，但到了跨年时似乎消散的热点与往事。</p><ul><li>1 月，鹅鸭杀与飞跃13号房热播，一遍通过拿到了驾照，在老家吃到了传统棉花糖，《三体》电视剧与《流浪地球2》，初次体验围炉炖鸡。</li><li>2 月，把闲置的甲骨文重新入网了 DN42，拿 Stable Diffusion 画了自己的 AI 头像，原子之心，餐桌上的广告还是孤独摇滚。</li><li>3 月，办了年卡爬崂山，拿到了自己设计的 Hackergame 2022 的文化衫，餐桌上的广告换成了艺画开天版《三体》，第一次品尝九转大肠，去了《铃兰之旅》的奈雪店，拼多多的系统后门。</li><li>4 月，第一次学校收信测试成功，第二三四次明信片，小米手环7砖了，网易云告诉我说我号注册10年了，星穹铁道。</li><li>5 月，在奥帆中心寄出明信片，和华硕在线客服 battle 却发现邮件系统拒收了文件大小过大的日志，开始搬服务器。</li><li>6 月，蓝桥杯，RoboMaster，山东省赛，逆天高考题，给笔记本换了 32G 内存，某个现已润美的群友发现竟然是带学同学。</li><li>7 月，爆炸校园网，半次元倒闭，冲线的 Qt 期末作业，巨高的投档线，「我和一群前途的光明的人在一起，以为自己也有光明的未来」，换了小米 12S Pro。</li><li>8 月，卡拉彼丘，夜袭 2km 外的雪糕批发店，maimai 上到万分，B站直播未登录无法查看用户昵称。</li><li>9 月，蔚蓝档案，换了小米显示器，Google Domains 倒闭，廉价的土区域名没了。</li><li>10 月，招行银行卡被非柜限额，开始推大创项目，发现 CS 的尽头是从二次元资料页到实名出道，打了 <a href="https://blog.hanlin.press/2023/11/hackergame-writeup-2023/">Hackergame 2023</a>，在小红书上看到了提示。</li><li>11 月，《中国游戏纪事》，摸到了尘封 5 年的校内 OJ 服务器，两周内连续触发不同病因重感冒两次。</li><li>12 月，明日方舟桌游，窜访鳌山湾，网易云关键词「未来」。</li></ul><h2 id="幸遇"><a href="#幸遇" class="headerlink" title="幸遇"></a>幸遇</h2><h3 id="网站-与-API"><a href="#网站-与-API" class="headerlink" title="网站 与 API"></a>网站 与 API</h3><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2024/01/wp_editor_md_d92acddd9cccf3cadecd68981b1beac7.jpg"/></div></div><p>感谢两千多个大伙光顾博客，其中的绝大部分直奔甲骨文跑方舟的那篇文章。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2024/01/wp_editor_md_b2c6b29fb13ebec72d0ea46127a2b7f2.jpg"/></div></div><p>搬迁（2023年5月30日）前的 CFRating 累计访问量达 300 万，感谢各位四处传播我的玩具项目。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2024/01/wp_editor_md_dfbfa6e48ccc8a452343a2b83e7b4cea.jpg"/></div></div><p>搬迁后下半年的 CFRating 请求仍旧到达 66 万，感谢蔡老师的接盘。</p><h3 id="约饭"><a href="#约饭" class="headerlink" title="约饭"></a>约饭</h3><p>本年开始高强度参与了岛蓝活动，约了好几次饭也参与了一些 Ingress 活动。<br>具体不想统计了，详见北蓝公众号下以 「青岛」 开头的文章。</p><p>真正意义上的网友约饭仅有 FlyingSky 一例，比较遗憾（</p><h3 id="设计"><a href="#设计" class="headerlink" title="设计"></a>设计</h3><p>本年度的「Design」文件夹里共有 13 个小项目和 8 个私货，虽然明显到不了大佬的设计水准，但是自用似乎够了。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2024/01/wp_editor_md_5c0d15ea3d6c26b036c2e1374080cafc.jpg"/></div></div><h3 id="频道"><a href="#频道" class="headerlink" title="频道"></a>频道</h3><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2024/01/wp_editor_md_d1aa25a639d3e9af67de360475ba42a6.jpg"/></div></div><p>今年的发帖数量相比去年直接折半，感谢各位读者一直以来的支持。</p><h3 id="Hackergame"><a href="#Hackergame" class="headerlink" title="Hackergame"></a>Hackergame</h3><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2024/01/wp_editor_md_d5f3435bfcad81fa1fc43fb115f0f6ba.jpg"/></div></div><p>一血和 79 名！</p><h2 id="一些「年度」"><a href="#一些「年度」" class="headerlink" title="一些「年度」"></a>一些「年度」</h2><h3 id="年度游戏"><a href="#年度游戏" class="headerlink" title="年度游戏"></a>年度游戏</h3><ul><li><a href="https://store.steampowered.com/app/2246110/Q_REMASTERED/">Q REMASTERED</a> ：新颖的解谜游戏，xQc 的节目效果对游戏的推广作出了突出贡献。</li><li><a href="https://store.steampowered.com/app/1551360/_5/">极限竞速：地平线 5</a> ： 相比于 4 代没活了但是瘦死的骆驼比马大的休闲赛车探索游戏，但是今年年中的<strong>史低降价</strong>将其真正拉到了可玩水准。</li><li><a href="https://store.steampowered.com/app/960170/DJMAX_RESPECT_V">DJMAX RESPECT V</a> 和 <a href="https://store.steampowered.com/app/952040/_/">同步音律喵赛克</a> ：两款在今年到达史低的 key 音键盘音游，其在音游圈内的意义无需多言。</li></ul><h3 id="年度歌曲"><a href="#年度歌曲" class="headerlink" title="年度歌曲"></a>年度歌曲</h3><ul><li><a href="https://open.spotify.com/track/5XhxMq0dQNenKr6eDU79Ka">Surges - Orangestar</a> ：柑橘星经典风格，广告曲掩盖不了的清新。</li><li><a href="https://open.spotify.com/track/3Vcy3GTq4lNTKYirrTfMap">You Gave a Lot and Now You Want It Back - Stephen Walking</a> </li><li><a href="https://open.spotify.com/track/3eekarcy7kvN4yt5ZFzltW">HIGHEST IN THE ROOM - Travis Scott</a></li><li><a href="https://open.spotify.com/track/6GL8u1y12BLtoXRmmI3m9k">This Is Our Time - WILD</a> ：积极向上的模板曲。</li><li><a href="https://open.spotify.com/track/2TL93QsV8EP8WC71Z4CQhs">Rocketship - Khaliber, Kholat &amp; Keisha Sounds</a></li><li><a href="https://open.spotify.com/track/2p8z2rZWj7G7hFkhU77SLz">Cinderella (Giga First Night Remix) - DECO*27</a></li><li><a href="https://open.spotify.com/track/5m9rwQVcChWF3TsKXlJfgE">人マニア - 重音テト</a> ： 听感带劲的实验曲目。</li></ul><h3 id="年度朋友"><a href="#年度朋友" class="headerlink" title="年度朋友"></a>年度朋友</h3><p>那些一年间陪我聊天水群的人，那些在我心情低落时给予帮助的人。<br>那些陪我四处乱逛的人，那些让我看见新世界的人。<br>以及某个说是别熬夜却在当地时间凌晨四点回我消息的人。  </p><p>新年快乐 :）</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2024/01/wp_editor_md_b1c17375ab80723a393e809109f995a6.jpg"/></div></div>]]></content>
    
    
    <summary type="html">参差是一词多义的。</summary>
    
    
    
    <category term="杂记" scheme="https://blog.hanlin.press/categories/%E6%9D%82%E8%AE%B0/"/>
    
    <category term="日常" scheme="https://blog.hanlin.press/categories/%E6%97%A5%E5%B8%B8/"/>
    
    
    <category term="口水" scheme="https://blog.hanlin.press/tags/%E5%8F%A3%E6%B0%B4/"/>
    
    <category term="杂谈" scheme="https://blog.hanlin.press/tags/%E6%9D%82%E8%B0%88/"/>
    
    <category term="年度报告" scheme="https://blog.hanlin.press/tags/%E5%B9%B4%E5%BA%A6%E6%8A%A5%E5%91%8A/"/>
    
    <category term="2023" scheme="https://blog.hanlin.press/tags/2023/"/>
    
  </entry>
  
  <entry>
    <title>Hackergame Writeup 2023 : A Newbie Perspective</title>
    <link href="https://blog.hanlin.press/2023/11/hackergame-writeup-2023/"/>
    <id>https://blog.hanlin.press/2023/11/hackergame-writeup-2023/</id>
    <published>2023-11-04T07:44:35.000Z</published>
    <updated>2023-11-04T07:44:35.000Z</updated>
    
    <content type="html"><![CDATA[<p><strong>Hackergame Writeup : A Newbie Perspective</strong></p><h3 id="0-一些废话"><a href="#0-一些废话" class="headerlink" title="0. 一些废话"></a>0. 一些废话</h3><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041233667.png"/></div></div> <p>对如上标题的额外补充： </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032022737.png"/></div></div> <p>过去这么多年，今年是第一次正儿八经拿时间来打 Hackergame ，得益于今年接近溢出的 Misc 浓度，对我这个菜鸡来说，抢个一血的正反馈确实不错，被排行榜卷得写不下项目查资料的过程也确实折磨。 </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032024393.png"/></div></div> <p>最先和 Hackergame 交集应该是 2019 年，当观众。 </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032027902.png"/></div></div> <p>然后是去年（2022），卷不动题去做了个文化衫，中了，现在还在穿，暂时不知道今年还收不收 看样子没了。 </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032028609.png"/></div></div> <div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032029681.png"/></div></div> <p>扯远了，对今年的 Hackergame 我的评价是：入门题更友好了，高端题更卷了，排行榜争抢更激烈了。不过确实得强调，自己的菜是原罪嘛。 </p><p>因为爆出来的都是简单题（<strong>且很多包含运气成分</strong>），所以有些不好截图的地方也不详细写了，出题人肯定写得比我详细，Writeup 大体上以做题顺序排列。</p><h3 id="1-Hackergame-启动"><a href="#1-Hackergame-启动" class="headerlink" title="1. Hackergame 启动"></a>1. Hackergame 启动</h3><p>按照 Hackergame 签到题的一贯尿性，进网页直接 F12 看请求。点提交按钮后注意到 <code>https://cnhktrz3k5nc.hack-challenge.lug.ustc.edu.cn:13202/?similarity=</code></p><p>尝试改为 <code>similarity=1</code> 后：</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032036147.png"/></div></div><p>盲猜没校验，直接  <code>similarity=1000</code> ，完成</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032036567.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032036303.png"/></div></div><h3 id="2-更深更暗"><a href="#2-更深更暗" class="headerlink" title="2. 更深更暗"></a>2. 更深更暗</h3><p>F12， 启动！</p><p><kbd>Ctrl</kbd> + <kbd>F</kbd> 搜索，启动！</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032038123.png"/></div></div><p>跳过猫咪小测直接来这题，抢到一血了，开心。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032039563.png"/></div></div><h3 id="3-猫咪小测"><a href="#3-猫咪小测" class="headerlink" title="3. 猫咪小测"></a>3. 猫咪小测</h3><blockquote><ol><li><p>想要借阅世界图书出版公司出版的《A Classical Introduction To Modern Number Theory 2nd ed.》，应当前往中国科学技术大学西区图书馆的哪一层？<strong>（30 分）</strong></p><p>提示：是一个非负整数。</p></li></ol></blockquote><ol><li><p>书目检索系统：&lt;<a href="http://opac.lib.ustc.edu.cn/">http://opac.lib.ustc.edu.cn/</a> ，用书名搜索后馆藏如下，由题意锁定<strong>西区外文书库</strong>。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032042590.jpeg"/></div></div></li><li><p>去科大图书馆的<a href="https://lib.ustc.edu.cn/%e6%9c%ac%e9%a6%86%e6%a6%82%e5%86%b5/%e9%a6%86%e8%97%8f%e5%88%86%e5%b8%83/"><strong>馆藏分布</strong></a> ，可得知各书库的具体位置。<code>12</code>.</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032043298.png"/></div></div></li></ol><blockquote><ol start="2"><li><p>今年 arXiv 网站的天体物理版块上有人发表了一篇关于「可观测宇宙中的鸡的密度上限」的论文，请问论文中作者计算出的鸡密度函数的上限为 10 的多少次方每立方秒差距？<strong>（30 分）</strong></p><p>提示：是一个非负整数。</p></li></ol></blockquote><p>Google Bard ，启动！</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311032120184.jpeg"/></div></div><p>典型的AI幻觉，但是出现了正确的文章名。</p><p>到 arxiv 去搜，看到了 <a href="https://arxiv.org/abs/2303.17626">https://arxiv.org/abs/2303.17626</a></p><p>高中阅读理解环节，得答案 <code>23</code>  .</p><blockquote><ol start="3"><li>为了支持 TCP BBR 拥塞控制算法，在<strong>编译</strong> Linux 内核时应该配置好哪一条内核选项？<strong>（20 分）</strong><br>提示：输入格式为 CONFIG_XXXXX，如 CONFIG_SCHED_SMT。</li></ol></blockquote><p>去 GitHub 爆搜源码。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041238769.jpeg"/></div></div><p><a href="https://github.com/search?q=repo:torvalds/linux%20config%20bbr&type=code">https://github.com/search?q=repo%3Atorvalds%2Flinux%20config%20bbr&amp;type=code</a></p><p><code>CONFIG_TCP_CONG_BBR</code> .</p><blockquote><ol start="4"><li>🥒🥒🥒：「我……从没觉得写类型标注有意思过」。在一篇论文中，作者给出了能够让 Python 的类型检查器 <del>MyPY</del> mypy 陷入死循环的代码，并证明 Python 的类型检查和停机问题一样困难。请问这篇论文发表在今年的哪个学术会议上？<strong>（20 分）</strong><br>提示：会议的大写英文简称，比如 ISCA、CCS、ICML。</li></ol></blockquote><p>直接上图：</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202310281220184.png"/></div></div><p><a href="https://drops.dagstuhl.de/opus/volltexte/2023/18237/pdf/LIPIcs-ECOOP-2023-44.pdf">https://drops.dagstuhl.de/opus/volltexte/2023/18237/pdf/LIPIcs-<strong>ECOOP</strong>-2023-44.pdf</a></p><p><code>ECOOP</code>.</p><h3 id="4-虫"><a href="#4-虫" class="headerlink" title="4. 虫"></a>4. 虫</h3><p>常规 SSTV 题，<strong>推荐使用 RX-SSTV</strong> ，笔记本扬声器外放即可解码。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202310281318974.png"/></div></div><p><code>flag&#123;SSssTV_y0u_W4NNa_HaV3_4_trY&#125;</code></p><h3 id="5-奶奶的睡前-flag-故事"><a href="#5-奶奶的睡前-flag-故事" class="headerlink" title="5. 奶奶的睡前 flag 故事"></a>5. 奶奶的睡前 flag 故事</h3><p>题面描述<strong>谷歌的『亲儿子』<strong>和</strong>连系统都没心思升级</strong>直接激发了 DNA，去搜了搜相关新闻。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202310281314118.png"/></div></div><p>得到一个工具 <a href="https://acropalypse.app/">https://acropalypse.app/</a> ，传上截图。题面说是<strong>老手机</strong>，所以直接往最老的（Pixel 3）选。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202310281315050.png"/></div></div><p><code>flag&#123;sh1nj1ru_k0k0r0_4nata_m4h0&#125;</code></p><h3 id="6-组委会模拟器"><a href="#6-组委会模拟器" class="headerlink" title="6. 组委会模拟器"></a>6. 组委会模拟器</h3><p>正解看官方题解，我是用 Python requests 的大sb（</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041251754.png"/></div></div><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><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></pre></td><td class="code"><pre><span class="line">import requests</span><br><span class="line">import json</span><br><span class="line">import re</span><br><span class="line">import time</span><br><span class="line"></span><br><span class="line">sess = requests.Session()</span><br><span class="line">sess.get(</span><br><span class="line">    &quot;http://202.38.93.111:10021/api/checkToken?token=&quot;</span><br><span class="line">)</span><br><span class="line"># remember to modify it</span><br><span class="line"></span><br><span class="line"># getMessage</span><br><span class="line">mess = sess.post(&quot;http://202.38.93.111:10021/api/getMessages&quot;).json()</span><br><span class="line">servertime_obj = time.strptime(mess[&quot;server_starttime&quot;], &quot;%Y-%m-%dT%H:%M:%S.%f%z&quot;)</span><br><span class="line">servertime = int(round(time.mktime(servertime_obj) * 1000.0)) + 28800050</span><br><span class="line"></span><br><span class="line"># print(servertime)</span><br><span class="line"></span><br><span class="line">id = 0</span><br><span class="line">totaldelay = 0</span><br><span class="line">for i in mess[&quot;messages&quot;]:</span><br><span class="line">    totaldelay = int(float(i[&quot;delay&quot;]) * 1000)</span><br><span class="line">    nowtime = int(time.time() * 1000)</span><br><span class="line">    # print(int(servertime) + totaldelay)</span><br><span class="line">    print(</span><br><span class="line">        &quot;&#123;&#125; + &#123;&#125; = &#123;&#125; : &#123;&#125;&quot;.format(</span><br><span class="line">            servertime, totaldelay, servertime + totaldelay, nowtime</span><br><span class="line">        )</span><br><span class="line">    )</span><br><span class="line">    if servertime + totaldelay &gt; nowtime:</span><br><span class="line">        # print((servertime + totaldelay - nowtime))</span><br><span class="line">        time.sleep((servertime + totaldelay - nowtime) / 1000)</span><br><span class="line">    if re.search(&quot;hack\[.*\]&quot;, i[&quot;text&quot;]):</span><br><span class="line">        payload = &#123;</span><br><span class="line">            &quot;id&quot;: id,</span><br><span class="line">        &#125;</span><br><span class="line">        hders = &#123;&quot;Content-Type&quot;: &quot;application/json&quot;&#125;</span><br><span class="line">        print(</span><br><span class="line">            sess.post(</span><br><span class="line">                &quot;http://202.38.93.111:10021/api/deleteMessage&quot;,</span><br><span class="line">                headers=hders,</span><br><span class="line">                data=json.dumps(payload),</span><br><span class="line">            ).text</span><br><span class="line">        )</span><br><span class="line">    id += 1</span><br><span class="line">print(sess.post(&quot;http://202.38.93.111:10021/api/getflag&quot;).text)</span><br><span class="line"></span><br></pre></td></tr></table></figure><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041255016.png"/></div></div><p><code>flag&#123;Web_pr0gra_mm1ng_b3ca854fa9_15fun&#125;</code></p><h3 id="7-赛博井字棋"><a href="#7-赛博井字棋" class="headerlink" title="7. 赛博井字棋"></a>7. 赛博井字棋</h3><p>井字棋本身没坑，但以正常思维对抗的话，最多平局。</p><p>F12 右键相关请求可以复制 fetch，在隔壁 console 改起来很容易。</p><p>注意到输入没做校验，可以把对方下的棋<strong>覆盖</strong>。</p><p>请求里改一个坐标，结束。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041257930.png"/></div></div><h3 id="8-Git-Git"><a href="#8-Git-Git" class="headerlink" title="8. Git? Git!"></a>8. Git? Git!</h3><p>和前面某一年 <code>git push -f</code> 那题区别开来，这题没 flush 掉分支，只是没推送。</p><p>查看 <code>.git/logs/ref/heads/main</code> 发现中间有一条疑似 commit：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">&lt;54608551+PRO-2684@users.noreply.github.com&gt; 1698306875 +0800clone: from https://github.com/dair-ai/ML-Course-Notes.git</span><br><span class="line">15fd0a13eb46c39f34cfc0dfb4757ad23a23d026 505e1a3f446c23f31807a117e860f57cb5b5bb79 some_english_postgraduate &lt;some_english_postgraduate@none-exist.com&gt; 1698307060 +0800commit: Trim trailing spaces</span><br><span class="line">505e1a3f446c23f31807a117e860f57cb5b5bb79 15fd0a13eb46c39f34cfc0dfb4757ad23a23d026 some_english_postgraduate &lt;some_english_postgraduate@none-exist.com&gt; 1698307092 +0800reset: moving to HEAD~</span><br><span class="line">15fd0a13eb46c39f34cfc0dfb4757ad23a23d026 ea49f0cd3d36edb2965f89581b11151959d20991 some_english_postgraduate &lt;some_english_postgraduate@none-exist.com&gt; 1698307103 +0800commit: Trim trailing spaces</span><br></pre></td></tr></table></figure><p><code>git checkout 505e1a3f446c23f31807a117e860f57cb5b5bb79</code>，可在主文件中见到 flag .</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041309167.jpeg"/></div></div><h3 id="9-Docker-for-Everyone"><a href="#9-Docker-for-Everyone" class="headerlink" title="9. Docker for Everyone"></a>9. Docker for Everyone</h3><p>这一幕我自己品鉴过了，privileged 啥都能干，端下去吧。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202310281727220.png"/></div></div><h3 id="10-旅行照片-3-0"><a href="#10-旅行照片-3-0" class="headerlink" title="10. 旅行照片 3.0"></a>10. 旅行照片 3.0</h3><p>兜兜转转回到了这道题，<strong>快到的饭点和手机上的红色软件为这道题埋下了伏笔</strong>。</p><h4 id="开始推断出的信息"><a href="#开始推断出的信息" class="headerlink" title="开始推断出的信息"></a>开始推断出的信息</h4><ul><li><strong>上午</strong>：<a href="https://zh.wikipedia.org/wiki/%E5%B0%8F%E6%9F%B4%E6%98%8C%E4%BF%8A">小柴昌俊</a> 的诺贝尔奖牌 -&gt; 地点在 <strong>东京大学</strong> 附近。</li><li><strong>中午</strong>：<ul><li>图一：最前方最显眼的吊牌「<strong>STATPHYS28</strong>」-&gt; <a href="https://statphys28.org/">https://statphys28.org/</a></li><li>图二：在东京大学附近按谷歌卫星地图与街景搜索，基本上锁定<strong>上野公园</strong>。</li></ul></li></ul><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041320198.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041321605.png"/></div></div><p>因题面中提到了学长的学术之旅，因此接下来重点关注图二的 <strong>STATPHYS28</strong>。打开主页，留意到：</p><ul><li><p>首页时间：2023 年 8 月 7 日-11 日，锁定第一题时间范围。</p></li><li><p>On-site 地点：东京大学，与前文推断信息吻合。</p></li><li><p>News 栏<a href="https://statphys28.org/instonsite.html">Participant Instruction: On-site Presentation Instruction</a> 得知 On-site 地点 <strong>Yasuda Auditorium</strong> 即 <strong>安田讲堂</strong> ，锁定第五题。</p></li></ul><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041326098.png"/></div></div><p>再回头看诺贝尔奖牌，按照人肉搜索法找到了<a href="https://zh.wikipedia.org/wiki/%E5%BA%B7%E6%96%AF%E5%9D%A6%E4%B8%81%C2%B7%E8%AF%BA%E6%B2%83%E8%82%96%E6%B4%9B%E5%A4%AB">康斯坦丁·诺沃肖洛夫</a> ，但是不知道曼彻斯特大学研究所简称，这条线暂时中断。</p><h4 id="思路打开"><a href="#思路打开" class="headerlink" title="思路打开"></a>思路打开</h4><p>接下来打算动一点社工的方法，去 B 站找点旅游视频，看看能不能漏点相关情报。</p><p>首先发现上野公园旁边有<strong>上野动物园</strong>和<strong>东京国立博物馆</strong>：</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041332856.png"/></div></div><p>然后发现了上野站的吉祥物是<strong>熊猫</strong>。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041337162.png"/></div></div><p>以上是答案猜测，完赛后在群里交流才发现这题的关键字可以直接在 Twitter 上搜到，挺亏的。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041339629.png"/></div></div><p>线索又一次停滞，到饭点了，出门买饭。去小红书上一搜关键词，大开眼界：</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041342240.png"/></div></div><p>回到小红书首页，这条视频<strong>出现在了首页</strong>。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041344189.png"/></div></div><p>抱着试试看的心态填上了第 5-6 题，过了，我超！</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202310281755371.png"/></div></div><h4 id="顺理成章"><a href="#顺理成章" class="headerlink" title="顺理成章"></a>顺理成章</h4><p>不懂日文又忽视了小红书的潜质，痛定思痛的我决定来小红书抽奖：</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041348128.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041348249.png"/></div></div><p><a href="https://www.xiaohongshu.com/explore/64c8eb40000000000800c394">https://www.xiaohongshu.com/explore/64c8eb40000000000800c394</a></p><p>得知八月第一个活动，以「全国梅酒まつりin東京2023」为关键词回谷歌搜索，找到主页，向下翻，看到了「Staff 募集」：<a href="https://umeshu-matsuri.jp/tokyo_staff/">https://umeshu-matsuri.jp/tokyo_staff/</a> ，得到问卷编号 <code>[S495584522</code>](<a href="https://ws.formzu.net/dist/S495584522/">https://ws.formzu.net/dist/S495584522/</a>)</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041353545.png"/></div></div><p>来到东京国立博物馆网站，直奔<strong>Tickets</strong>：<a href="https://www.tnm.jp/modules/r_free_page/index.php?id=113#ticket">https://www.tnm.jp/modules/r_free_page/index.php?id=113#ticket</a></p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041356048.png"/></div></div><p>三个数都试一遍，原本以为是 500 ，结果发现是 0.</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202310281802099.png"/></div></div><p>第一问的日期也基本上锁定，继续回小红书抽奖：</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041359232.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041359774.png"/></div></div><p>填上 ICRR ，过了，感谢小红书。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202310281755933.png"/></div></div><hr><p>后记：跌跌撞撞过了后发了条频道庆祝下，惊动了某个 Staff.</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041402002.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041402056.png"/></div></div><h3 id="11-JSON-⊂-YAML"><a href="#11-JSON-⊂-YAML" class="headerlink" title="11. JSON ⊂ YAML?"></a>11. JSON ⊂ YAML?</h3><p>整体做题方法：没基础，去 Hackernews 看<a href="https://news.ycombinator.com/item?id=30052128">骂战</a>，看了半天没结果。</p><p>最终还是 <a href="https://stackoverflow.com/questions/21584985/what-valid-json-files-are-not-valid-yaml-1-1-files">https://stackoverflow.com/questions/21584985/what-valid-json-files-are-not-valid-yaml-1-1-files</a> 说明了一切。</p><ul><li><p>YAML 1.1：浮点数解析差异</p></li><li><p>YAML 1.2：不允许出现重复的 key</p></li></ul><p>二合一即可得到一穿二的 payload：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">&#123;&quot;233&quot;: 12345e999, &quot;233&quot;: 12345e999&#125;</span><br></pre></td></tr></table></figure><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041406830.png"/></div></div><p>后记：听说YAML 的 key 长度长了也不行，结果塞进去后发现 Python 先报错了。</p><h3 id="12-HTTP-集邮册"><a href="#12-HTTP-集邮册" class="headerlink" title="12. HTTP 集邮册"></a>12. HTTP 集邮册</h3><p>因为只碰出 12 个状态码，还挺失败的，这题直接看 <a href="https://github.com/USTC-Hackergame/hackergame2023-writeups/tree/master/official/HTTP%20%E9%9B%86%E9%82%AE%E5%86%8C">官方题解</a> 吧。</p><h3 id="13-惜字如金-2-0"><a href="#13-惜字如金-2-0" class="headerlink" title="13. 惜字如金 2.0"></a>13. 惜字如金 2.0</h3><p>因为代码逻辑非常清晰，所以这题是水课的时候静态分析的。</p><p>由 <code>get_cod_dict</code> 里的 <code>check_equals</code>  和主函数中的 <code>decrypt_data</code> 逻辑综合分析，得出 <code>cod_dict</code> 中的每一组应为 <strong>24</strong> 长度。</p><p>于是尝试对原始数据进行「反惜字如金」化处理：</p><ul><li>首先去除所有 <code>check_equals</code> 校验，将 <code>cod_dict</code> 后每行加上 <code>e</code></li><li>参考 <code>decrypt_data</code> 所在坐标，尝试将 <code>flag&#123;</code> 和 <code>&#125;</code> 与原始数据对齐。<ul><li>对齐方式：逐字符对齐，判断偏移关系。</li><li>通过「在整行后加e」与「在目标字符前加字符」来操作目标字符的位移。</li><li>每行的改动不超过1字符。</li></ul></li></ul><p>改动后的原始数据：</p><blockquote><p>nymeh1niwemflcir}echaet<strong>e</strong><br>a3g7}kidgojernoetlsup?h<strong>e</strong><br>u<strong>l</strong>lw!f5soadrhwnrsnstnoeq<br>c<strong>t</strong>t{l-findiehaai{oveatas<br>ty9kxbors<strong>s</strong>zstguyd?!blm-p</p></blockquote><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><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><span class="line"><span class="keyword">def</span> <span class="title function_">get_cod_dict</span>():</span><br><span class="line">    <span class="comment"># prepar th cod dict</span></span><br><span class="line">    cod_dict = []</span><br><span class="line">    cod_dict += [<span class="string">&#x27;nymeh1niwemflcir&#125;echaete&#x27;</span>]</span><br><span class="line">    cod_dict += [<span class="string">&#x27;a3g7&#125;kidgojernoetlsup?he&#x27;</span>]</span><br><span class="line">    cod_dict += [<span class="string">&#x27;ullw!f5soadrhwnrsnstnoeq&#x27;</span>]</span><br><span class="line">    cod_dict += [<span class="string">&#x27;ctt&#123;l-findiehaai&#123;oveatas&#x27;</span>]</span><br><span class="line">    cod_dict += [<span class="string">&#x27;ty9kxborsszstguyd?!blm-p&#x27;</span>]</span><br><span class="line">    check_equals(<span class="built_in">set</span>(<span class="built_in">len</span>(s) <span class="keyword">for</span> s <span class="keyword">in</span> cod_dict), &#123;<span class="number">24</span>&#125;)</span><br><span class="line">    <span class="keyword">return</span> <span class="string">&#x27;&#x27;</span>.join(cod_dict)</span><br></pre></td></tr></table></figure><p>运行后可得 <code>flag&#123;you-ve-r3cover3d-7he-an5w3r-r1ght?&#125;</code></p><h3 id="14-高频率星球"><a href="#14-高频率星球" class="headerlink" title="14. 高频率星球"></a>14. 高频率星球</h3><p>搜索得 <a href="https://asciinema.org/">asciinema</a> 存在 cat 功能，可以无视时间序列一口气输出全部数据。</p><p>直接 <code>asciinema cat asciinema_restore.rec &gt; 1111.js</code> 发现还有控制符没删。</p><p>本着不重复造轮子的原则，在 Gitlab 找到个神器 <a href="https://gitlab.com/saalen/ansifilter">ANSIFILTER</a> ，还进了包，<code>apt install</code> 即装即用。</p><p>接下来进入半人工半自动流程：</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><span class="line">asciinema <span class="built_in">cat</span> asciinema_restore.rec | ansifilter &gt; parsed.js</span><br></pre></td></tr></table></figure><p>然后找到一个带语法高亮和检查的编辑器，比如装好扩展和 <code>clang-format</code> 的 VS Code。</p><p>去掉头尾的无用信息，留下中间的 js 部分。会发现有一大串报错，多半是控制符没删干净导致的。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041433703.png"/></div></div><p>以纯文本方式打开 <code>asciinema_restore.rec</code> ，通过搜索确定报错代码原位置，直接把多出来的字符删除。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041449534.png"/></div></div><p>在每次替换为空后保存并重新运行格式化，检查下一处报错，如此操作，直到报错全部消失，代码正常格式化为止。</p><p>在实践中，我删掉了<code>ESESC[6~</code>，一个 <code>~</code> 和若干 <code>6~</code> ，最终顺利消除全部报错。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041450121.png"/></div></div><p>运行 <code>node parsed.js</code> ，得到 <code>flag&#123;y0u_cAn_ReSTorE_C0de_fr0m_asc11nema_3db2da1063300e5dabf826e40ffd016101458df23a371&#125;</code></p><h3 id="15-低带宽星球"><a href="#15-低带宽星球" class="headerlink" title="15. 低带宽星球"></a>15. 低带宽星球</h3><p>第一题使用 <a href="https://tinypng.com/">tinypng.com</a> 压缩，即可获得 Flag <code>flag&#123;A1ot0f_t0015_is_available_to_compre55_PNG&#125;</code>.</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041453721.png"/></div></div><p>第二题手操了一遍发现 svg 也不好满足要求，放弃。</p><h3 id="16-小型大语言模型星球"><a href="#16-小型大语言模型星球" class="headerlink" title="16. 小型大语言模型星球"></a>16. 小型大语言模型星球</h3><p>第一段长度限制极宽松，所以我直接去找了训练集。</p><p><a href="https://huggingface.co/datasets/roneneldan/TinyStories">https://huggingface.co/datasets/roneneldan/TinyStories</a></p><p>在 <code>TinyStories-train.txt</code> 中 以  <code>you are smart</code> 为关键词搜索，并将其前面的文段粘贴进去验证。</p><p>很容易就能找到一段 <code>flag&#123;1-7hink-y0U-4R3-rEAllY-rE4lly-SmARt&#125;</code>：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">&quot;You are a brave girl, Lily. You are not silly, </span><br></pre></td></tr></table></figure><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041458079.png"/></div></div><p>第二段因为长度较短也比较好猜，只是我水课上试出来个 <strong><code>accept*</code></strong> 直接爆了<code>flag&#123;y0U-are-ACcEP7ed-7O-c0NTiNuE-TH3-G4m3&#125;</code>：</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041459611.png"/></div></div><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></pre></td><td class="code"><pre><span class="line"><span class="keyword">from</span> transformers <span class="keyword">import</span> AutoModelForCausalLM, AutoTokenizer, GenerationConfig</span><br><span class="line"><span class="keyword">from</span> tqdm <span class="keyword">import</span> tqdm, trange</span><br><span class="line">model = AutoModelForCausalLM.from_pretrained(<span class="string">&quot;../TinyStories-33M&quot;</span>).<span class="built_in">eval</span>()</span><br><span class="line">tokenizer = AutoTokenizer.from_pretrained(<span class="string">&quot;../TinyStories-33M&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">get</span>(<span class="params">prompt</span>):</span><br><span class="line">input_ids = tokenizer.encode(prompt, return_tensors=<span class="string">&quot;pt&quot;</span>)</span><br><span class="line"><span class="built_in">print</span>(tokenizer.tokenize(prompt))</span><br><span class="line">output = model.generate(input_ids, max_length = <span class="built_in">len</span>(prompt)+<span class="number">30</span>, num_beams=<span class="number">1</span>, pad_token_id=tokenizer.eos_token_id)</span><br><span class="line">output_text = tokenizer.decode(output[<span class="number">0</span>], skip_special_tokens=<span class="literal">True</span>)</span><br><span class="line"><span class="built_in">print</span>(output_text)</span><br><span class="line"><span class="keyword">return</span> output_text</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="keyword">while</span> <span class="literal">True</span>:</span><br><span class="line">s = <span class="built_in">input</span>(<span class="string">&quot;&gt; &quot;</span>)</span><br><span class="line">get(s)</span><br></pre></td></tr></table></figure><h3 id="17-流式星球"><a href="#17-流式星球" class="headerlink" title="17. 流式星球"></a>17. 流式星球</h3><p>因为已知流式文件被删去了一小段，就写了段三脚猫 <code>C++</code>， 直接爆破可能的 pad 和分辨率：</p><figure class="highlight cpp"><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></pre></td><td class="code"><pre><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;cstdio&gt;</span></span></span><br><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;iostream&gt;</span></span></span><br><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;vector&gt;</span></span></span><br><span class="line"><span class="keyword">using</span> <span class="keyword">namespace</span> std;</span><br><span class="line"></span><br><span class="line"><span class="type">const</span> <span class="type">unsigned</span> <span class="type">long</span> <span class="type">long</span> fsize = <span class="number">135146688</span>;</span><br><span class="line"></span><br><span class="line">vector&lt;<span class="type">unsigned</span> <span class="type">long</span> <span class="type">long</span>&gt; libpad, libheight, libwidth;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>&#123;</span><br><span class="line">    <span class="type">unsigned</span> <span class="type">long</span> <span class="type">long</span> pad = <span class="number">0</span>;</span><br><span class="line">    <span class="type">unsigned</span> <span class="type">long</span> <span class="type">long</span> height = <span class="number">1</span>;</span><br><span class="line">    <span class="type">unsigned</span> <span class="type">long</span> <span class="type">long</span> width = <span class="number">1</span>;</span><br><span class="line">    <span class="keyword">for</span> (pad = <span class="number">0</span>; pad &lt;= <span class="number">100</span>; pad++) &#123;</span><br><span class="line">        <span class="keyword">for</span> (height = <span class="number">100</span>; height &lt;= <span class="number">2000</span>; height++) &#123;</span><br><span class="line">            <span class="keyword">for</span> (width = <span class="number">100</span>; width &lt;= <span class="number">2000</span>; width++) &#123;</span><br><span class="line">                <span class="keyword">if</span> ((pad + fsize) % (height * width * <span class="number">3</span>) == <span class="number">0</span>) &#123;</span><br><span class="line">                    <span class="type">unsigned</span> <span class="type">long</span> <span class="type">long</span> frame =</span><br><span class="line">                        (pad + fsize) / (height * width * <span class="number">3</span>);</span><br><span class="line">                    <span class="keyword">if</span> (frame &gt; <span class="number">1</span> &amp;&amp; frame &lt;= <span class="number">100</span>) &#123;</span><br><span class="line">                        libpad.<span class="built_in">push_back</span>(pad);</span><br><span class="line">                        libheight.<span class="built_in">push_back</span>(height);</span><br><span class="line">                        libwidth.<span class="built_in">push_back</span>(width);</span><br><span class="line">                    &#125;</span><br><span class="line">                &#125;</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="keyword">for</span> (<span class="keyword">auto</span> i : libpad) &#123;</span><br><span class="line">        cout &lt;&lt; i &lt;&lt; <span class="string">&quot;,&quot;</span>;</span><br><span class="line">    &#125;</span><br><span class="line">    cout &lt;&lt; endl;</span><br><span class="line">    <span class="keyword">for</span> (<span class="keyword">auto</span> i : libheight) &#123;</span><br><span class="line">        cout &lt;&lt; i &lt;&lt; <span class="string">&quot;,&quot;</span>;</span><br><span class="line">    &#125;</span><br><span class="line">    cout &lt;&lt; endl;</span><br><span class="line">    <span class="keyword">for</span> (<span class="keyword">auto</span> i : libwidth) &#123;</span><br><span class="line">        cout &lt;&lt; i &lt;&lt; <span class="string">&quot;,&quot;</span>;</span><br><span class="line">    &#125;</span><br><span class="line">    cout &lt;&lt; endl;</span><br><span class="line">    <span class="keyword">return</span> <span class="number">0</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>C++ 的输出直接复制到隔壁 Python ，吐每一个爆破结果的<strong>第 5 帧</strong>：</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> cv2</span><br><span class="line"><span class="keyword">import</span> numpy <span class="keyword">as</span> np</span><br><span class="line"><span class="keyword">import</span> random</span><br><span class="line"><span class="keyword">from</span> tqdm <span class="keyword">import</span> tqdm, trange</span><br><span class="line">libpad = [<span class="number">0</span>,<span class="number">0</span>,<span class="number">0</span>,<span class="number">0</span>,<span class="number">0</span>,<span class="number">0</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">72</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">84</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>,<span class="number">93</span>]</span><br><span class="line">libheight = [<span class="number">409</span>,<span class="number">818</span>,<span class="number">1636</span>,<span class="number">1721</span>,<span class="number">1721</span>,<span class="number">1721</span>,<span class="number">349</span>,<span class="number">461</span>,<span class="number">461</span>,<span class="number">698</span>,<span class="number">698</span>,<span class="number">922</span>,<span class="number">922</span>,<span class="number">922</span>,<span class="number">1396</span>,<span class="number">1396</span>,<span class="number">1745</span>,<span class="number">1745</span>,<span class="number">1745</span>,<span class="number">1844</span>,<span class="number">1844</span>,<span class="number">1844</span>,<span class="number">342</span>,<span class="number">402</span>,<span class="number">603</span>,<span class="number">603</span>,<span class="number">684</span>,<span class="number">804</span>,<span class="number">983</span>,<span class="number">983</span>,<span class="number">983</span>,<span class="number">983</span>,<span class="number">983</span>,<span class="number">1206</span>,<span class="number">1206</span>,<span class="number">1273</span>,<span class="number">1273</span>,<span class="number">1966</span>,<span class="number">1966</span>,<span class="number">1966</span>,<span class="number">1966</span>,<span class="number">1966</span>,<span class="number">417</span>,<span class="number">417</span>,<span class="number">427</span>,<span class="number">483</span>,<span class="number">671</span>,<span class="number">759</span>,<span class="number">973</span>,<span class="number">973</span>,<span class="number">973</span>,<span class="number">1281</span>,<span class="number">1403</span>,<span class="number">1403</span>,<span class="number">1403</span>,<span class="number">1529</span>,<span class="number">1529</span>,<span class="number">1529</span>,<span class="number">1529</span>,<span class="number">1771</span>]</span><br><span class="line">libweight = [<span class="number">1721</span>,<span class="number">1721</span>,<span class="number">1721</span>,<span class="number">409</span>,<span class="number">818</span>,<span class="number">1636</span>,<span class="number">1844</span>,<span class="number">1396</span>,<span class="number">1745</span>,<span class="number">922</span>,<span class="number">1844</span>,<span class="number">698</span>,<span class="number">1396</span>,<span class="number">1745</span>,<span class="number">461</span>,<span class="number">922</span>,<span class="number">461</span>,<span class="number">922</span>,<span class="number">1844</span>,<span class="number">349</span>,<span class="number">698</span>,<span class="number">1745</span>,<span class="number">1966</span>,<span class="number">1966</span>,<span class="number">983</span>,<span class="number">1966</span>,<span class="number">983</span>,<span class="number">983</span>,<span class="number">603</span>,<span class="number">684</span>,<span class="number">804</span>,<span class="number">1206</span>,<span class="number">1273</span>,<span class="number">983</span>,<span class="number">1966</span>,<span class="number">983</span>,<span class="number">1966</span>,<span class="number">342</span>,<span class="number">402</span>,<span class="number">603</span>,<span class="number">1206</span>,<span class="number">1273</span>,<span class="number">1403</span>,<span class="number">1771</span>,<span class="number">1529</span>,<span class="number">1529</span>,<span class="number">973</span>,<span class="number">973</span>,<span class="number">671</span>,<span class="number">759</span>,<span class="number">1403</span>,<span class="number">1529</span>,<span class="number">417</span>,<span class="number">973</span>,<span class="number">1529</span>,<span class="number">427</span>,<span class="number">483</span>,<span class="number">1281</span>,<span class="number">1403</span>,<span class="number">417</span>]</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">extract_video</span>(<span class="params">file</span>):</span><br><span class="line">    buffer = np.fromfile(file, dtype=np.uint8)</span><br><span class="line">    lenlib = <span class="built_in">len</span>(libpad)</span><br><span class="line">    picPath = <span class="string">&quot;pics-batch&quot;</span></span><br><span class="line">    <span class="keyword">for</span> i <span class="keyword">in</span> trange(lenlib):</span><br><span class="line">        ht = libheight[i]</span><br><span class="line">        wd = libweight[i]</span><br><span class="line">        pad = libpad[i]</span><br><span class="line">        frame_count = (<span class="built_in">len</span>(buffer) + pad) // (ht * wd * <span class="number">3</span>)</span><br><span class="line">        backup = buffer.copy()</span><br><span class="line">        backup = np.pad(backup, (<span class="number">0</span>, pad), mode=<span class="string">&quot;constant&quot;</span>)</span><br><span class="line">        backup = backup.reshape((frame_count, ht, wd, <span class="number">3</span>))</span><br><span class="line">        backup = backup.astype(np.uint8)</span><br><span class="line">        cv2.imwrite(<span class="string">f&quot;<span class="subst">&#123;picPath&#125;</span>/<span class="subst">&#123;ht&#125;</span>-<span class="subst">&#123;wd&#125;</span>-<span class="subst">&#123;pad&#125;</span>.jpg&quot;</span>, backup[<span class="number">5</span>])</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> __name__ == <span class="string">&quot;__main__&quot;</span>:</span><br><span class="line">    <span class="keyword">with</span> <span class="built_in">open</span>(<span class="string">&quot;video.bin&quot;</span>, <span class="string">&quot;rb&quot;</span>) <span class="keyword">as</span> <span class="built_in">input</span>:</span><br><span class="line">        extract_video(<span class="built_in">input</span>)</span><br></pre></td></tr></table></figure><p>然后直接使用<strong>人眼观测法</strong> 找看着最顺眼的：</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041510607.png"/></div></div><p>当时选中了 <code>1529-427-93.jpg</code>，直接把参数抄回去导图片：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><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></pre></td><td class="code"><pre><span class="line">def extract_video(file):</span><br><span class="line">    buffer = np.fromfile(file, dtype=np.uint8)</span><br><span class="line"></span><br><span class="line">    ht = 1529</span><br><span class="line">    wd = 427</span><br><span class="line">    pad = 93</span><br><span class="line">    frame_count = (len(buffer) + pad) // (ht * wd * 3)</span><br><span class="line">    buffer = np.pad(buffer, (0, pad), mode=&quot;constant&quot;)</span><br><span class="line">    buffer = buffer.reshape((frame_count, ht, wd, 3))</span><br><span class="line">    buffer = buffer.astype(np.uint8)</span><br><span class="line">    picPath = &quot;pics&quot;</span><br><span class="line">    for i in range(frame_count):</span><br><span class="line">        print(f&quot;Writing frame &#123;i&#125;&quot;)</span><br><span class="line">        cv2.imwrite(f&quot;&#123;picPath&#125;/frame&#123;i&#125;.jpg&quot;, buffer[i])</span><br></pre></td></tr></table></figure><p>深夜做这个给气笑了：</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041511170.png"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041511221.png"/></div></div><p><code>flag&#123;it-could-be-easy-to-restore-video-with-haruhikage-even-without-metadata-0F7968CC&#125;</code></p><h3 id="18-Komm-susser-Flagge"><a href="#18-Komm-susser-Flagge" class="headerlink" title="18. Komm, süsser Flagge"></a>18. Komm, süsser Flagge</h3><p>问了问 GPT ，GPT 说把数据包切分开来发出去就行。</p><p>好像还因为非预期解把第二问给爆了，详见<a href="https://github.com/USTC-Hackergame/hackergame2023-writeups/tree/master/official/Komm%2C%20s%C3%BCsser%20Flagge">官方题解</a>吧。</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></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> socket</span><br><span class="line"><span class="keyword">import</span> time</span><br><span class="line"></span><br><span class="line">server_ip = <span class="string">&quot;202.38.93.111&quot;</span></span><br><span class="line">server_port = <span class="number">18080</span></span><br><span class="line"><span class="comment"># server_port = 18081</span></span><br><span class="line">token = <span class="string">&quot;&quot;</span></span><br><span class="line"></span><br><span class="line">post_part1 = <span class="string">&quot;PO&quot;</span></span><br><span class="line">post_part2 = <span class="string">&quot;ST / HTTP/1.1\r\n&quot;</span></span><br><span class="line">post_part2 += <span class="string">&quot;Host: &#123;&#125;\r\n&quot;</span>.<span class="built_in">format</span>(server_ip)</span><br><span class="line">post_part2 += <span class="string">&quot;Content-Type: application/x-www-form-urlencoded\r\n&quot;</span></span><br><span class="line">post_part2 += <span class="string">&quot;Content-Length: &#123;&#125;\r\n\r\n&quot;</span>.<span class="built_in">format</span>(<span class="built_in">len</span>(token))</span><br><span class="line">post_part2 += <span class="built_in">str</span>(token)</span><br><span class="line"></span><br><span class="line">s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)</span><br><span class="line"></span><br><span class="line">s.connect((server_ip, server_port))</span><br><span class="line"></span><br><span class="line"><span class="keyword">try</span>:</span><br><span class="line">    <span class="comment"># Send the first part of the &quot;POST&quot; method</span></span><br><span class="line">    s.sendall(post_part1.encode())</span><br><span class="line">    time.sleep(<span class="number">1</span>)</span><br><span class="line">    <span class="comment"># Send the rest of the request</span></span><br><span class="line">    s.sendall(post_part2.encode())</span><br><span class="line"></span><br><span class="line">    <span class="comment"># Receive the response</span></span><br><span class="line">    response = s.recv(<span class="number">4096</span>)</span><br><span class="line">    <span class="built_in">print</span>(response.decode())</span><br><span class="line"><span class="keyword">finally</span>:</span><br><span class="line">    s.close()</span><br></pre></td></tr></table></figure><h3 id="19-LD-PRELOAD-love"><a href="#19-LD-PRELOAD-love" class="headerlink" title="19.  LD_PRELOAD, love!"></a>19.  LD_PRELOAD, love!</h3><p>听说因为 <code>@taoky</code> 老师没删干净库调用，被有些人钻了空子（<code>popen</code>）当非预期解爆了。</p><p>而我的非预期解显得更加离谱：</p><figure class="highlight cpp"><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></pre></td><td class="code"><pre><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;algorithm&gt;</span></span></span><br><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;cstdio&gt;</span></span></span><br><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;cstdlib&gt;</span></span></span><br><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;cstring&gt;</span></span></span><br><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;fstream&gt;</span></span></span><br><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string">&lt;iostream&gt;</span></span></span><br><span class="line"><span class="keyword">using</span> <span class="keyword">namespace</span> std;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>&#123;</span><br><span class="line">    ifstream infile;</span><br><span class="line">    infile.<span class="built_in">open</span>(<span class="string">&quot;/flag&quot;</span>);</span><br><span class="line">    cout &lt;&lt; infile.<span class="built_in">rdbuf</span>() &lt;&lt; endl;</span><br><span class="line">    <span class="keyword">return</span> <span class="number">0</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041518991.jpeg"/></div></div><p>啊？</p><h3 id="20-异星歧途"><a href="#20-异星歧途" class="headerlink" title="20. 异星歧途"></a>20. 异星歧途</h3><p>有趣的手算模拟题，理解地图需要明白两个事：</p><ul><li><p>机器状态被微型处理器通过开关控制。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041522766.png"/></div></div></li><li><p>微型处理器可以进行逻辑运算与判断。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041523803.png"/></div></div></li></ul><p>理解了这些，就可以在平板上手算了。</p><h4 id="第一组机器"><a href="#第一组机器" class="headerlink" title="第一组机器"></a>第一组机器</h4><p>要想让 <code>generator1</code> 启用（即不发生跳转），前面的所有条件必须一起反选。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041525662.png"/></div></div><p>得序列 <code>10100101</code></p><h4 id="第二组机器"><a href="#第二组机器" class="headerlink" title="第二组机器"></a>第二组机器</h4><p>更复杂的逻辑推断，因为不想再经历一遍所以直接上做题笔记。</p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/picgo/202311041527663.png"/></div></div><p>得序列 <code>11000100</code></p><h4 id="第三组机器"><a href="#第三组机器" class="headerlink" title="第三组机器"></a>第三组机器</h4><p>需要强调的点：</p><ul><li>题目询问的是<strong>最终序列</strong>，开关顺序和<strong>中间状态</strong>都是不重要的。</li><li>让反应堆稳定运行：有能源、有散热（冷冻液）<ul><li>有冷冻液的条件：冷冻液混合器里有输入、有电</li></ul></li></ul><p>因此：</p><ul><li>需要<strong>左侧</strong>输送带正常运行、右侧输送带未被切断 ：<code>sw1 = 1</code>、<code>sw2 = 0</code></li><li>反应堆正常运行：<code>sw3=0</code> （注意一开始调试时应为1，防止反应堆没有散热自己炸掉）</li><li>水和反应液不能流掉：<code>sw4=0</code></li><li>正常分解产生冷冻液：两台机器开启 -&gt; <code>sw5=1</code> 、 <code>sw6=1</code></li><li><strong>熔毁炮塔不能启用</strong>，否则会耗尽电量导致无法散热：<code>sw7=0</code></li><li><code>sw8=0</code> 以防不能打开的机器被打开。</li></ul><p>得序列 <code>10001100</code></p><h4 id="第四组机器"><a href="#第四组机器" class="headerlink" title="第四组机器"></a>第四组机器</h4><p>观察到电量输出为左下角，故建议从左下角逆向推导。</p><p>每一个小块与组合逻辑的思想类似，注意操作的时候有延迟。</p><p>更详细的参考<a href="https://github.com/USTC-Hackergame/hackergame2023-writeups/tree/master/official/%E5%BC%82%E6%98%9F%E6%AD%A7%E9%80%94">官方题解</a> 吧。</p><p>得序列 <code>01110111</code></p><p>最终序列：<code>10100101 11000100 10001100 01110111</code></p><hr><h3 id="FF-后记"><a href="#FF-后记" class="headerlink" title="FF. 后记"></a>FF. 后记</h3><p>仍旧是感谢 Miscgame 让我能够在今年拥有一个在榜上的参与感，就是这几天光打 Hackergame ，只有一天出了勤还掉了 50 名，难受。</p>]]></content>
    
    
    <summary type="html">过去这么多年，今年是第一次正儿八经拿时间来打 Hackergame ，得益于今年接近溢出的 Misc 浓度，对我这个菜鸡来说，抢个一血的正反馈确实不错，被排行榜卷得写不下项目查资料的过程也确实折磨。</summary>
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="Hackergame" scheme="https://blog.hanlin.press/tags/Hackergame/"/>
    
    <category term="Writeup" scheme="https://blog.hanlin.press/tags/Writeup/"/>
    
  </entry>
  
  <entry>
    <title>连夜分析，探寻B站2022年度百大的版本答案</title>
    <link href="https://blog.hanlin.press/2023/01/analysis-bpu-2022/"/>
    <id>https://blog.hanlin.press/2023/01/analysis-bpu-2022/</id>
    <published>2023-01-13T18:31:54.000Z</published>
    <updated>2023-01-13T18:31:54.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p>为方便分享，做了一个微信公众号的版本，欢迎大伙捧场分享： <a href="https://mp.weixin.qq.com/s/Vq7NVn_sU_muxaFFzO0Vcg">https://mp.weixin.qq.com/s/Vq7NVn_sU_muxaFFzO0Vcg</a><br>在我写文章的几小时以前，一年一度的B站<a href="https://www.bilibili.com/BPU2022">2022 百大 UP 主盛典</a>刚刚结束，一如既往地，我点开名单尝试找寻熟悉的UP主，却发现新老面孔交集，大有陌生之感。<br>加之前几天偶见视频<a href="https://www.bilibili.com/video/BV1GY411y7Yt">「盲猜 B 站 22 年最火的视频，居然有一半没看过？」</a>，我的心情同片中人物一样，震惊而又陌生，所以这次完整名单揭晓后，便花1个小时来写了个爬虫，看看今年百大的「版本答案」。 照例，下面一节先写一点关键的技术细节，想要直接看结果可以跳到下下节。</p><h2 id="技术细节"><a href="#技术细节" class="headerlink" title="技术细节"></a>技术细节</h2><ul><li>百大 UP 名单页面： <a href="https://www.bilibili.com/BPU2022#/poweruplist">https://www.bilibili.com/BPU2022#/poweruplist</a></li><li>其中的名单通过访问路由 <code>/activity/operation/list</code> 获得： <a href="https://api.bilibili.com/x/activity/operation/list?pn=1&ps=50&source_id=63a14c147e8469700e19a45d">https://api.bilibili.com/x/activity/operation/list?pn=1&amp;ps=50&amp;source_id&#x3D;63a14c147e8469700e19a45d</a> ，合计分 3 页，通过切换 <code>pn</code> 参数换页。</li><li>数据中掺杂了一部分 UP 主数据，包含了 UP 主 <code>mid</code> 等关键数据，但是粉丝数 <code>follower</code> 为 0 ，故该部分连带其他基础数据通过访问路由 <a href="http://api.bilibili.com/x/web-interface/card?mid=">http://api.bilibili.com/x/web-interface/card?mid=</a> 二次查询。</li><li>UP 主的投稿视频列表可以通过 <a href="https://api.bilibili.com/x/space/wbi/arc/search?mid=">https://api.bilibili.com/x/space/wbi/arc/search?mid=</a> 获取，讨喜的是，数据的开头直接给出了该 UP 主在<strong>不同分区</strong>分别投稿的视频数，以嵌套字典的形式展现，示例： <code>&quot;tlist&quot;:&#123;&quot;160&quot;:&#123;&quot;tid&quot;:160,&quot;count&quot;:3,&quot;name&quot;:&quot;生活&quot;&#125;,&quot;4&quot;:&#123;&quot;tid&quot;:4,&quot;count&quot;:147,&quot;name&quot;:&quot;游戏&quot;&#125;&#125;</code></li><li>至于嵌套字典如何处理，这是一个 Python 中获取 json 解析后某 UP 主投稿第二多的分区名的示例： <code>c1 = sorted(searchdata.items(), key=lambda x: x[1][&#39;count&#39;], reverse=True)[1][1][&#39;name&#39;]</code></li><li>更多参考： <a href="https://github.com/SocialSisterYi/bilibili-API-collect">https://github.com/SocialSisterYi/bilibili-API-collect</a></li></ul><h2 id="数据时间"><a href="#数据时间" class="headerlink" title="数据时间"></a>数据时间</h2><p>这次数据爬取合计关注了如下维度：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">ID,昵称,性别,关注数,投稿数,粉丝数,获赞数,第一分区,第一分区投稿数,第二分区,第二分区投稿数,签名,认证,大会员状态,</span><br></pre></td></tr></table></figure><h3 id="谁是老人？"><a href="#谁是老人？" class="headerlink" title="谁是老人？"></a>谁是老人？</h3><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_e033ed8689869aca67d1fe91e1c819fc.jpg"/></div></div><hr><p><code>STN 工作室</code>以4位ID占据了绝对优势，<code>泛式</code>与<code>-LKs-</code>紧随其后，从上到下都是一些老面孔。而最新的则是<code>汪苏泷</code>。</p><h3 id="谁是粉丝之王？"><a href="#谁是粉丝之王？" class="headerlink" title="谁是粉丝之王？"></a>谁是粉丝之王？</h3><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_5363f5beaf3be0f6425010f13a547f5e.jpg"/></div></div><p> <code>罗翔说刑法</code> 以 2572W 的千万级粉丝数获得百大粉丝榜首，而 <code>Milk缪客</code> 以 62W 的粉丝数守住百大 UP 粉丝数底线。</p><h3 id="谁是获赞之王？"><a href="#谁是获赞之王？" class="headerlink" title="谁是获赞之王？"></a>谁是获赞之王？</h3><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_b515ed87ed0ca97ded7e747cea49c5e3.jpg"/></div></div><h3 id="谁是投稿之王？"><a href="#谁是投稿之王？" class="headerlink" title="谁是投稿之王？"></a>谁是投稿之王？</h3><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_e57c8956dc91aedd5e415a81fae60396.jpg"/></div></div><p>最高为 <code>籽岷</code> 的 <code>5092</code>，最低为 <code>light是光华</code> 的 <code>14</code>。</p><h3 id="谁是追星之王？"><a href="#谁是追星之王？" class="headerlink" title="谁是追星之王？"></a>谁是追星之王？</h3><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_642041951499c40a1e9413eb4d2e0486.jpg"/></div></div><p><code>CSGO久菜合子</code> 在关注数上获得了数量级优势。</p><h2 id="趣味数据"><a href="#趣味数据" class="headerlink" title="趣味数据"></a>趣味数据</h2><h3 id="大会员"><a href="#大会员" class="headerlink" title="大会员"></a>大会员</h3><p>以下 UP 主<strong>没有大会员</strong>： <div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_be1ae2b754118ae81b872dad426114f9.jpg"/></div></div></p><p>这两个有<strong>非年费大会员</strong>： <div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_bda8587f24a9c384af18a3bd87432447.jpg"/></div></div></p><p>这三个有<strong>十年大会员</strong>： <div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_6d6d0c32b0319b728447f86de0ae4d74.jpg"/></div></div></p><p><code>籽岷</code>有<strong>百年大会员</strong>，其余百大均为<strong>年费大会员</strong>。 共 92 人拥有大会员。</p><h3 id="既往认证史"><a href="#既往认证史" class="headerlink" title="既往认证史"></a>既往认证史</h3><ul><li>2 人在百大前没有获得过任何认证。</li><li>36 人被认证为既往年份百大 UP 主。</li><li>74 人被认证为知名 UP 主。</li><li>10 人被认证为直播高能主播。</li><li>4 人被认证为优质 UP 主。</li><li>13 人被认证为官方账号，或显示为指定艺人。</li><li>3 人被认证或是「自称」为虚拟主播。</li></ul><h2 id="一点分区的版本答案"><a href="#一点分区的版本答案" class="headerlink" title="一点分区的版本答案"></a>一点分区的版本答案</h2><p>这份数据的来源在前文已经阐明，对于一个 UP 主，我们将其投稿视频前二的分区分别称之为第一、二分区，借此可以在通常意义上探索一个 UP 的<strong>主业</strong>与<strong>副业</strong>。 </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_f882f502e93e8e5b85aaa8a4c021b6ee.jpg"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_623cc96a0ab4c0f7989c601aba64fcfd.jpg"/></div></div><p>一些较为离谱数据的解释： 这些人浅尝辄止，只在第二分区投稿了一个视频： </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_236c0440ee74ec06b32208336ebfe6fc.jpg"/></div></div> <p>这些人始终如一，只在一个分区投稿视频： </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2023/01/wp_editor_md_6fe2f029946ff59cfacb3f5410eeb1cd.jpg"/></div></div><h2 id="尾声"><a href="#尾声" class="headerlink" title="尾声"></a>尾声</h2><p>所以说，如果你从零起步，想迈进百大 UP 主的门槛，就数据来看，你最好需要完成什么条件？ 拿到 70W 以上的粉丝，获取一个以上的个人认证，发表 20 个以上的视频，拥有 200W 以上的点赞数。<br>主业目前还是一个蓝海，游戏区可以掺和，动画美食科普区也行。但是最好还是涉足一下生活区，作为垫底的副业。</p>]]></content>
    
    
    <summary type="html">在我写文章的几小时以前，一年一度的B站 2022 百大 UP 主盛典刚刚结束，一如既往地，我点开名单尝试找寻熟悉的UP主，却发现新老面孔交集，大有陌生之感。</summary>
    
    
    
    <category term="杂记" scheme="https://blog.hanlin.press/categories/%E6%9D%82%E8%AE%B0/"/>
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="B站" scheme="https://blog.hanlin.press/tags/B%E7%AB%99/"/>
    
    <category term="爬虫" scheme="https://blog.hanlin.press/tags/%E7%88%AC%E8%99%AB/"/>
    
    <category term="数据分析" scheme="https://blog.hanlin.press/tags/%E6%95%B0%E6%8D%AE%E5%88%86%E6%9E%90/"/>
    
  </entry>
  
  <entry>
    <title>2022：不为所动，做最业余的年度报告</title>
    <link href="https://blog.hanlin.press/2022/12/2022-annual-report/"/>
    <id>https://blog.hanlin.press/2022/12/2022-annual-report/</id>
    <published>2022-12-31T10:37:35.000Z</published>
    <updated>2022-12-31T10:37:35.000Z</updated>
    
    <content type="html"><![CDATA[<h2 id="多说一句标题"><a href="#多说一句标题" class="headerlink" title="多说一句标题"></a>多说一句标题</h2><p>一向主打意识流（想到啥写啥）的写作手法，使我在写文章的时候不得不对一个标题做一个特别的说明。<br>从来源上说，这个标题 neta 自今年 BMS 的整活作品<a href="https://www.bilibili.com/video/BV1de4y1v7VS">《【BOF:ET】 BMS：不为所动 做更专业的自己》</a>，但在敲下标题之后，我又特地去谷歌了一下「不为所动」这个词的含义：</p><blockquote><p>不为所动，bù wéi suǒ dòng ，指不受外力影响而变动，引申为：不管别人说什么或者怎么诱惑你，都能坚持自己的初衷和原则，立场坚定。</p></blockquote><p>若按卫健委通报时间（2019年12月31日）算，今天应该恰好是3周年的时候。这3周年期间，在被疫情打乱的世界线上，我们能够「不为所动」的事件又有多少呢？<br>以往做年度总结的时候，相信大伙都会给自己的下一年做一个简单的计划。但一年的变数过去之后，计划可能也是有些七零八碎了，从宏大的旅游计划，最后变成了「活着就好」。 当然，我个人的阅历水平也注定了我没有「写合订本」的那种年度报告能力，只能东摸摸西看看，把算法嚼烂过的大数据再喂给读者看一看。虽然从根本上来说，或许没人会对一个和他交集不大的人一年到头的数据感兴趣，但总结数据本身已经成了一个年度定番，亦或是一个人归档过去这一年的仪式。我们不妨把话说肤浅一点：管其他人如何评价呢，不为所动，我要写最业余的年度报告。</p><h2 id="创作"><a href="#创作" class="headerlink" title="创作"></a>创作</h2><p>前半年由于众所周知的原因，各方面基本上都处于休眠状态。真论创作嘛，议论文写了不少。主要的输出都在下半年，但是真没逃过懒的毛病。</p><h3 id="博客"><a href="#博客" class="headerlink" title="博客"></a>博客</h3><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/12/wp_editor_md_3668a8458a1bb6ad67e2488ac1e7b455.jpg"/></div></div><p>今年博客上合计有 4 篇文章，其中水分大的有 2 篇。隔壁北蓝上还有一篇，但另当别论。<br>准确来说，今年是我正经开始写博客的元年，但写的还是不多，我想原因是对自己博客的功能认识不清。每写一篇文章都有大展拳脚的阵势，但是长的写不动，短的懒得写。<br>无论如何，每天都有个位数以上的人来我的博客踩踩，还不时能提醒我续一下不知道过期多久的证书，感谢大伙们这一年的遛弯。</p><h3 id="频道"><a href="#频道" class="headerlink" title="频道"></a>频道</h3><p>不得不提的是，频道在一定程度上承接了博客的作用。一些短到不想发博客的就排排版去频道发了，而且转发自带来源，传播起来真的快。<br>其他的不用说了，可以自行看图。 </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/12/wp_editor_md_14e55c7a72549c929ea0ada2896915bd.jpg"/></div></div><h2 id="娱乐"><a href="#娱乐" class="headerlink" title="娱乐"></a>娱乐</h2><h3 id="音乐"><a href="#音乐" class="headerlink" title="音乐"></a>音乐</h3><p>今年听歌的重心从网易云转移到了 Spotify，相应的数据也就发生了偏移，使我不得不对着两份报告来看。  </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/12/wp_editor_md_8812b06fbea801e2a34938f4e62e8f29.jpg"/></div></div> <div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/12/wp_editor_md_b7241330f00c42e3463229021cabd24a.jpg"/></div></div> <p>我印象比较深的几首歌，或者说是主观年度推歌：  </p><ul><li><a href="https://music.163.com/song?id=1815533595">Look at the Sky - Porter Robinson</a> - <a href="https://music.163.com/song?id=1863541743">Phoenix - Netrum</a>   </li><li><a href="https://music.163.com/song?id=1953860839">Your Star - 塞壬唱片-MSR</a>   </li><li><a href="https://www.bilibili.com/video/BV1SG411H7KH/">Everything Goes On - Porter Robinson</a> - <a href="https://music.163.com/song?id=1968218336">Moving On - Rare Americans</a>  </li><li><a href="https://music.163.com/song?id=1359151531">Howling - Lupus Nocte</a> </li><li><a href="https://music.163.com/song?id=1472780388">Caution - The Killers</a></li></ul><h3 id="游戏"><a href="#游戏" class="headerlink" title="游戏"></a>游戏</h3><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/12/wp_editor_md_f17b602e73dbb1d75f9d58193d9ee4aa.jpg"/></div></div><p>今年总的来看，仍然以 CSGO 为主，虽然一年过去了仍然在 D+ 上下蹦跶，但总的来说枪法是准了点。  </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/12/wp_editor_md_76f920d1e4196560166fe2726ebd6e96.jpg"/></div></div><h4 id="今年玩过的其他游戏"><a href="#今年玩过的其他游戏" class="headerlink" title="今年玩过的其他游戏"></a>今年玩过的其他游戏</h4><h5 id="橡胶强盗"><a href="#橡胶强盗" class="headerlink" title="橡胶强盗"></a>橡胶强盗</h5><p><a href="https://store.steampowered.com/app/1206610">https://store.steampowered.com/app/1206610</a><br>从试玩版眼睁睁看着它转正式版，又眼睁睁看着它降价的。<br>比较简单暴力的排队类游戏，远程联机或者多插个键盘，然后群殴抢分就行了。</p><h5 id="杀戮尖塔"><a href="#杀戮尖塔" class="headerlink" title="杀戮尖塔"></a>杀戮尖塔</h5><p><a href="https://store.steampowered.com/app/646570/Slay_the_Spire">https://store.steampowered.com/app/646570/Slay_the_Spire</a><br>今年主要是带着 MOD 玩的，模组赋予游戏第二春的典例（点名 Downfall 等）。</p><h5 id="冰与火之舞"><a href="#冰与火之舞" class="headerlink" title="冰与火之舞"></a>冰与火之舞</h5><p><a href="https://store.steampowered.com/app/977950/_A_Dance_of_Fire_and_Ice">https://store.steampowered.com/app/977950/_A_Dance_of_Fire_and_Ice</a><br>单键类音游，门槛低但上限高。谱面是绝对亮点。</p><h5 id="地平线4-5"><a href="#地平线4-5" class="headerlink" title="地平线4&#x2F;5"></a>地平线4&#x2F;5</h5><p><a href="https://store.steampowered.com/app/1293830/_4">https://store.steampowered.com/app/1293830/_4</a><br>玩4或是玩5，取决于我号上的 Xbox Game Pass 有没有到期，观光游戏名副其实，甚至说我今年主听的很大一部分歌都是游戏电台来的。<br>不过看了 Steam 的一句话评价属实没绷住：  </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/12/wp_editor_md_e12fa01b41b4e6e4fa52fbed61c57eef.jpg"/></div></div><h5 id="求生之路2"><a href="#求生之路2" class="headerlink" title="求生之路2"></a>求生之路2</h5><p><a href="https://store.steampowered.com/app/550/Left_4_Dead_2">https://store.steampowered.com/app/550/Left_4_Dead_2</a><br>联机玩，带 MOD 玩。<br>不得不说最近几年出的几张国产图是真不错。这游戏今年就大张旗鼓联机玩了一次，玩的是那个广州增城，带了个 CCTV6 的 MOD ，代入感真的强。<br>其余的看图。  </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/12/wp_editor_md_5142104b02ec1b36ec4375db9641eda6.jpg"/></div></div><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/12/wp_editor_md_f39e2c9e311dcec67cdcb068a0a474cc.jpg"/></div></div><h3 id="视频"><a href="#视频" class="headerlink" title="视频"></a>视频</h3><p>今年下半年以看 CSGO 赛事为主，基本上跟完了 EPL 到 BLAST 全球总决赛的全过程，最终的感受：G2，继续质疑！<br>赛事解说方面推荐一下 B 站老懂哥：<a href="https://live.bilibili.com/21674333">https://live.bilibili.com/21674333</a> ，比较有激情，起码睡不着。<br>其他方面，给 Veritasium真理元素 做了几期时间轴，给吴伟老师做了几期轴，权当练了练听力。</p><h2 id="健康"><a href="#健康" class="headerlink" title="健康"></a>健康</h2><p>身处一个地势上上下下不能用车的学校是什么体验？那自然是每天步数拉满咯。  </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/12/wp_editor_md_3435aca783fd1d48d143531564fe6c49.jpg"/></div></div><p>至于新冠，那自然是没能进决赛圈。病比较常规，连续烧了几天就只剩咳嗽了，倒是到写文章时也没止住咳嗽。  </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/12/wp_editor_md_697adc74d85530b153b1113694ba63a2.jpg"/></div></div><h2 id="多嘴一句"><a href="#多嘴一句" class="headerlink" title="多嘴一句"></a>多嘴一句</h2><p>我这篇年终总结已经算是出稿比较晚的了，但也是在高速上用笔记本完成的。<br>总有人说，今年最大的成就是啥啊？应该是身体健康地活着。<br>三年以来，我们已经见过太多的变数，或许最应该「不为所动」的，是抛开幻想，去珍视现实，珍视身边的人，珍视跨年的时光。<br>本文没有解谜，没有彩蛋，仅祝有闲心读到这里的各位跨年快乐。</p>]]></content>
    
    
    <summary type="html">虽然从根本上来说，或许没人会对一个和他交集不大的人一年到头的数据感兴趣，但总结数据本身已经成了一个年度定番，亦或是一个人归档过去这一年的仪式。我们不妨把话说肤浅一点：管其他人如何评价呢，不为所动，我要写最业余的年度报告。</summary>
    
    
    
    <category term="杂记" scheme="https://blog.hanlin.press/categories/%E6%9D%82%E8%AE%B0/"/>
    
    <category term="日常" scheme="https://blog.hanlin.press/categories/%E6%97%A5%E5%B8%B8/"/>
    
    
    <category term="口水" scheme="https://blog.hanlin.press/tags/%E5%8F%A3%E6%B0%B4/"/>
    
    <category term="杂谈" scheme="https://blog.hanlin.press/tags/%E6%9D%82%E8%B0%88/"/>
    
    <category term="年度报告" scheme="https://blog.hanlin.press/tags/%E5%B9%B4%E5%BA%A6%E6%8A%A5%E5%91%8A/"/>
    
    <category term="2022" scheme="https://blog.hanlin.press/tags/2022/"/>
    
  </entry>
  
  <entry>
    <title>对于谷歌翻译大陆版「引导页」行为的观测（2022年9月底）</title>
    <link href="https://blog.hanlin.press/2022/09/translate-google-cnhp/"/>
    <id>https://blog.hanlin.press/2022/09/translate-google-cnhp/</id>
    <published>2022-09-28T04:06:55.000Z</published>
    <updated>2022-09-30T04:06:55.000Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p>9月30日更新： <em>谷歌发言人通过电子邮件向 TechCrunch 证实，“由于使用率低”，公司已停止在中国大陆的谷歌翻译服务。</em> <a href="https://techcrunch.com/2022/09/30/google-appears-to-have-disabled-google-translate-in-parts-of-china/">https://techcrunch.com/2022/09/30/google-appears-to-have-disabled-google-translate-in-parts-of-china/</a></p></blockquote><h3 id="缘起"><a href="#缘起" class="headerlink" title="缘起"></a>缘起</h3><p>今天有群友观测到，在他的地区访问大陆版谷歌翻译 <a href="http://translate.google.cn/">translate.google.cn</a> 时，会概率性出现类似于访问 <a href="http://www.google.cn/">www.google.cn</a> 的引导页。 幸运的是，谷歌自己的<a href="https://webcache.googleusercontent.com/search?q=cache:cv0sHIlKDmMJ:https://translate.google.cn/?hl=zh-CN&cd=1&hl=zh-CN&ct=clnk&gl=jp">一个快照</a>记载下了这一瞬间：  </p><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/09/wp_editor_md_1ff206868971bf1c40055f2776be8cb1.jpg"/></div></div> <p>怪异之，遂展开简单研究。<strong>以下的内容极不专业，均为发博客时现场观测，不代表博主观点与立场，仅为通过第三方媒介进行的客观陈述，不具有证明意义与预测价值。</strong></p><h3 id="引导页的典型特点"><a href="#引导页的典型特点" class="headerlink" title="引导页的典型特点"></a>引导页的典型特点</h3><p>以下内容均通过<a href="https://www.boce.com/?k=kj5xsMXdKO">拨测</a>展开观测，他们的多节点是真的好用，不过如果你愿意的话也可以用别的监测工具。 引导页的网页内容十分简单，一张<a href="https://www.google.cn/intl/zh-CN_cn/landing/cnexp/google-search.png">图</a>和一行「请收藏我们的网址」，<a href="https://www.boce.com/http/d52cf90fd017452b4463a88f8e4e107e.html">监测</a>完发现文件不大，保持在极为稳定的 1.36kB： <a href="/wp-content/uploads/2022/09/wp_editor_md_e2d93c80500c6839a12cea70207870ad.jpg"><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/09/wp_editor_md_e2d93c80500c6839a12cea70207870ad.jpg"/></div></div></a><br>与隔壁正常状态下的谷歌翻译简直天壤之别（966kB 上下）： <a href="/wp-content/uploads/2022/09/wp_editor_md_3590c13e375dd19cd27ac2e3df431a24.jpg"><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/09/wp_editor_md_3590c13e375dd19cd27ac2e3df431a24.jpg"/></div></div></a> 与此同时，正常的谷歌翻译前端会有 <code>Set-Cookie</code> 等一大串响应头，而引导页的响应头则简简单单： <a href="/wp-content/uploads/2022/09/wp_editor_md_66892d4039bc749344f3f5e56b753ddf.jpg"><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/09/wp_editor_md_66892d4039bc749344f3f5e56b753ddf.jpg"/></div></div></a></p><h3 id="简易观测"><a href="#简易观测" class="headerlink" title="简易观测"></a>简易观测</h3><p>掌握了对应特征，我们就可以来观测了。<br>重点前置，先把观测结果放上：<a href="https://www.boce.com/http/b5db5f44b9201cf2d8aeb61ea11d9d52.html">https://www.boce.com/http/b5db5f44b9201cf2d8aeb61ea11d9d52.html</a><br>通过按大小排序，很容易找到受影响地区：<br><a href="/wp-content/uploads/2022/09/wp_editor_md_788336e162b26bd9ea54a614ced13d51.jpg"><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/09/wp_editor_md_788336e162b26bd9ea54a614ced13d51.jpg"/></div></div></a> 尽管来自不同节点，但是他们都返回了类似引导页的数据。<br>更有趣的是他们的 <code>Server</code> 响应头。对于正常响应而言，他们的数据都是 <code>Server: ESF</code>，而对于「引导页」而言，他们的数据变成了 <code>Server: sffe</code>。<br><a href="/wp-content/uploads/2022/09/wp_editor_md_a0eaceeb9fd9442fb19d5e651b1e8b21.jpg"><div class="tag-plugin image"><div class="image-bg"><img src="/wp-content/uploads/2022/09/wp_editor_md_a0eaceeb9fd9442fb19d5e651b1e8b21.jpg"/></div></div></a> 而在 <code>www.google.cn</code> 的监测结果中，他们的 <code>Server</code> 头也<strong>都是</strong><code>sffe</code>。 根据一些简单的查询，发现 <code>sffe</code> 基本被用于存放形如 <code>gstatic</code> 的静态页。 那么这一次发生了什么事情呢？但愿是个乌龙吧。</p>]]></content>
    
    
      
      
    <summary type="html">&lt;blockquote&gt;
&lt;p&gt;9月30日更新： &lt;em&gt;谷歌发言人通过电子邮件向 TechCrunch 证实，“由于使用率低”，公司已停止在中国大陆的谷歌翻译服务。&lt;/em&gt; &lt;a href=&quot;https://techcrunch.com/2022/09/30/google-a</summary>
      
    
    
    
    <category term="瞎折腾" scheme="https://blog.hanlin.press/categories/%E7%9E%8E%E6%8A%98%E8%85%BE/"/>
    
    
    <category term="谷歌" scheme="https://blog.hanlin.press/tags/%E8%B0%B7%E6%AD%8C/"/>
    
  </entry>
  
</feed>
