Skip to content

Commit 5731881

Browse files
mikkleyclaudejackwener
authored
feat: add bilibili/comments, xiaohongshu/comments commands + rate-limiter plugin docs (#457)
* feat: add bilibili/comments, xiaohongshu/comments, and rate-limiter plugin docs - bilibili/comments: fetch top-level replies via /x/v2/reply/main with WBI signing (bvid → aid resolution + signed params, no DOM dependency) - xiaohongshu/comments: DOM extraction from note detail page with login-wall detection and correct handling of 0-like counts (XHS shows "赞" text instead of "0") - docs/advanced/rate-limiter-plugin.md: documents the onAfterExecute hook pattern and shows a plug-and-play rate limiter that adds random sleep between platform commands to reduce bot-detection risk Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(xiaohongshu): allow empty comments results --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent c75fea9 commit 5731881

5 files changed

Lines changed: 422 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Rate Limiter Plugin
2+
3+
An optional plugin that adds a random sleep between browser-based commands to reduce the risk of platform rate-limiting or bot detection.
4+
5+
## Install
6+
7+
```bash
8+
opencli plugin install github:jackwener/opencli-plugin-rate-limiter
9+
```
10+
11+
Or copy the example below into `~/.opencli/plugins/rate-limiter/` to use it locally without installing from GitHub.
12+
13+
## What it does
14+
15+
After every command targeting a browser platform (xiaohongshu, weibo, bilibili, douyin, tiktok, …), the plugin sleeps for a random duration — 5–30 seconds by default — before returning control to the caller.
16+
17+
## Configuration
18+
19+
| Variable | Default | Description |
20+
|---|---|---|
21+
| `OPENCLI_RATE_MIN` | `5` | Minimum sleep in seconds |
22+
| `OPENCLI_RATE_MAX` | `30` | Maximum sleep in seconds |
23+
| `OPENCLI_NO_RATE` || Set to `1` to disable entirely (local dev) |
24+
25+
```bash
26+
# Shorter delays for light scraping
27+
OPENCLI_RATE_MIN=3 OPENCLI_RATE_MAX=10 opencli xiaohongshu search "AI眼镜"
28+
29+
# Skip delays when iterating locally
30+
OPENCLI_NO_RATE=1 opencli bilibili comments BV1WtAGzYEBm
31+
```
32+
33+
## Local installation (without GitHub)
34+
35+
1. Create the plugin directory:
36+
37+
```bash
38+
mkdir -p ~/.opencli/plugins/rate-limiter
39+
```
40+
41+
2. Create `~/.opencli/plugins/rate-limiter/package.json`:
42+
43+
```json
44+
{ "type": "module" }
45+
```
46+
47+
3. Create `~/.opencli/plugins/rate-limiter/index.js`:
48+
49+
```js
50+
import { onAfterExecute } from '@jackwener/opencli/hooks'
51+
52+
const BROWSER_DOMAINS = [
53+
'xiaohongshu', 'weibo', 'bilibili', 'douyin', 'tiktok',
54+
'instagram', 'twitter', 'youtube', 'zhihu', 'douban',
55+
'jike', 'weixin', 'xiaoyuzhou',
56+
]
57+
58+
onAfterExecute(async (ctx) => {
59+
if (process.env.OPENCLI_NO_RATE === '1') return
60+
61+
const site = ctx.command?.split('/')?.[0] ?? ''
62+
if (!BROWSER_DOMAINS.includes(site)) return
63+
64+
const min = Number(process.env.OPENCLI_RATE_MIN ?? 5)
65+
const max = Number(process.env.OPENCLI_RATE_MAX ?? 30)
66+
const ms = Math.floor(Math.random() * (max - min + 1) + min) * 1000
67+
68+
process.stderr.write(`[rate-limiter] ${site}: sleeping ${(ms / 1000).toFixed(0)}s\n`)
69+
await new Promise(r => setTimeout(r, ms))
70+
})
71+
```
72+
73+
4. Verify it loaded:
74+
75+
```bash
76+
OPENCLI_NO_RATE=1 opencli xiaohongshu search "test" 2>&1 | grep rate-limiter
77+
# → (no output — plugin loaded but rate limit skipped)
78+
79+
opencli xiaohongshu search "test" 2>&1 | grep rate-limiter
80+
# → [rate-limiter] xiaohongshu: sleeping 12s
81+
```
82+
83+
## Writing your own plugin
84+
85+
Plugins are plain JS/TS files in `~/.opencli/plugins/<name>/`. A plugin file must export a hook registration call that matches the pattern `onStartup(`, `onBeforeExecute(`, or `onAfterExecute(` — opencli's discovery engine uses this pattern to identify hook files vs. command files.
86+
87+
```js
88+
// ~/.opencli/plugins/my-plugin/index.js
89+
import { onAfterExecute } from '@jackwener/opencli/hooks'
90+
91+
onAfterExecute(async (ctx) => {
92+
// ctx.command — e.g. "bilibili/comments"
93+
// ctx.args — coerced command arguments
94+
// ctx.error — set if the command threw
95+
console.error(`[my-plugin] finished: ${ctx.command}`)
96+
})
97+
```
98+
99+
See [hooks.ts](../../src/hooks.ts) for the full `HookContext` type.

src/clis/bilibili/comments.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const { mockApiGet } = vi.hoisted(() => ({
4+
mockApiGet: vi.fn(),
5+
}));
6+
7+
vi.mock('./utils.js', () => ({
8+
apiGet: mockApiGet,
9+
}));
10+
11+
import { getRegistry } from '../../registry.js';
12+
import './comments.js';
13+
14+
describe('bilibili comments', () => {
15+
const command = getRegistry().get('bilibili/comments');
16+
17+
beforeEach(() => {
18+
mockApiGet.mockReset();
19+
});
20+
21+
it('resolves bvid to aid and fetches replies', async () => {
22+
mockApiGet
23+
.mockResolvedValueOnce({ data: { aid: 12345 } }) // view endpoint
24+
.mockResolvedValueOnce({
25+
data: {
26+
replies: [
27+
{
28+
member: { uname: 'Alice' },
29+
content: { message: 'Great video!' },
30+
like: 42,
31+
rcount: 3,
32+
ctime: 1700000000,
33+
},
34+
],
35+
},
36+
});
37+
38+
const result = await command!.func!({} as any, { bvid: 'BV1WtAGzYEBm', limit: 5 });
39+
40+
expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
41+
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
42+
params: { oid: 12345, type: 1, mode: 3, ps: 5 },
43+
signed: true,
44+
});
45+
46+
expect(result).toEqual([
47+
{
48+
rank: 1,
49+
author: 'Alice',
50+
text: 'Great video!',
51+
likes: 42,
52+
replies: 3,
53+
time: new Date(1700000000 * 1000).toISOString().slice(0, 16).replace('T', ' '),
54+
},
55+
]);
56+
});
57+
58+
it('throws when aid cannot be resolved', async () => {
59+
mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid
60+
61+
await expect(command!.func!({} as any, { bvid: 'BV_invalid', limit: 5 })).rejects.toThrow(
62+
'Cannot resolve aid for bvid: BV_invalid',
63+
);
64+
});
65+
66+
it('returns empty array when replies is missing', async () => {
67+
mockApiGet
68+
.mockResolvedValueOnce({ data: { aid: 99 } })
69+
.mockResolvedValueOnce({ data: {} }); // no replies key
70+
71+
const result = await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 });
72+
expect(result).toEqual([]);
73+
});
74+
75+
it('caps limit at 50', async () => {
76+
mockApiGet
77+
.mockResolvedValueOnce({ data: { aid: 1 } })
78+
.mockResolvedValueOnce({ data: { replies: [] } });
79+
80+
await command!.func!({} as any, { bvid: 'BV1xxx', limit: 999 });
81+
82+
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
83+
params: { oid: 1, type: 1, mode: 3, ps: 50 },
84+
signed: true,
85+
});
86+
});
87+
88+
it('collapses newlines in comment text', async () => {
89+
mockApiGet
90+
.mockResolvedValueOnce({ data: { aid: 1 } })
91+
.mockResolvedValueOnce({
92+
data: {
93+
replies: [
94+
{ member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
95+
],
96+
},
97+
});
98+
99+
const result = (await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 })) as any[];
100+
expect(result[0].text).toBe('line1 line2 line3');
101+
});
102+
});

src/clis/bilibili/comments.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Bilibili comments — fetches top-level replies via the official API with WBI signing.
3+
* Uses the /x/v2/reply/main endpoint which is stable and doesn't depend on DOM structure.
4+
*/
5+
6+
import { cli, Strategy } from '../../registry.js';
7+
import { apiGet } from './utils.js';
8+
9+
cli({
10+
site: 'bilibili',
11+
name: 'comments',
12+
description: '获取 B站视频评论(使用官方 API + WBI 签名)',
13+
domain: 'www.bilibili.com',
14+
strategy: Strategy.COOKIE,
15+
args: [
16+
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g. BV1WtAGzYEBm)' },
17+
{ name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
18+
],
19+
columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
20+
func: async (page, kwargs) => {
21+
const bvid = String(kwargs.bvid).trim();
22+
const limit = Math.min(Number(kwargs.limit) || 20, 50);
23+
24+
// Resolve bvid → aid (required by reply API)
25+
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
26+
const aid = view?.data?.aid;
27+
if (!aid) throw new Error(`Cannot resolve aid for bvid: ${bvid}`);
28+
29+
const payload = await apiGet(page, '/x/v2/reply/main', {
30+
params: { oid: aid, type: 1, mode: 3, ps: limit },
31+
signed: true,
32+
});
33+
34+
const replies: any[] = payload?.data?.replies ?? [];
35+
return replies.slice(0, limit).map((r: any, i: number) => ({
36+
rank: i + 1,
37+
author: r.member?.uname ?? '',
38+
text: (r.content?.message ?? '').replace(/\n/g, ' ').trim(),
39+
likes: r.like ?? 0,
40+
replies: r.rcount ?? 0,
41+
time: new Date(r.ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
42+
}));
43+
},
44+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import type { IPage } from '../../types.js';
3+
import { getRegistry } from '../../registry.js';
4+
import './comments.js';
5+
6+
function createPageMock(evaluateResult: any): IPage {
7+
return {
8+
goto: vi.fn().mockResolvedValue(undefined),
9+
evaluate: vi.fn().mockResolvedValue(evaluateResult),
10+
snapshot: vi.fn().mockResolvedValue(undefined),
11+
click: vi.fn().mockResolvedValue(undefined),
12+
typeText: vi.fn().mockResolvedValue(undefined),
13+
pressKey: vi.fn().mockResolvedValue(undefined),
14+
scrollTo: vi.fn().mockResolvedValue(undefined),
15+
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
16+
wait: vi.fn().mockResolvedValue(undefined),
17+
tabs: vi.fn().mockResolvedValue([]),
18+
closeTab: vi.fn().mockResolvedValue(undefined),
19+
newTab: vi.fn().mockResolvedValue(undefined),
20+
selectTab: vi.fn().mockResolvedValue(undefined),
21+
networkRequests: vi.fn().mockResolvedValue([]),
22+
consoleMessages: vi.fn().mockResolvedValue([]),
23+
scroll: vi.fn().mockResolvedValue(undefined),
24+
autoScroll: vi.fn().mockResolvedValue(undefined),
25+
installInterceptor: vi.fn().mockResolvedValue(undefined),
26+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
27+
getCookies: vi.fn().mockResolvedValue([]),
28+
screenshot: vi.fn().mockResolvedValue(''),
29+
};
30+
}
31+
32+
describe('xiaohongshu comments', () => {
33+
const command = getRegistry().get('xiaohongshu/comments');
34+
35+
it('returns ranked comment rows', async () => {
36+
const page = createPageMock({
37+
loginWall: false,
38+
results: [
39+
{ author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01' },
40+
{ author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02' },
41+
],
42+
});
43+
44+
const result = (await command!.func!(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 })) as any[];
45+
46+
expect((page.goto as any).mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
47+
expect(result).toEqual([
48+
{ rank: 1, author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01' },
49+
{ rank: 2, author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02' },
50+
]);
51+
expect(result[0]).not.toHaveProperty('loginWall');
52+
});
53+
54+
it('strips /explore/ prefix from full URL input', async () => {
55+
const page = createPageMock({
56+
loginWall: false,
57+
results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01' }],
58+
});
59+
60+
await command!.func!(page, {
61+
'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131',
62+
limit: 5,
63+
});
64+
65+
expect((page.goto as any).mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
66+
});
67+
68+
it('throws AuthRequiredError when login wall is detected', async () => {
69+
const page = createPageMock({ loginWall: true, results: [] });
70+
71+
await expect(command!.func!(page, { 'note-id': 'abc123', limit: 5 })).rejects.toThrow(
72+
'Note comments require login',
73+
);
74+
});
75+
76+
it('returns empty array when no comments are found', async () => {
77+
const page = createPageMock({ loginWall: false, results: [] });
78+
79+
await expect(command!.func!(page, { 'note-id': 'abc123', limit: 5 })).resolves.toEqual([]);
80+
});
81+
82+
it('respects the limit', async () => {
83+
const manyComments = Array.from({ length: 10 }, (_, i) => ({
84+
author: `User${i}`,
85+
text: `Comment ${i}`,
86+
likes: i,
87+
time: '2024-01-01',
88+
}));
89+
const page = createPageMock({ loginWall: false, results: manyComments });
90+
91+
const result = (await command!.func!(page, { 'note-id': 'abc123', limit: 3 })) as any[];
92+
expect(result).toHaveLength(3);
93+
expect(result[0].rank).toBe(1);
94+
expect(result[2].rank).toBe(3);
95+
});
96+
});

0 commit comments

Comments
 (0)