diff --git a/cli-manifest.json b/cli-manifest.json index c5344bfff..2f8f2f841 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -3305,7 +3305,7 @@ { "site": "bilibili", "name": "comments", - "description": "获取 B站视频评论(官方 API;用 --parent 读取某条评论下的「楼中楼」回复)", + "description": "获取 B站视频评论(官方 API;用 --parent 读取某条评论下的「楼中楼」回复;用 --top 只看置顶评论)", "access": "read", "domain": "www.bilibili.com", "strategy": "cookie", @@ -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", diff --git a/clis/bilibili/comments.js b/clis/bilibili/comments.js index 18d72a54c..2b43ba10d 100644 --- a/clis/bilibili/comments.js +++ b/clis/bilibili/comments.js @@ -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) { @@ -90,12 +95,13 @@ cli({ site: 'bilibili', name: 'comments', access: 'read', - description: '获取 B站视频评论(官方 API;用 --parent 读取某条评论下的「楼中楼」回复)', + description: '获取 B站视频评论(官方 API;用 --parent 读取某条评论下的「楼中楼」回复;用 --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'], @@ -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'); @@ -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); diff --git a/clis/bilibili/comments.test.js b/clis/bilibili/comments.test.js index 5e17abf1c..ca2479420 100644 --- a/clis/bilibili/comments.test.js +++ b/clis/bilibili/comments.test.js @@ -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); diff --git a/docs/adapters/browser/bilibili.md b/docs/adapters/browser/bilibili.md index 402167b4a..06493bd0b 100644 --- a/docs/adapters/browser/bilibili.md +++ b/docs/adapters/browser/bilibili.md @@ -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` | | @@ -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 @@ -99,6 +102,7 @@ opencli bilibili hot -v - `feed-detail` expects the dynamic ID from a `https://t.bilibili.com/` 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