Repository Wiki
LyraVoid/Mizuki

追番页面与外部数据源(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)**策略:

  1. 为何构建期抓取:Bangumi API 有速率限制(脚本中显式加入 delay(300) / delay(150)),且收藏数据变化频率低。把抓取放到构建/更新脚本中,页面运行时只读本地 JSON,既避免访客请求被限流,也保证了静态站点的加载速度与可用性。
  2. 多数据源设计:config/siteConfig.ts 中的 anime.mode 决定使用哪个数据源。当 mode !== "bangumi" 时,本脚本直接跳过——这为并行的 Bilibili 数据管道留出了切换空间(Bilibili 侧的具体实现未在本次阅读范围内验证)。
  3. 数据降级链:每个字段都设计了多级回退(用户评分 → 条目评分 → 0;中文标题 → 原文标题 → "Unknown Title";封面 → /assets/anime/default.webp),保证脏数据或缺失数据不会中断整批更新。

典型使用场景:用户在 bgm.tv 标记"在看/想看/看过/搁置/抛弃"后,运行该脚本即可把整份清单同步进站点仓库,随站点一起部署。

Architecture(架构)

Loading diagram...

架构解读:

  • 配置层: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() 的实际调用顺序):

Loading diagram...

关键控制流说明:

  1. 配置门控优先:main() 第一件事就是读 anime.mode,非 bangumi 直接 return——不会写文件,因此切换到 Bilibili 数据源时残留的旧 bangumi-data.json 不会被本脚本破坏或清空。
  2. 五种收藏类型的顺序是刻意的:watching(3) → planned(1) → completed(2) → onhold(4) → dropped(5),把"在看"放在最前,保证即使中途失败,最关心的数据也已抓完(但注意:写入文件发生在全部处理完成后,中途失败不会产生半成品文件)。
  3. 进度可视化:脚本使用 process.stdout.write(...\r) 原地刷新进度,长批次抓取时可见 Processing progress: 12/300 (12345)。
  4. 错误终止路径:main().catch() 打印错误并 process.exit(1),保证 CI 中能以非零码失败。

Data Model(数据模型)

脚本最终写入 src/data/bangumi-data.json 的记录结构如下(字段名与取值直接对应 processData 中 results.push({...}) 的键):

Loading diagram...

字段映射表(来源 API 字段 → 输出字段):

输出字段来源(优先级从高到低)备注
titleitem.subject.name_cn → item.subject.name → "Unknown Title"中文标题优先
ratingitem.rate.toFixed(1) → item.subject.score.toFixed(1) → 0个人评分优先于社区评分
coveritem.subject.images.medium → "/assets/anime/default.webp"站点内置兜底封面
descriptionsubjectDetail.summary → item.subject.short_summary → item.subject.name_cn → ""详情接口提供的全文简介优先
studiosubjectDetail.infobox 中键为 动画制作/制作/製作/开发 的值数组型值取第一个含 v 的项
genreitem.subject.tags 前 3 个的 name硬编码只取 3 个标签
totalEpisodesitem.subject.eps → progress无集数信息时用当前进度充当
linkhttps://bgm.tv/subject/{item.subject.id} → "#"指向 Bangumi 条目页

Usage Examples(代码示例)

示例 1:配置门控与用户 ID 读取

脚本从 TypeScript 配置中用正则抽取键值,避免引入 TS 编译依赖;同时对"仍是默认占位值"的配置给出警告但不阻断执行:

javascript
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 兜底与限速的分页抓取

javascript
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:逐条补全详情 + 制作公司提取

javascript
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:字段组装与降级链

javascript
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.modestring"bangumi"否动画数据源模式。非 "bangumi" 时脚本直接跳过 Bangumi 更新(用于切换到 Bilibili 等其他数据源)。通过正则 /mode:\s*["']([^"']+)["']/ 从 anime 配置段读取
bangumi.userIdstring无(缺失即抛错)是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 条,流程继续
收藏接口返回其他非 2xxthrow new Error("API Error {status}"),被 catch 后 hasMore = false保留已抓部分
详情接口失败fetchSubjectDetail 返回 nullstudio/description 走兜底值
目录不存在fs.mkdir(recursive: true)自动创建 src/data/
顶层异常main().catch() 打印并退出码 1CI 可感知失败

边界条件

  • 短页即末页: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 的退出码检查。

Sources

(1 files)