hexo配置追番页面 — Node.js 22

hexo 可以连接bilibili,加一个番剧页面,但是网上很多教程都是使用 hexo-bilibili-bangumi 这个插件实现的,但是 hexo-bilibili-bangumi 在 Node.js 18+ 下无法使用,所以我就重新做了一个新的加载方法。

效果可以看我的博客Wenjie Wang - 拾光的老人生活/追番 页面

一、抓取脚本(构建时拉取 B 站数据)

新建文件:source/scripts/fetch_bangumi.cjs

// source/scripts/fetch_bangumi.cjs
const fs = require("fs");
const path = require("path");

// 必填:你的 B 站 UID(纯数字)
const UID = process.env.BILI_UID || "3493143351659309";
// 选填:追番列表不公开时需要(浏览器 Cookie 中的 SESSDATA 值)
const SESSDATA = process.env.BILI_SESSDATA || "";

const API_BASE = "https://api.bilibili.com/x/space/bangumi/follow/list";
const PAGE_SIZE = 30; // B站分页
const TYPE_NUM = 1; // 1=番剧,2=追剧(当前只抓番剧)

async function requestOnce(status, pn) {
const url = `${API_BASE}?vmid=${UID}&type=${TYPE_NUM}&follow_status=${status}&ps=${PAGE_SIZE}&pn=${pn}`;
const headers = {
"User-Agent": "Mozilla/5.0",
"Referer": "https://www.bilibili.com/"
};
if (SESSDATA) headers["cookie"] = `SESSDATA=${SESSDATA};`;

const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.code !== 0) {
throw new Error(`Bilibili API error: code=${data.code}, message=${data.message}`);
}
return data.data || { list: [] };
}

async function fetchAll(status) {
let pn = 1;
const out = [];
while (true) {
const data = await requestOnce(status, pn);
const list = data.list || [];
for (const b of list) {
let cover = b?.cover || "";
if (cover.startsWith("http://")) cover = cover.replace("http://", "https://");
out.push({
title: b?.title ?? "",
cover: cover,
new_ep: b?.new_ep?.title ?? "",
total_count: b?.total_count ?? -1,
media_id: b?.media_id ?? 0,
season_id: b?.season_id ?? 0, // ← 新增:优先直达播放页
area: b?.areas?.[0]?.name ?? "",
type_name: b?.season_type_name ?? "",
evaluate: b?.evaluate ?? "",
stat: b?.stat ?? {}
});
}
if (list.length < PAGE_SIZE) break;
pn += 1;
}
return out;
}

(async () => {
if (!/^\d+$/.test(UID)) {
throw new Error("请设置环境变量 BILI_UID 为你的 B 站 UID(纯数字)。");
}

console.log(`[bangumi] Fetching UID=${UID} ...`);
const wantWatch = await fetchAll(1); // 想看
const watching = await fetchAll(2); // 在看
const watched = await fetchAll(3); // 已看

const outDir = path.resolve(process.cwd(), "source/bangumi");
fs.mkdirSync(outDir, { recursive: true });
const outPath = path.join(outDir, "data.json");
fs.writeFileSync(outPath, JSON.stringify({ wantWatch, watching, watched }, null, 2), "utf-8");
console.log(`[bangumi] Saved ${wantWatch.length + watching.length + watched.length} items -> ${outPath}`);
})().catch(err => {
console.error("[bangumi] Failed:", err);
process.exit(1);
});

若你的追番列表是非公开,要在系统里设置环境变量 BILI_SESSDATA(从浏览器 Cookie 里复制自己的 SESSDATA 值),否则接口可能返回空数组。

二、页面(读取本地 JSON 渲染三个标签)

新建文件:source/bangumi/index.md

---
title: 谁不热爱追番呢
date: 2025-09-01
layout: page
comments: false
top_img: img/top_img/bangumi.png
---
<div id="bangumi-root">
<style>
/* :root { --card:#fff; --muted:#6b7280; --border:#e5e7eb; --bg:#f8fafc; } */
#bangumi-root { margin:0; }
.bangumi-header { padding:24px 16px; text-align:center; background:var(--bg); }
.bangumi-title { margin:0 0 8px; font-size:30px; }
.tabs { display:flex; justify-content:center; gap:8px; flex-wrap:wrap; }
.tab { padding:8px 14px; border:1px solid var(--border); background:#fff; border-radius:10px; cursor:pointer; }
.tab.active { border-color:#3b82f6; color:#1f2937; box-shadow:0 1px 2px rgba(0,0,0,.06); }
.bangumi-main { padding:16px; max-width:1100px; margin:0 auto; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px; }
.card { background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden; transition:transform .15s ease; }
.card:hover { transform:translateY(-2px); }
.thumb { display:block; width:100%; aspect-ratio:10/14; object-fit:cover; background:#eef2f7; }
.content { padding:10px; }
.title { margin:0 0 6px; font-size:14px; line-height:1.35; }
.meta { margin:0; font-size:12px; color:var(--muted); }
.empty { text-align:center; color:var(--muted); padding:40px 0; }
</style>

<div class="bangumi-header">
<h1 class="bangumi-title">📺 我的追番</h1>
<div class="tabs">
<button class="tab" data-key="watching">在看</button>
<button class="tab active" data-key="wantWatch">想看</button>
<button class="tab" data-key="watched">已看</button>
</div>
</div>

<div class="bangumi-main">
<div id="grid" class="grid"></div>
<div id="empty" class="empty" style="display:none;">暂无数据,请先执行抓取脚本。</div>
</div>
</div>

<script>
(function () {
const PROXY_ON = true; // 若封面不显示,保持 true 通过代理加载;若你不想代理,可改为 false
const PROXY = (url) => {
try {
if (!url) return "";
// 统一成 https
if (url.startsWith("//")) url = "https:" + url;
if (url.startsWith("http://")) url = url.replace("http://", "https://");
if (!PROXY_ON) return url;
// 走公开镜像,避免防盗链(不带协议的域名)
const clean = url.replace(/^https?:\/\//, "");
return "https://images.weserv.nl/?url=" + encodeURIComponent(clean);
} catch (e) { return url || ""; }
};

const GRID = document.getElementById("grid");
const EMPTY = document.getElementById("empty");
const TABS = Array.from(document.querySelectorAll(".tab"));
let DATA = { wantWatch:[], watching:[], watched:[] };
let currentKey = "watching";

function fmtTotal(n) {
if (n === -1) return "未完结";
if (!n || n <= 0) return "";
return `全${n}话`;
}

function render(key) {
GRID.innerHTML = "";
const list = DATA[key] || [];
if (!list.length) { EMPTY.style.display = "block"; return; }
EMPTY.style.display = "none";

const frag = document.createDocumentFragment();
list.forEach(b => {
const card = document.createElement("article");
card.className = "card";

// 计算跳转链接:优先 season_id(播放页),否则 media_id(媒体页)
const href =
(b.season_id && `https://www.bilibili.com/bangumi/play/ss${b.season_id}`) ||
(b.media_id && `https://www.bilibili.com/bangumi/media/md${b.media_id}`) ||
"";

// 封面(加链接)
const img = document.createElement("img");
img.className = "thumb";
img.loading = "lazy";
img.referrerPolicy = "no-referrer";
img.alt = b.title || "";
img.src = PROXY(b.cover);
img.onerror = () => { img.onerror = null; img.src = (b.cover || "").replace(/^http:\/\//,"https://"); };

if (href) {
const aImg = document.createElement("a");
aImg.href = href; aImg.target = "_blank"; aImg.rel = "noopener noreferrer";
aImg.appendChild(img);
card.appendChild(aImg);
} else {
card.appendChild(img);
}

// 文本区
const box = document.createElement("div");
box.className = "content";

// 标题(加链接)
const h3 = document.createElement("h3");
h3.className = "title";
if (href) {
const aTitle = document.createElement("a");
aTitle.href = href; aTitle.target = "_blank"; aTitle.rel = "noopener noreferrer";
aTitle.textContent = b.title || "未命名";
h3.appendChild(aTitle);
} else {
h3.textContent = b.title || "未命名";
}

const p = document.createElement("p");
p.className = "meta";
const items = [
b.new_ep ? `进度:${b.new_ep}` : "",
(b.total_count === -1) ? "未完结" : (b.total_count > 0 ? `全${b.total_count}话` : ""),
b.area ? `地区:${b.area}` : ""
].filter(Boolean);
p.textContent = items.join(" · ");

box.appendChild(h3);
box.appendChild(p);
card.appendChild(box);

frag.appendChild(card);
});
GRID.appendChild(frag);
}


async function boot() {
try {
const res = await fetch("./data.json?_t=" + Date.now());
if (!res.ok) throw new Error("data.json not found");
DATA = await res.json();
} catch (e) {
console.error(e);
DATA = { wantWatch:[], watching:[], watched:[] };
}
render(currentKey);
}

TABS.forEach(tab => {
tab.addEventListener("click", () => {
TABS.forEach(t => t.classList.remove("active"));
tab.classList.add("active");
currentKey = tab.dataset.key;
render(currentKey);
});
});

boot();
})();
</script>

三、把它接入 Hexo

  1. 安装依赖:
npm i axios --save
  1. package.json 替换成如下代码:
{
"name": "hexo-site",
"version": "0.0.0",
"private": true,
"scripts": {
"bangumi:fetch": "node source/scripts/fetch_bangumi.cjs",
"prebuild": "npm run bangumi:fetch",
"preserver": "npm run bangumi:fetch",
"predeploy": "npm run bangumi:fetch && hexo generate",
"build": "hexo generate",
"clean": "hexo clean",
"deploy": "hexo deploy",
"server": "hexo server",
"start": "npm run server"
},
"hexo": {
"version": "7.3.0"
},
"dependencies": {
"add": "^2.0.6",
"axios": "^1.11.0",
"hexo": "^7.3.0",
"hexo-butterfly-artitalk": "^1.0.5",
"hexo-butterfly-categories-card": "^1.0.0",
"hexo-butterfly-envelope": "^1.0.15",
"hexo-butterfly-hpptalk": "^1.0.4",
"hexo-deployer-git": "^4.0.0",
"hexo-generator-archive": "^2.0.0",
"hexo-generator-category": "^2.0.0",
"hexo-generator-feed": "^3.0.0",
"hexo-generator-index": "^4.0.0",
"hexo-generator-search": "^2.4.3",
"hexo-generator-tag": "^2.0.0",
"hexo-helper-live2d": "^3.1.0",
"hexo-next-giscus": "^1.3.0",
"hexo-renderer-ejs": "^2.0.0",
"hexo-renderer-marked": "^7.0.0",
"hexo-renderer-pug": "^3.0.0",
"hexo-renderer-stylus": "^3.0.1",
"hexo-server": "^3.0.0",
"hexo-tag-plugins": "^1.0.5",
"hexo-theme-landscape": "^1.0.0",
"hexo-wordcount": "^6.0.1",
"i": "^0.3.7",
"image-size": "^2.0.2",
"install": "^0.13.0",
"live2d-widget-model-koharu": "^1.0.5",
"live2d-widget-model-miku": "^1.0.5",
"live2d-widget-model-wanko": "^1.0.5",
"or": "^0.2.0",
"yarn": "^1.22.22"
}
}

其实就是把下面这一部分代码加进去

{
"scripts": {
"bangumi:fetch": "node scripts/fetch_bangumi.mjs",
"build": "npm run bangumi:fetch && hexo g",
"start": "npm run bangumi:fetch && hexo s"
}
}
  1. 设置你的 UID(以及可选的 SESSDATA)后运行:
  • PowerShell(Windows):

    $env:BILI_UID="你的B站UID"
    # 若追番是私密的,还需要:
    # $env:BILI_SESSDATA="你的SESSDATA"

    npm run build # 会先自动抓取 -> 生成 data.json -> 再生成站点
    npm run server # 预览 http://localhost:4000/bangumi/
  • 或者临时前缀(Git Bash/CMD):

    BILI_UID=你的UID npm run start
    # 可选:BILI_SESSDATA=你的SESSDATA npm run start

脚本会生成:source/bangumi/data.json
然后你的 source/bangumi/index.md 页面(我之前给的那个)会从同目录读取 data.json,三个标签「想看/在看/已看」就会有内容了。

使用

以后使用的话要先运行

npm run build   # 自动抓取 -> 生成 data.json -> 再生成站点

之后正常运行

hexo clean && hexo g && hexo s

预览 http://localhost:4000