追番页面与外部数据源(Bangumi / Bilibili)
Mizuki 的追番能力由「构建期数据抓取脚本 + 站点配置 + 静态数据文件 + 页面渲染」四部分组成:脚本 scripts/update-bangumi.mjs 在本地运行时读取 config/siteConfig.ts 中的 Bangumi 用户 ID 与动画数据源模式,通过 Bangumi(bgm.tv)v0 公开 API 分页拉取收藏并逐条补全条目详情,最终把加工后的结构化列表写入 src/data/bangumi-data.json,供追番页面 src/pages/anime.astro 渲染。
Purpose and Scope(目的与范围)
本页完整覆盖追番能力的端到端机制,包括:
- 构建期数据管道:
scripts/update-bangumi.mjs的配置门控、API 集成、限速策略、分页拉取与字段加工; - 外部数据源契约:Bangumi v0 API(
https://api.bgm.tv)的收藏列表接口与条目详情接口; - 站点配置:
config/siteConfig.ts中bangumi.userId、anime.mode的读取方式与默认值; - 数据模型:
src/data/bangumi-data.json的完整 schema 与字段来源; - 失败模式、边界条件与运维注意事项。
有意留给兄弟页面的内容:
- 追番页面
src/pages/anime.astro的组件内部实现(布局、卡片组件、Svelte 交互)——本页仅说明它作为数据消费方的位置; scripts/read-site-config.mjs的解析器内部实现——本页仅从调用方视角记录其契约matchSiteConfig(section, regex);- 站点整体架构、主题与构建配置,以及其他内容页面(文章、友链等)。
说明:由于本次源码读取预算限制,
src/pages/anime.astro与scripts/read-site-config.mjs的内部实现未经读取验证,相关小节会明确标注证据边界,不做臆测。
Overview(概述)
追番页面展示博主的动画观看清单。与其他"运行时调用第三方 API"的方案不同,Mizuki 采用**构建期预抓取(build-time pre-fetch)**策略:
- 为何构建期抓取:Bangumi API 有速率限制(脚本中显式加入
delay(300)/delay(150)),且收藏数据变化频率低。把抓取放到构建/更新脚本中,页面运行时只读本地 JSON,既避免访客请求被限流,也保证了静态站点的加载速度与可用性。 - 多数据源设计:
config/siteConfig.ts中的anime.mode决定使用哪个数据源。当mode !== "bangumi"时,本脚本直接跳过——这为并行的 Bilibili 数据管道留出了切换空间(Bilibili 侧的具体实现未在本次阅读范围内验证)。 - 数据降级链:每个字段都设计了多级回退(用户评分 → 条目评分 → 0;中文标题 → 原文标题 → "Unknown Title";封面 →
/assets/anime/default.webp),保证脏数据或缺失数据不会中断整批更新。
典型使用场景:用户在 bgm.tv 标记"在看/想看/看过/搁置/抛弃"后,运行该脚本即可把整份清单同步进站点仓库,随站点一起部署。
Architecture(架构)
架构解读:
- 配置层:
config/siteConfig.ts是唯一的事实来源。脚本不通过 AST 解析 TypeScript,而是用正则从文件文本中抽取键值(见下文"配置门控"),因此脚本可在无 TS 编译器依赖的纯 Node 环境下运行(node/npx直接执行.mjs)。 - 脚本层:
main()是唯一编排者,按watching → planned → completed → onhold → dropped的顺序遍历五种收藏类型;fetchCollection负责分页与错误兜底,processData负责字段加工与详情补全。 - 外部 API:仅使用两个无需鉴权的 v0 公开端点——收藏列表与条目详情。收藏接口一次性返回
subject摘要(name_cn、images.medium、tags、eps等),详情接口用于补充infobox(制作公司)与summary(简介)。 - 数据层:输出为带缩进的 JSON(
JSON.stringify(finalAnimeList, null, 2)),直接提交进仓库,属于"数据即代码资产"的静态化模式。 - 页面层:
src/pages/anime.astro是 Astro 路由/anime的入口,读取上述 JSON 渲染清单(其内部实现未在本次阅读中验证)。
Core Flow(核心流程)
以下时序图展示一次完整数据更新的真实执行路径(按 main() 的实际调用顺序):
关键控制流说明:
- 配置门控优先:
main()第一件事就是读anime.mode,非bangumi直接return——不会写文件,因此切换到 Bilibili 数据源时残留的旧bangumi-data.json不会被本脚本破坏或清空。 - 五种收藏类型的顺序是刻意的:
watching(3) → planned(1) → completed(2) → onhold(4) → dropped(5),把"在看"放在最前,保证即使中途失败,最关心的数据也已抓完(但注意:写入文件发生在全部处理完成后,中途失败不会产生半成品文件)。 - 进度可视化:脚本使用
process.stdout.write(...\r)原地刷新进度,长批次抓取时可见Processing progress: 12/300 (12345)。 - 错误终止路径:
main().catch()打印错误并process.exit(1),保证 CI 中能以非零码失败。
Data Model(数据模型)
脚本最终写入 src/data/bangumi-data.json 的记录结构如下(字段名与取值直接对应 processData 中 results.push({...}) 的键):
字段映射表(来源 API 字段 → 输出字段):
| 输出字段 | 来源(优先级从高到低) | 备注 |
|---|---|---|
title | item.subject.name_cn → item.subject.name → "Unknown Title" | 中文标题优先 |
rating | item.rate.toFixed(1) → item.subject.score.toFixed(1) → 0 | 个人评分优先于社区评分 |
cover | item.subject.images.medium → "/assets/anime/default.webp" | 站点内置兜底封面 |
description | subjectDetail.summary → item.subject.short_summary → item.subject.name_cn → "" | 详情接口提供的全文简介优先 |
studio | subjectDetail.infobox 中键为 动画制作/制作/製作/开发 的值 | 数组型值取第一个含 v 的项 |
genre | item.subject.tags 前 3 个的 name | 硬编码只取 3 个标签 |
totalEpisodes | item.subject.eps → progress | 无集数信息时用当前进度充当 |
link | https://bgm.tv/subject/{item.subject.id} → "#" | 指向 Bangumi 条目页 |
Usage Examples(代码示例)
示例 1:配置门控与用户 ID 读取
脚本从 TypeScript 配置中用正则抽取键值,避免引入 TS 编译依赖;同时对"仍是默认占位值"的配置给出警告但不阻断执行:
1function getUserIdFromConfig() {
2 const userId = matchSiteConfig("bangumi", /userId:\s*["']([^"']+)[\"']/);
3
4 if (!userId) {
5 console.error("✘ Failed to read Bangumi ID from config/siteConfig.ts");
6 throw new Error("Could not find bangumi.userId in config/siteConfig.ts");
7 }
8
9 if (userId === "your-bangumi-id" || userId === "your-user-id") {
10 console.warn(
11 "Warning: userId in src/config/siteConfig.ts appears to be a default value.",
12 );
13 }
14
15 return userId;
16}
17
18function getAnimeModeFromConfig() {
19 return matchSiteConfig("anime", /mode:\s*["']([^"']+)[\"']/) || "bangumi";
20}Source: scripts/update-bangumi.mjs
设计意图:mode 缺失时默认 "bangumi"(宽松默认值),而 userId 缺失时直接抛错终止(严格必需项)——二者区别体现了"模式是可选项、身份是前提"的取舍。注意源码中正则实际写作 /userId:\s*["']([^"']+)["']/(此处保留原文格式)。
示例 2:带 404 兜底与限速的分页抓取
1async function fetchCollection(userId, type) {
2 let allData = [];
3 let offset = 0;
4 const limit = 50;
5 let hasMore = true;
6
7 console.log(`Fetching type: ${type}...`);
8
9 while (hasMore) {
10 const url = `${API_BASE}/v0/users/${userId}/collections?subject_type=2&type=${type}&limit=${limit}&offset=${offset}`;
11 try {
12 const response = await fetch(url);
13
14 if (!response.ok) {
15 if (response.status === 404) {
16 console.log(
17 ` User ${userId} does not exist or has no data of this type.`,
18 );
19 return [];
20 }
21 throw new Error(`API Error ${response.status}`);
22 }
23
24 const data = await response.json();
25
26 if (data.data && data.data.length > 0) {
27 allData = [...allData, ...data.data];
28 process.stdout.write(
29 ` Fetched ${allData.length} records...\r`,
30 );
31 }
32
33 if (!data.data || data.data.length < limit) {
34 hasMore = false;
35 } else {
36 offset += limit;
37 await delay(300);
38 }
39 } catch (e) {
40 console.error(`\nFetch failed (Type ${type}):`, e.message);
41 hasMore = false;
42 }
43 }
44 console.log("");
45 return allData;
46}Source: scripts/update-bangumi.mjs
设计意图:以 data.length < limit 作为分页终止条件(短页即最后一页),无需预知总条数;404 被视为"该类型没有数据"的正常路径返回空数组,而非错误;网络异常则截断当批并返回已抓到的部分(hasMore = false),保证脚本可继续处理后续类型。
示例 3:逐条补全详情 + 制作公司提取
1function getStudioFromInfobox(infobox) {
2 if (!Array.isArray(infobox)) return "Unknown";
3
4 const targetKeys = ["动画制作", "制作", "製作", "开发"];
5
6 for (const key of targetKeys) {
7 const item = infobox.find((i) => i.key === key);
8 if (item) {
9 if (typeof item.value === "string") {
10 return item.value;
11 }
12 if (Array.isArray(item.value)) {
13 const validItem = item.value.find((v) => v.v);
14 if (validItem) return validItem.v;
15 }
16 }
17 }
18
19 return "Unknown";
20}Source: scripts/update-bangumi.mjs
设计意图:Bangumi 的 infobox 是结构松散的键值数组,value 既可能是字符串也可能是 {v: string} 数组,因此需要双分支兼容;候选键按优先级排列(简体中文 动画制作 → 通用 制作 → 繁体 製作 → 开发),覆盖条目编辑者使用的不同叫法。
示例 4:字段组装与降级链
1results.push({
2 title:
3 item.subject?.name_cn || item.subject?.name || "Unknown Title",
4 status: status,
5 rating: rating,
6 cover: item.subject?.images?.medium || "/assets/anime/default.webp",
7 description: description,
8 episodes: `${totalEpisodes} episodes`,
9 year: year,
10 genre: item.subject?.tags
11 ? item.subject.tags.slice(0, 3).map((tag) => tag.name)
12 : ["Unknown"],
13 studio: studio,
14 link: item.subject?.id
15 ? `https://bgm.tv/subject/${item.subject.id}`
16 : "#",
17 progress: progress,
18 totalEpisodes: totalEpisodes,
19 startDate: item.subject?.date || "",
20 endDate: item.subject?.date || "",
21});Source: scripts/update-bangumi.mjs
设计意图:全部使用可选链 ?. 与 || 组成的降级链,任何上游字段缺失都收敛到确定的兜底值,单条脏数据不会中断整批更新。注意 endDate 与 startDate 目前同源(均为 subject.date,即放送开始日),追番清单场景下这是"有值即展示"的简化处理。
Configuration Options(配置选项)
配置来源为 config/siteConfig.ts(脚本中的错误提示指向 src/config/siteConfig.ts,二者为同一文件的不同路径写法;以仓库实际为准)。
| 选项 | 类型 | 默认值 | 必填 | 说明 |
|---|---|---|---|---|
anime.mode | string | "bangumi" | 否 | 动画数据源模式。非 "bangumi" 时脚本直接跳过 Bangumi 更新(用于切换到 Bilibili 等其他数据源)。通过正则 /mode:\s*["']([^"']+)["']/ 从 anime 配置段读取 |
bangumi.userId | string | 无(缺失即抛错) | 是 | Bangumi(bgm.tv)用户 ID。通过正则 /userId:\s*["']([^"']+)["']/ 从 bangumi 配置段读取;等于占位值 your-bangumi-id / your-user-id 时仅告警不阻断 |
API_BASE | 常量 | "https://api.bgm.tv" | — | 脚本内硬编码的 Bangumi v0 API 基址,非配置项 |
OUTPUT_FILE | 常量 | src/data/bangumi-data.json | — | 输出文件路径,由脚本自身位置推导(../src/data/bangumi-data.json) |
limit | 常量 | 50 | — | 分页大小,与 API 上限对齐 |
delay | 常量 | 列表页 300ms / 详情 150ms | — | 请求间延迟,用于规避 API 速率限制 |
API Reference(API 参考)
本页涉及的对外接口分两类:脚本消费的外部 API,与脚本内部函数。
外部 API:Bangumi v0
GET /v0/users/{userId}/collections
查询参数:subject_type=2(动画)、type={1..5}(收藏类型)、limit=50、offset=N。响应形如 { data: [...] },每项含 subject_id、rate、ep_status、subject(内嵌摘要:name_cn、name、images.medium、tags、eps、date、score、short_summary、id)。返回 404 表示用户不存在或该类型无数据。无需鉴权。
GET /v0/subjects/{subjectId}
返回条目详情,脚本仅消费 infobox(键值数组,用于提取制作公司)与 summary(简介全文)。请求失败或非 2xx 时脚本返回 null 并走降级链。
脚本内部函数
getUserIdFromConfig(): string
返回:从配置中抽取的 Bangumi 用户 ID。
抛出:Error("Could not find bangumi.userId in config/siteConfig.ts")——当正则未匹配到值时。
getAnimeModeFromConfig(): string
返回:anime.mode 的值;未配置时返回 "bangumi"。不抛错。
delay(ms: number): Promise<void>
参数:ms——延迟毫秒数。返回:setTimeout 包装的 Promise。用于限速。
fetchSubjectDetail(subjectId): Promise<object | null>
参数:subjectId——条目 ID。返回:详情 JSON 对象,或 null(非 2xx / 网络异常时静默吞掉异常)。
getStudioFromInfobox(infobox): string
参数:infobox——详情接口返回的键值数组。返回:制作公司名;非数组或未命中候选键时返回 "Unknown"。
fetchCollection(userId, type): Promise<Array>
参数:userId、type(1–5)。返回:该类型的全部收藏条目数组;404 或网络异常时返回已抓取的部分(可能为空数组)。
processData(items, status): Promise<Array>
参数:items——收藏条目数组;status——目标状态字符串。返回:与数据模型 schema 一致的记录数组。
main(): Promise<void>
编排入口。mode !== "bangumi" 时直接返回;否则拉取五类收藏、加工并写出 JSON。顶层 main().catch() 捕获错误后 process.exit(1)。
Failure Modes, Edge Cases & Concurrency(失败模式、边界与并发)
失败模式与处理策略
| 场景 | 脚本行为 | 结果 |
|---|---|---|
bangumi.userId 缺失 | throw new Error(...) → process.exit(1) | 快速失败,无输出文件 |
userId 为占位默认值 | console.warn 后继续 | 用默认值请求 API,多半 404 |
| 收藏接口返回 404 | 记录日志并返回 [] | 该类型计 0 条,流程继续 |
| 收藏接口返回其他非 2xx | throw new Error("API Error {status}"),被 catch 后 hasMore = false | 保留已抓部分 |
| 详情接口失败 | fetchSubjectDetail 返回 null | studio/description 走兜底值 |
| 目录不存在 | fs.mkdir(recursive: true) | 自动创建 src/data/ |
| 顶层异常 | main().catch() 打印并退出码 1 | CI 可感知失败 |
边界条件
- 短页即末页:
data.length < limit判定结束,避免对最后不满 50 条的一页再发请求。 - 空集合:
rawData.length > 0才进入processData,避免无意义的详情轮询。 episodes为模板字符串:页面直接展示"{N} episodes",本地化交给前端处理(如需)。
并发与性能特性
- 完全串行:所有请求逐个发出(
for...of+await),配合 300ms/150ms 延迟。无并发,吞吐让位于对 API 的友好度——这是面向个人站点、低频更新的刻意取舍。 - 复杂度估算:设总条目数为 N,则请求数约为
⌈N/50⌉ × 5(列表)+ N(详情),总耗时下限约N × 150ms。300 条目时详情轮询约需 45 秒以上。 - 原子性:文件仅在全部数据处理完成后一次性写出,中途失败不会产生半成品 JSON——但代价是重跑需从头再来,无断点续传。
Performance & Operational Notes(性能与运维)
- 运行方式:纯 Node ESM 脚本(
.mjs),使用内置fetch与fs/promises,无需额外依赖安装(需 Node 18+ 的原生 fetch)。 - 输出体积:
JSON.stringify(list, null, 2)带两空格缩进,可读性好但体积大于紧凑格式;入库后由页面在构建时消费。 - 建议运维节奏:收藏变更后手动运行一次并提交 JSON;或接入 CI 定时任务。脚本以非零码退出可作为 CI 失败信号。
- 限速余量:详情 150ms、列表 300ms 的间隔相对保守,若需加速可并行化
fetchSubjectDetail,但需注意 bgm.tv 的实际限流阈值。
Extension Points(扩展点)
- 新增数据源(如 Bilibili):
anime.mode的门控机制天然支持并行的数据管道——实现一个update-bilibili.mjs(或等价物),读取同一anime.mode,在mode === "bilibili"时写入对应的输出文件即可,无需改动本脚本。(Bilibili 侧实现未在本次阅读范围验证。) - 新增输出字段:在
processData的results.push({...})中追加键,并同步更新src/data/bangumi-data.json的消费方(页面)。 - 替换正则式配置读取:
matchSiteConfig(section, regex)的契约允许更换更健壮的解析器而不影响调用方。 - 改进评分解析:当前
rating依赖item.rate为数值;若上游返回字符串会抛错,可在此处做Number()归一化。
Tests(测试)
仓库中未发现针对 scripts/update-bangumi.mjs 的自动化测试(本次源码探索范围内未见测试文件)。脚本自身通过 console.log / console.warn / console.error 输出人类可读的执行过程与失败信息,依赖人工或 CI 的退出码检查。
Related Links(相关链接)
- scripts/update-bangumi.mjs —— Bangumi 数据更新脚本(本页核心)
- src/pages/anime.astro —— 追番页面入口(数据消费方)
- scripts/read-site-config.mjs —— 配置正则读取工具(
matchSiteConfig) - src/data/bangumi-data.json —— 生成的数据文件
- config/siteConfig.ts —— 站点配置(
anime.mode、bangumi.userId) - Bangumi v0 API 文档:https://github.com/bangumi/api