Skip to content

Commit 663b338

Browse files
asimovVongclaudejackwener
authored
feat(bilibili): add summary command for the official AI video summary (#1590)
* feat(bilibili): add summary command for the official AI video summary Adds `opencli bilibili summary <bvid>` — fetches Bilibili's official AI-generated video summary (the "AI总结" shown on the video page) via /x/web-interface/view/conclusion/get. Returns the overall summary followed by the timestamped section outline, so you get a structured digest of a video without watching it. - Resolves cid + up_mid from the view endpoint (both required by the conclusion API), then calls the WBI-signed conclusion endpoint. - Throws a clear EmptyResultError when a video has no AI summary — Bilibili only generates them for some videos. Covered by clis/bilibili/summary.test.js (5 cases): summary + outline, summary without outline, no-summary, view-resolution failure, API error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(bilibili): harden summary command contract --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 7164615 commit 663b338

7 files changed

Lines changed: 411 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ To load the source Browser Bridge extension:
251251
|------|----------|
252252
| **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
253253
| **rednote** | `search` `note` `comments` `user` `download` `feed` `notifications` |
254-
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `video` `user-videos` |
254+
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `summary` `video` `user-videos` |
255255
| **tieba** | `hot` `posts` `search` `read` |
256256
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
257257
| **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ npm link
237237
| **tieba** | `hot` `posts` `search` `read` | 浏览器 |
238238
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 |
239239
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 |
240-
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
240+
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 |
241241
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `projects` `history` `export` | 桌面端 |
242242
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 |
243243
| **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 |

cli-manifest.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2463,6 +2463,32 @@
24632463
"sourceFile": "bilibili/subtitle.js",
24642464
"navigateBefore": true
24652465
},
2466+
{
2467+
"site": "bilibili",
2468+
"name": "summary",
2469+
"description": "获取 B站视频的官方 AI 总结(视频页「AI总结」同款,含分段大纲与时间戳)",
2470+
"access": "read",
2471+
"domain": "www.bilibili.com",
2472+
"strategy": "cookie",
2473+
"browser": true,
2474+
"args": [
2475+
{
2476+
"name": "bvid",
2477+
"type": "str",
2478+
"required": true,
2479+
"positional": true,
2480+
"help": "Video BV ID / URL / b23.tv short link"
2481+
}
2482+
],
2483+
"columns": [
2484+
"time",
2485+
"content"
2486+
],
2487+
"type": "js",
2488+
"modulePath": "bilibili/summary.js",
2489+
"sourceFile": "bilibili/summary.js",
2490+
"navigateBefore": "https://www.bilibili.com"
2491+
},
24662492
{
24672493
"site": "bilibili",
24682494
"name": "user-videos",

clis/bilibili/summary.js

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* Bilibili summary — fetches the official AI-generated video summary (the "AI总结"
3+
* shown on the video page) via /x/web-interface/view/conclusion/get.
4+
*/
5+
import { cli, Strategy } from '@jackwener/opencli/registry';
6+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
7+
import { apiGet, resolveBvid } from './utils.js';
8+
9+
const BILIBILI_HOST_RE = /(^|\.)bilibili\.com$/i;
10+
const B23_HOST_RE = /(^|\.)b23\.tv$/i;
11+
const BVID_RE = /^BV[A-Za-z0-9]+$/;
12+
13+
function formatTime(seconds) {
14+
const s = Math.max(0, Math.floor(Number(seconds) || 0));
15+
const h = Math.floor(s / 3600);
16+
const m = Math.floor((s % 3600) / 60);
17+
const sec = s % 60;
18+
const pad = (n) => String(n).padStart(2, '0');
19+
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
20+
}
21+
22+
async function readBvid(raw) {
23+
const input = String(raw ?? '').trim();
24+
if (!input) {
25+
throw new ArgumentError('bilibili summary bvid cannot be empty', 'Pass a BV ID, Bilibili video URL, or b23.tv short link.');
26+
}
27+
if (BVID_RE.test(input)) {
28+
return input;
29+
}
30+
let parsed = null;
31+
try {
32+
parsed = new URL(input);
33+
} catch {
34+
// Bare b23.tv short codes are accepted by the shared resolver.
35+
}
36+
if (parsed) {
37+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
38+
throw new ArgumentError('Bilibili summary URL must use http or https');
39+
}
40+
if (BILIBILI_HOST_RE.test(parsed.hostname)) {
41+
const match = parsed.pathname.match(/\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
42+
if (!match) {
43+
throw new ArgumentError('Bilibili summary URL must contain a BV video id');
44+
}
45+
return match[1];
46+
}
47+
if (!B23_HOST_RE.test(parsed.hostname)) {
48+
throw new ArgumentError('Bilibili summary URL must be a bilibili.com or b23.tv URL');
49+
}
50+
}
51+
try {
52+
return await resolveBvid(input);
53+
} catch (error) {
54+
throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${input}`, error instanceof Error ? error.message : String(error));
55+
}
56+
}
57+
58+
function requireOkPayload(payload, label) {
59+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
60+
throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`);
61+
}
62+
if (payload.code !== 0) {
63+
const message = payload.message ?? 'unknown error';
64+
if (payload.code === -101 || payload.code === -403 || /||forbidden|permission|login/i.test(String(message))) {
65+
throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`);
66+
}
67+
throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`);
68+
}
69+
return payload.data;
70+
}
71+
72+
function readModelResult(data, bvid) {
73+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
74+
throw new CommandExecutionError('Bilibili conclusion API returned malformed data');
75+
}
76+
if (data.code !== 0) {
77+
throw new EmptyResultError('bilibili summary', `Bilibili has not generated an AI summary for ${bvid}.`);
78+
}
79+
let modelResult = data.model_result;
80+
if (typeof modelResult === 'string') {
81+
try {
82+
modelResult = JSON.parse(modelResult);
83+
} catch {
84+
throw new CommandExecutionError('Bilibili conclusion API returned malformed model_result JSON');
85+
}
86+
}
87+
if (!modelResult || typeof modelResult !== 'object' || Array.isArray(modelResult)) {
88+
throw new CommandExecutionError('Bilibili conclusion API returned malformed model_result');
89+
}
90+
const summary = String(modelResult.summary ?? '').trim();
91+
if (!summary) {
92+
throw new EmptyResultError('bilibili summary', `Bilibili has not generated an AI summary for ${bvid}.`);
93+
}
94+
const outline = modelResult.outline ?? [];
95+
if (!Array.isArray(outline)) {
96+
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline');
97+
}
98+
return { summary, outline };
99+
}
100+
101+
function rowsFromModel(model) {
102+
const rows = [{ time: '', content: model.summary }];
103+
for (const section of model.outline) {
104+
if (!section || typeof section !== 'object' || Array.isArray(section)) {
105+
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline section');
106+
}
107+
const sectionTitle = String(section.title ?? '').trim();
108+
const sectionTime = formatTime(section.timestamp);
109+
if (sectionTitle) {
110+
rows.push({ time: sectionTime, content: `# ${sectionTitle}` });
111+
}
112+
const points = section.part_outline ?? [];
113+
if (!Array.isArray(points)) {
114+
throw new CommandExecutionError('Bilibili conclusion API returned malformed part outline');
115+
}
116+
for (const point of points) {
117+
if (!point || typeof point !== 'object' || Array.isArray(point)) {
118+
throw new CommandExecutionError('Bilibili conclusion API returned malformed outline point');
119+
}
120+
const content = String(point.content ?? '').trim();
121+
if (content) {
122+
rows.push({ time: formatTime(point.timestamp), content });
123+
}
124+
}
125+
}
126+
return rows;
127+
}
128+
129+
var command = cli({
130+
site: 'bilibili',
131+
name: 'summary',
132+
access: 'read',
133+
description: '获取 B站视频的官方 AI 总结(视频页「AI总结」同款,含分段大纲与时间戳)',
134+
domain: 'www.bilibili.com',
135+
strategy: Strategy.COOKIE,
136+
args: [
137+
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID / URL / b23.tv short link' },
138+
],
139+
columns: ['time', 'content'],
140+
func: async (page, kwargs) => {
141+
if (!page) {
142+
throw new CommandExecutionError('Browser session required for bilibili summary');
143+
}
144+
const bvid = await readBvid(kwargs.bvid);
145+
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
146+
const viewData = requireOkPayload(view, 'view');
147+
const cid = viewData?.cid;
148+
const upMid = viewData?.owner?.mid;
149+
if (!cid || !upMid) {
150+
throw new CommandExecutionError(`Bilibili view API did not return cid/up_mid for ${bvid}`);
151+
}
152+
const conclusion = await apiGet(page, '/x/web-interface/view/conclusion/get', {
153+
params: { bvid, cid, up_mid: upMid },
154+
signed: true,
155+
});
156+
const conclusionData = requireOkPayload(conclusion, 'conclusion');
157+
return rowsFromModel(readModelResult(conclusionData, bvid));
158+
},
159+
});
160+
161+
export const __test__ = {
162+
command,
163+
formatTime,
164+
readBvid,
165+
readModelResult,
166+
rowsFromModel,
167+
};

0 commit comments

Comments
 (0)