背景

B 站首页顶部有一条很长的动态壁纸,不是一张图,而是十几到几十层 PNG 叠加,鼠标移动时各层以不同速度偏移,产生视差效果。每次季节更替或活动时,B 站都会换一套新壁纸。

我想让博客底部也出现这条壁纸,而且要实时跟着 B 站走——B 站换了,博客也跟着换,不需要我手动去改。

整体架构

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────┐     抓取脚本      ┌─────────────────┐
│ B 站首页 │ ────────────────▶ │ bili-banner.json│
│ (多层壁纸) │ Puppeteer │ (图层清单) │
└─────────────┘ └────────┬────────┘

GitHub Actions │ 每 6h 检查
(云端定时任务) │

┌─────────────┐ fetch ┌─────────────────┐
│ 访客浏览器 │ ◀────────────── │ ariesfun.github │
│ (渲染壁纸) │ 热更新 │ .io │
└─────────────┘ └─────────────────┘

核心思路:不下载图片,只存清单。清单里记录每层图片的 URL 和视差系数,前端访问时直接 fetch 这份清单,图片走 B 站 CDN——零带宽成本,B 站换了图博客自动跟着变。

第一步:抓包分析 B 站壁纸是怎么渲染的

打开 B 站首页,F12 看 Elements,找到 .animated-banner 这个容器:

1
2
3
4
5
6
7
8
9
<div class="animated-banner">
<div class="layer">
<img src="https://i2.hdslb.com/bfs/vc/xxx.png" style="transform: translate(100px, 0) scale(1)">
</div>
<div class="layer">
<img src="https://i2.hdslb.com/bfs/vc/yyy.png" style="transform: translate(50px, 0) scale(1.05)">
</div>
<!-- ... 更多层 -->
</div>

每层都是一个 <img>,通过 transform: translate(x, y) scale(s) 控制位置和大小。鼠标移动时,B 站用 JS 计算每层的位移量——移得越远,视差越强

关键发现

  1. 图片不是一张,是 20~30 层 PNG —— 远景层(天空、远山)移动慢,近景层(人物、前景)移动快
  2. B 站没有公开 API —— 壁纸数据是页面加载后用 JS 动态插入 DOM 的
  3. CDN 域名会轮换 —— i0.hdslb.comi1.hdslb.comi2.hdslb.com 随机出现,同一个壁纸可能从不同节点加载

第二步:写抓取脚本,生成图层清单

因为没有 API,只能用无头浏览器打开页面,抓取 DOM 里的信息。

抓取什么

  • 每层图片的 URL
  • 视差系数(怎么算?见下一步)

视差系数怎么测量

B 站的视差是基于鼠标位置的:鼠标在屏幕左端时各层在一个位置,移到右端时各层偏移不同距离。

我的做法是:

  1. 把鼠标移到 banner 左端 → 记录各层 transform
  2. 把鼠标移到 banner 右端 → 记录各层 transform
  3. 比较同一层两次的 translateX 差值,除以鼠标移动距离,得到位移系数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 伪代码
const leftX = banner.left + 50;
const rightX = banner.right - 50;
const span = rightX - leftX;

// 鼠标在左端
await page.mouse.move(leftX, midY);
const tfLeft = await page.evaluate(getTransforms);

// 鼠标在右端
await page.mouse.move(rightX, midY);
const tfRight = await page.evaluate(getTransforms);

// 计算系数
layers[i].kx = (tfRight[i].x - tfLeft[i].x) / span;

清单格式

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"fingerprint": "42b37263aa958e73",
"updated": "2026-09-18T08:04:57.442Z",
"width": 3840,
"height": 360,
"layers": [
{
"url": "https://i2.hdslb.com/bfs/vc/xxx.png",
"kx": 0.003478,
"ks": -0.0000079
}
]
}
  • fingerprint:所有图层文件名的 SHA1 摘要,用来判断是否换了壁纸(CDN 域名变化不算换)
  • kx:水平位移系数(正值向右移,负值向左移)
  • ks:缩放系数

第三步:前端渲染

博客用的是 Hexo + Butterfly 主题。前端需要:

  1. fetch 清单 —— 从 https://ariesfun.github.io/bili-banner.json 拉取
  2. 对比指纹 —— 如果和当前不一样,说明 B 站换壁纸了,重建图层
  3. 叠加渲染 —— 按清单里的 URL 创建 <img>,设置 referrerpolicy="no-referrer"(绕过 B 站防盗链)
  4. 视差交互 —— 监听 mousemove,根据鼠标在 banner 内的位置,计算每层 transform

关键代码片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 1. 拉取清单
const res = await fetch('https://ariesfun.github.io/bili-banner.json');
const data = await res.json();

// 2. 指纹比对
if (data.fingerprint === currentFingerprint) return; // 没换,什么都不做

// 3. 重建图层
data.layers.forEach((layer, i) => {
const img = document.createElement('img');
img.src = layer.url;
img.referrerPolicy = 'no-referrer'; // 关键!不加这个 B 站 CDN 会 403
img.className = `bili-layer bili-layer-${i}`;
container.appendChild(img);
});

// 4. 鼠标视差
document.addEventListener('mousemove', (e) => {
const rect = container.getBoundingClientRect();
const t = (e.clientX - rect.left) / rect.width; // 0 ~ 1
layers.forEach((layer, i) => {
const x = (t - 0.5) * layer.kx * rect.width;
const s = 1 + (t - 0.5) * layer.ks * rect.width;
imgs[i].style.transform = `translate(${x}px, 0) scale(${s})`;
});
});

首屏兜底

fetch 是异步的,首屏可能空白。解决方案:构建时把同一份清单内联到 HTML 里(Hexo 的 _data/ 机制),前端先用内联数据渲染,同时后台 fetch 最新清单——如果不一样再热更新。

第四步:GitHub Actions 定时同步

现在前端能实时拉清单了,但谁来更新清单

我的环境:

  • 博客源码在本地 Gitea(不在 GitHub)
  • 构建产物在 GitHub Pages(ariesfun.github.io

方案:让 GitHub Actions 跑在产物仓库上,定时执行抓取脚本,有变化就提交。

怎么把 workflow 塞进产物仓库

Hexo 默认构建时不复制隐藏目录.github/),而且 include 配置对它无效(实测过)。解决:写一个 Hexo 插件,在 after_generate 钩子中手动把 .github/ 复制到 public/

1
2
3
4
5
6
7
8
// scripts/copy-github-dir.js
hexo.extend.filter.register('after_generate', function () {
const src = path.join(hexo.base_dir, '.github');
const dst = path.join(hexo.public_dir, '.github');
if (!fs.existsSync(src)) return;
fs.rmSync(dst, { recursive: true, force: true });
fs.cpSync(src, dst, { recursive: true });
});

同时 _config.yml 的 deploy 段加 ignore_hidden: false,确保 hexo deploy 时把 .github/ 一起推上去。

Workflow 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name: 同步 B 站壁纸
on:
schedule:
- cron: '0 */6 * * *' # 每 6 小时
workflow_dispatch: # 支持手动触发
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm i puppeteer@23 --no-save
- run: node .github/scripts/grab-bili-banner.mjs
- run: |
git config user.name "github-actions[bot]"
git add bili-banner.json
git diff --staged --quiet || \
(git commit -m "chore: 同步 B 站壁纸" && git push)

首跑验证

  1. GitHub 仓库 → Settings → Actions → General → Workflow permissions → Read and write permissions
  2. Actions 页面 → 「同步 B 站壁纸」→ Run workflow 手动触发
  3. 等 1~2 分钟,看是否显示绿色 ✓

踩过的坑

现象 原因 解法
图片全部 403 B 站 CDN 有 Referer 校验 <img referrerpolicy="no-referrer">
source/.github/ 不出现在产物里 Hexo 跳过隐藏目录 after_generate 钩子手动复制
每次判定「壁纸变了」 CDN 域名轮换(i0/i1/i2) 指纹只取文件名,不含域名
轮播图空白 隐藏元素不会被懒加载 opacity 切换,不用 visibility: hidden
Node 20 废弃警告 GitHub Actions 平台升级 node-version: '22'

效果

  • 文件数:页面从 222 个文件降到 194 个(不再打包 28 张图片)
  • 清单体积:仅 3 KB
  • 更新延迟:B 站换壁纸后最多 6 小时,博客自动同步
  • 无需重建:清单更新后前端立即生效,不需要 hexo deploy

源码

完整的 workflow 和脚本在博客源码仓库的 .github/ 目录下,前端渲染代码在 source/js/footer-banner.js


完。