9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
142 lines
7.1 KiB
JavaScript
142 lines
7.1 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||
import { apiGet, resolveBvid, parsePageArg, selectVideoPart } from './utils.js';
|
||
cli({
|
||
site: 'bilibili',
|
||
name: 'subtitle',
|
||
access: 'read',
|
||
description: '获取 Bilibili 视频的字幕',
|
||
domain: 'www.bilibili.com',
|
||
strategy: Strategy.COOKIE,
|
||
args: [
|
||
{ name: 'bvid', required: true, positional: true, help: 'Bilibili 视频 BV ID(如 BV1xx411c7mD),或视频 URL / b23.tv 短链' },
|
||
{ name: 'lang', required: false, help: '字幕语言代码 (如 zh-CN, en-US, ai-zh),默认取第一个' },
|
||
{ name: 'page', required: false, help: '分P 选集序号(从 1 开始)。多 P 视频取该集字幕;缺省取默认 P1' },
|
||
],
|
||
columns: ['index', 'from', 'to', 'content'],
|
||
func: async (page, kwargs) => {
|
||
if (!page)
|
||
throw new CommandExecutionError('Browser session required for bilibili subtitle');
|
||
const bvid = await resolveBvid(kwargs.bvid);
|
||
const selectedPage = parsePageArg(kwargs.page);
|
||
// 1. 通过 view API 拿 cid。
|
||
// 以前的实现走 page.goto(/video/<bvid>) + window.__INITIAL_STATE__.videoData.cid,
|
||
// bangumi 绑定的 bvid(番剧/纪录片/电影/综艺)页面 state 不在 videoData 而在 epList,
|
||
// 导致 SELECTOR 错。view API 接受任何 bvid(UGC + PGC 都通),且不依赖 DOM 结构。
|
||
let view;
|
||
try {
|
||
view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
|
||
}
|
||
catch (err) {
|
||
throw new CommandExecutionError(`获取视频信息失败: ${err?.message || err}`);
|
||
}
|
||
if (view?.code !== 0) {
|
||
throw new CommandExecutionError(`获取视频信息失败: ${view?.message ?? 'unknown'} (${view?.code})`);
|
||
}
|
||
// --page 给定时用该集 cid(selectVideoPart 越界抛错);缺省取整集默认 cid(P1,旧行为)。
|
||
const cid = selectedPage != null ? selectVideoPart(view?.data, selectedPage).cid : view?.data?.cid;
|
||
if (!cid) {
|
||
throw new CommandExecutionError(`无法从 view API 拿到 cid (bvid=${bvid})`);
|
||
}
|
||
// 2. 用带 Wbi 签名的 player/v2 拿字幕列表(之前 evaluate 里 fetch 因为没签名会 403)
|
||
let payload;
|
||
try {
|
||
payload = await apiGet(page, '/x/player/wbi/v2', {
|
||
params: { bvid, cid },
|
||
signed: true,
|
||
});
|
||
}
|
||
catch (err) {
|
||
throw new CommandExecutionError(`获取视频播放信息失败: ${err?.message || err}`);
|
||
}
|
||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||
throw new CommandExecutionError('获取到的视频播放信息对象不符合预期格式');
|
||
}
|
||
if (payload.code !== 0) {
|
||
throw new CommandExecutionError(`获取视频播放信息失败: ${payload.message} (${payload.code})`);
|
||
}
|
||
const needLoginSubtitle = payload.data?.need_login_subtitle === true;
|
||
const subtitles = payload.data?.subtitle?.subtitles;
|
||
if (!Array.isArray(subtitles)) {
|
||
throw new CommandExecutionError('获取到的字幕列表对象不符合数组格式');
|
||
}
|
||
if (subtitles.length === 0) {
|
||
if (needLoginSubtitle) {
|
||
throw new AuthRequiredError('bilibili.com', 'Bilibili subtitles are hidden behind login for this video. Please log in to bilibili.com in Chrome and retry.');
|
||
}
|
||
throw new EmptyResultError('bilibili subtitle', '此视频没有发现外挂或智能字幕。');
|
||
}
|
||
// 3. 选择目标字幕语言
|
||
const target = kwargs.lang
|
||
? subtitles.find((s) => s.lan === kwargs.lang) || subtitles[0]
|
||
: subtitles[0];
|
||
if (!target || typeof target !== 'object' || !Object.hasOwn(target, 'subtitle_url')) {
|
||
throw new CommandExecutionError('字幕条目缺少 subtitle_url 字段');
|
||
}
|
||
const targetSubUrl = typeof target.subtitle_url === 'string' ? target.subtitle_url.trim() : '';
|
||
if (!targetSubUrl) {
|
||
throw new AuthRequiredError('bilibili.com', '[风控拦截/未登录] 获取到的 subtitle_url 为空!请确保 CLI 已成功登录且风控未封锁此账号。');
|
||
}
|
||
const finalUrl = targetSubUrl.startsWith('//') ? 'https:' + targetSubUrl : targetSubUrl;
|
||
if (!/^https?:\/\//i.test(finalUrl)) {
|
||
throw new CommandExecutionError(`字幕 URL 非法: ${finalUrl}`);
|
||
}
|
||
// 4. 解析并拉取 CDN 的 JSON 文件
|
||
const fetchJs = `
|
||
(async () => {
|
||
const url = ${JSON.stringify(finalUrl)};
|
||
const res = await fetch(url);
|
||
const text = await res.text();
|
||
|
||
if (text.startsWith('<!DOCTYPE') || text.startsWith('<html')) {
|
||
return { error: 'HTML', text: text.substring(0, 100), url };
|
||
}
|
||
|
||
try {
|
||
const subJson = JSON.parse(text);
|
||
// 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: ""}] }
|
||
if (Array.isArray(subJson?.body)) return { success: true, data: subJson.body };
|
||
if (Array.isArray(subJson)) return { success: true, data: subJson };
|
||
return { error: 'UNKNOWN_JSON', data: subJson };
|
||
} catch (e) {
|
||
return { error: 'PARSE_FAILED', text: text.substring(0, 100) };
|
||
}
|
||
})()
|
||
`;
|
||
let items;
|
||
try {
|
||
items = await page.evaluate(fetchJs);
|
||
}
|
||
catch (err) {
|
||
throw new CommandExecutionError(`字幕获取失败: ${err?.message || err}`);
|
||
}
|
||
if (items?.error) {
|
||
throw new CommandExecutionError(`字幕获取失败: ${items.error}${items.text ? ' — ' + items.text : ''}`);
|
||
}
|
||
if (!items || typeof items !== 'object' || items.success !== true) {
|
||
throw new CommandExecutionError('字幕获取结果对象不符合预期格式');
|
||
}
|
||
const finalItems = items.data;
|
||
if (!Array.isArray(finalItems)) {
|
||
throw new CommandExecutionError('解析到的字幕列表对象不符合数组格式');
|
||
}
|
||
if (finalItems.length === 0) {
|
||
throw new EmptyResultError('bilibili subtitle', '字幕文件中没有字幕片段。');
|
||
}
|
||
// 5. 数据映射
|
||
return finalItems.map((item, idx) => {
|
||
const from = Number(item?.from);
|
||
const to = Number(item?.to);
|
||
if (!item || typeof item !== 'object' || !Number.isFinite(from) || !Number.isFinite(to)) {
|
||
throw new CommandExecutionError('字幕片段缺少有效 from/to 时间戳');
|
||
}
|
||
return {
|
||
index: idx + 1,
|
||
from: from.toFixed(2) + 's',
|
||
to: to.toFixed(2) + 's',
|
||
content: String(item.content ?? '')
|
||
};
|
||
});
|
||
},
|
||
});
|