Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3305,7 +3305,7 @@
{
"site": "bilibili",
"name": "comments",
"description": "获取 B站视频评论(官方 API;用 --parent <rpid> 读取某条评论下的「楼中楼」回复)",
"description": "获取 B站视频评论(官方 API;用 --parent <rpid> 读取某条评论下的「楼中楼」回复;用 --top 只看置顶评论)",
"access": "read",
"domain": "www.bilibili.com",
"strategy": "cookie",
Expand All @@ -3324,6 +3324,13 @@
"required": false,
"help": "rpid of a comment — fetch the replies under it instead of top-level comments"
},
{
"name": "top",
"type": "boolean",
"default": false,
"required": false,
"help": "只返回置顶评论(与 --parent 互斥)"
},
{
"name": "limit",
"type": "int",
Expand Down
34 changes: 25 additions & 9 deletions clis/bilibili/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,25 @@ function requireOkPayload(payload, label) {
return payload.data;
}

function requireReplies(data, label) {
function requireReplies(data, label, key = 'replies') {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new CommandExecutionError(`Bilibili ${label} API returned malformed data`);
}
if (!Object.hasOwn(data, 'replies')) {
throw new CommandExecutionError(`Bilibili ${label} API did not return replies`);
// top_replies is omitted entirely when a video has no pinned comment; treat
// its absence as an empty list rather than a malformed payload.
if (!Object.hasOwn(data, key)) {
if (key !== 'replies') {
return [];
}
throw new CommandExecutionError(`Bilibili ${label} API did not return ${key}`);
}
if (data.replies === null) {
if (data[key] === null) {
return [];
}
if (!Array.isArray(data.replies)) {
throw new CommandExecutionError(`Bilibili ${label} API returned malformed replies`);
if (!Array.isArray(data[key])) {
throw new CommandExecutionError(`Bilibili ${label} API returned malformed ${key}`);
}
return data.replies;
return data[key];
}

function formatReplyRow(reply, index) {
Expand Down Expand Up @@ -90,12 +95,13 @@ cli({
site: 'bilibili',
name: 'comments',
access: 'read',
description: '获取 B站视频评论(官方 API;用 --parent <rpid> 读取某条评论下的「楼中楼」回复)',
description: '获取 B站视频评论(官方 API;用 --parent <rpid> 读取某条评论下的「楼中楼」回复;用 --top 只看置顶评论)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g. BV1WtAGzYEBm)' },
{ name: 'parent', type: 'int', help: 'rpid of a comment — fetch the replies under it instead of top-level comments' },
{ name: 'top', type: 'boolean', default: false, help: '只返回置顶评论(与 --parent 互斥)' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
],
columns: ['rank', 'rpid', 'author', 'text', 'likes', 'replies', 'time'],
Expand All @@ -112,6 +118,10 @@ cli({
}
const limit = parseLimit(kwargs.limit);
const parent = parseParent(kwargs.parent);
const top = kwargs.top === true;
if (top && parent != null) {
throw new ArgumentError('bilibili comments --top cannot be combined with --parent (pinned comments only exist at the top level)');
}
// Resolve bvid → aid (required by reply API)
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
const viewData = requireOkPayload(view, 'view');
Expand All @@ -127,8 +137,14 @@ cli({
signed: true,
});
const label = parent != null ? 'reply thread' : 'reply main';
const replies = requireReplies(requireOkPayload(payload, label), label);
const data = requireOkPayload(payload, label);
const replies = top
? requireReplies(data, label, 'top_replies')
: requireReplies(data, label);
if (replies.length === 0) {
if (top) {
throw new EmptyResultError(`bilibili pinned comments: ${bvid}`);
}
throw new EmptyResultError(parent != null ? `bilibili comment replies: ${parent}` : `bilibili comments: ${bvid}`);
}
return replies.slice(0, limit).map(formatReplyRow);
Expand Down
50 changes: 50 additions & 0 deletions clis/bilibili/comments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,56 @@ describe('bilibili comments', () => {
expect(result[0].rpid).toBe('888');
expect(result[0].text).toBe('视频总结:作者开了一家咖啡馆');
});
it('returns pinned comments from top_replies when --top is given', async () => {
mockApiGet
.mockResolvedValueOnce({ code: 0, data: { aid: 12345 } }) // view endpoint
.mockResolvedValueOnce({
code: 0,
data: {
top_replies: [
{
rpid: 999,
member: { uname: 'UP主' },
content: { message: '置顶:欢迎大家' },
like: 100,
rcount: 5,
ctime: 1700000000,
},
],
replies: [
{ rpid: 777, member: { uname: 'Alice' }, content: { message: 'Great video!' }, like: 42, rcount: 3, ctime: 1700000000 },
],
},
});
const result = await command.func({}, { bvid: 'BV1WtAGzYEBm', top: true, limit: 5 });
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
params: { oid: 12345, type: 1, mode: 3, ps: 5 },
signed: true,
});
expect(result).toEqual([
{
rank: 1,
rpid: '999',
author: 'UP主',
text: '置顶:欢迎大家',
likes: 100,
replies: 5,
time: new Date(1700000000 * 1000).toISOString().slice(0, 16).replace('T', ' '),
},
]);
});
it('rejects --top combined with --parent before fetching', async () => {
await expect(command.func({}, { bvid: 'BV1xxx', top: true, parent: 777, limit: 5 }))
.rejects.toBeInstanceOf(ArgumentError);
expect(mockApiGet).not.toHaveBeenCalled();
});
it('throws EmptyResultError when --top finds no pinned comment', async () => {
mockApiGet
.mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
.mockResolvedValueOnce({ code: 0, data: { top_replies: null, replies: [] } });
await expect(command.func({}, { bvid: 'BV1xxx', top: true, limit: 5 }))
.rejects.toBeInstanceOf(EmptyResultError);
});
it('throws when aid cannot be resolved', async () => {
mockApiGet.mockResolvedValueOnce({ code: 0, data: {} }); // no aid
await expect(command.func({}, { bvid: 'BVinvalid123', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
Expand Down
6 changes: 5 additions & 1 deletion docs/adapters/browser/bilibili.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
| `opencli bilibili subtitle` | |
| `opencli bilibili video` | Get one video's metadata (title, author, duration, stats) by BV / URL / b23.tv link |
| `opencli bilibili summary` | Get the official AI video summary and timestamped outline by BV / URL / b23.tv link |
| `opencli bilibili comments` | Read top-level comments, or read replies under a top-level comment with `--parent` |
| `opencli bilibili comments` | Read top-level comments; `--parent` reads replies under a comment, `--top` reads only pinned comments |
| `opencli bilibili comment` | Post a top-level comment or reply under a top-level comment (requires `--execute`) |
| `opencli bilibili dynamic` | |
| `opencli bilibili ranking` | |
Expand Down Expand Up @@ -75,6 +75,9 @@ opencli bilibili summary https://www.bilibili.com/video/BV1xx411c7mD/
opencli bilibili comments BV1xx411c7mD --limit 10
opencli bilibili comments BV1xx411c7mD --parent 123456789 --limit 10

# Read only the pinned (置顶) comments
opencli bilibili comments BV1xx411c7mD --top

# Post a comment or reply. The write only happens with --execute.
opencli bilibili comment BV1xx411c7mD "这条评论来自 OpenCLI" --execute
opencli bilibili comment BV1xx411c7mD "回复楼主" --parent 123456789 --execute
Expand All @@ -99,6 +102,7 @@ opencli bilibili hot -v
- `feed-detail` expects the dynamic ID from a `https://t.bilibili.com/<id>` URL
- `comments` emits `rpid`; pass a top-level row's `rpid` to `comments --parent` to read its reply thread
- `comments --limit` accepts `1..50`; empty comment lists raise `EmptyResultError`
- `comments --top` returns only pinned comments (from the API's `top_replies`); it cannot be combined with `--parent`, and a video with no pinned comment raises `EmptyResultError`
- `comment` is a write command and refuses to post unless `--execute` is passed
- `comment --parent` expects the top-level/root `rpid`; nested reply-to-reply targeting is not inferred
- `follow` and `unfollow` are write commands; they no-op when the current relation already matches the requested state and otherwise re-read `/x/relation` after modify before reporting success
Expand Down
Loading