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 });