/ clis / bilibili / subtitle.js
subtitle.js
 1  import { cli, Strategy } from '@jackwener/opencli/registry';
 2  import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
 3  import { apiGet, resolveBvid } from './utils.js';
 4  cli({
 5      site: 'bilibili',
 6      name: 'subtitle',
 7      description: '获取 Bilibili 视频的字幕',
 8      strategy: Strategy.COOKIE,
 9      args: [
10          { name: 'bvid', required: true, positional: true },
11          { name: 'lang', required: false, help: '字幕语言代码 (如 zh-CN, en-US, ai-zh),默认取第一个' },
12      ],
13      columns: ['index', 'from', 'to', 'content'],
14      func: async (page, kwargs) => {
15          if (!page)
16              throw new CommandExecutionError('Browser session required for bilibili subtitle');
17          const bvid = await resolveBvid(kwargs.bvid);
18          // 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
19          await page.goto(`https://www.bilibili.com/video/${bvid}/`);
20          // 2. 利用 __INITIAL_STATE__ 获取基础信息,拿 CID
21          const cid = await page.evaluate(`(async () => {
22        const state = window.__INITIAL_STATE__ || {};
23        return state?.videoData?.cid;
24      })()`);
25          if (!cid) {
26              throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
27          }
28          // 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
29          // 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
30          const payload = await apiGet(page, '/x/player/wbi/v2', {
31              params: { bvid, cid },
32              signed: true, // 开启 wbi_sign 自动签名
33          });
34          if (payload.code !== 0) {
35              throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
36          }
37          const needLoginSubtitle = payload.data?.need_login_subtitle === true;
38          const subtitles = payload.data?.subtitle?.subtitles || [];
39          if (subtitles.length === 0) {
40              if (needLoginSubtitle) {
41                  throw new AuthRequiredError('bilibili.com', 'Bilibili subtitles are hidden behind login for this video. Please log in to bilibili.com in Chrome and retry.');
42              }
43              throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
44          }
45          // 4. 选择目标字幕语言
46          const target = kwargs.lang
47              ? subtitles.find((s) => s.lan === kwargs.lang) || subtitles[0]
48              : subtitles[0];
49          const targetSubUrl = target.subtitle_url;
50          if (!targetSubUrl || targetSubUrl === '') {
51              throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
52          }
53          const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
54          // 5. 解析并拉取 CDN 的 JSON 文件
55          const fetchJs = `
56        (async () => {
57           const url = ${JSON.stringify(finalUrl)};
58           const res = await fetch(url);
59           const text = await res.text();
60           
61           if (text.startsWith('<!DOCTYPE') || text.startsWith('<html')) {
62              return { error: 'HTML', text: text.substring(0, 100), url };
63           }
64           
65           try {
66               const subJson = JSON.parse(text);
67               // B站真实返回格式是 { font_size: 0.4, font_color: "#FFFFFF", background_alpha: 0.5, background_color: "#9C27B0", Stroke: "none", type: "json" , body: [{from: 0, to: 0, content: ""}] }
68               if (Array.isArray(subJson?.body)) return { success: true, data: subJson.body };
69               if (Array.isArray(subJson)) return { success: true, data: subJson };
70               return { error: 'UNKNOWN_JSON', data: subJson };
71           } catch (e) {
72               return { error: 'PARSE_FAILED', text: text.substring(0, 100) };
73           }
74        })()
75      `;
76          const items = await page.evaluate(fetchJs);
77          if (items?.error) {
78              throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
79          }
80          const finalItems = items?.data || [];
81          if (!Array.isArray(finalItems)) {
82              throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
83          }
84          // 6. 数据映射
85          return finalItems.map((item, idx) => ({
86              index: idx + 1,
87              from: Number(item.from || 0).toFixed(2) + 's',
88              to: Number(item.to || 0).toFixed(2) + 's',
89              content: item.content
90          }));
91      },
92  });