背景

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

B 站首页动态壁纸

上图是 B 站首页的动态壁纸,由 28 层 PNG 叠加而成,鼠标移动时各层以不同速度偏移,产生视差效果。

我想让博客底部也出现这条壁纸,而且要实时跟着 B 站走——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.com、i1.hdslb.com、i2.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 Pages(ariesfun.github.io)

GitHub Actions 必须跑在 GitHub 仓库上,所以先把源码同步一份过去:

1
2
3
4
5
6
7
8
9
本地 Gitea(源码)──push──▶ GitHub(blog-source,Private)
│
▼ Actions 定时触发
① 抓取壁纸清单
② hexo generate
③ hexo deploy
│
▼
GitHub Pages(ariesfun.github.io)

推送源码到 GitHub

1
2
git remote add github git@github.com:ariesfun/blog-source.git
git push github master

配置 Deploy Key

Actions 需要权限把产物推到 Pages 仓库:

  1. 生成密钥对:
    1
    2
    ssh-keygen -t ed25519 -C "blog-source-actions" \
    -f ~/.ssh/blog-source-deploy -N ""
  2. 公钥 → ariesfun.github.io 仓库的 Settings → Deploy keys(勾选 Allow write access)
  3. 私钥 → blog-source 仓库的 Settings → Secrets → Actions,命名为 HEXO_DEPLOY_KEY

走过的弯路

一开始我把 workflow 塞进了产物仓库——因为源码不在 GitHub,只能让 CI 跟着产物走。

为此需要两个 hack。第一个是 Hexo 插件,在构建后把 .github/ 复制进产物:

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(否则隐藏目录不会被部署上去)。

能跑,但不该这么做:

问题 说明
产物不纯净 产物仓库本该能随时整体重建,却混着 CI 配置
排查困难 workflow 消失时,得先想到「是不是插件没跑」
脆弱 插件没装、配置丢失都会静默失效

结论:让每个仓库只做该做的事——产物仓库只管输出,CI 配置待在源码仓库。
改成源码同步后,上面两个 hack 全部删除。

Workflow 配置

.github/workflows/deploy.yml(放在源码仓库里):

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
name: 构建并部署博客
on:
schedule:
- cron: '0 */6 * * *' # 每 6 小时
workflow_dispatch: # 支持手动触发
push:
branches: [master] # 推送即部署

jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci

- name: 安装 Puppeteer(含 Chrome 系统依赖)
run: |
npm i puppeteer@23 --no-save
sudo npx puppeteer browsers install chrome --install-deps

- name: 抓取壁纸清单
run: node .github/scripts/grab-bili-banner.mjs
continue-on-error: true # 抓取失败不阻断部署

- name: 配置 SSH
env:
DEPLOY_KEY: ${{ secrets.HEXO_DEPLOY_KEY }}
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan github.com >> ~/.ssh/known_hosts

- name: 生成并部署
run: |
npx hexo clean
npx hexo generate
npx hexo deploy

首跑验证

  1. Actions 页面 → 「构建并部署博客」→ Run workflow 手动触发
  2. 等 2~3 分钟,确认显示绿色 ✓
  3. 打开博客确认底部壁纸正常显示

踩过的坑

现象 原因 解法
图片全部 403 B 站 CDN 有 Referer 校验 <img referrerpolicy="no-referrer">
壁纸完全不显示 主题构建时把 src 换成 1×1 占位图 JS 里从 data-lazy-src 恢复 src
鼠标移动时闪烁 视差在图片加载完成前就启用 等所有图片 load 后再绑定视差
每次判定「壁纸变了」 CDN 域名轮换(i0/i1/i2) 指纹只取文件名,不含域名
npm ci 报 lock 不同步 package-lock.json 未更新 本地 npm install 后提交 lock
Actions 装依赖失败 新版 Ubuntu 的 libasound2 改名 用 puppeteer browsers install chrome --install-deps

效果

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

参考


完。