Skip to content
Merged
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
99 changes: 99 additions & 0 deletions docs/advanced/rate-limiter-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Rate Limiter Plugin

An optional plugin that adds a random sleep between browser-based commands to reduce the risk of platform rate-limiting or bot detection.

## Install

```bash
opencli plugin install github:jackwener/opencli-plugin-rate-limiter
```

Or copy the example below into `~/.opencli/plugins/rate-limiter/` to use it locally without installing from GitHub.

## What it does

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.

## Configuration

| Variable | Default | Description |
|---|---|---|
| `OPENCLI_RATE_MIN` | `5` | Minimum sleep in seconds |
| `OPENCLI_RATE_MAX` | `30` | Maximum sleep in seconds |
| `OPENCLI_NO_RATE` | — | Set to `1` to disable entirely (local dev) |

```bash
# Shorter delays for light scraping
OPENCLI_RATE_MIN=3 OPENCLI_RATE_MAX=10 opencli xiaohongshu search "AI眼镜"

# Skip delays when iterating locally
OPENCLI_NO_RATE=1 opencli bilibili comments BV1WtAGzYEBm
```

## Local installation (without GitHub)

1. Create the plugin directory:

```bash
mkdir -p ~/.opencli/plugins/rate-limiter
```

2. Create `~/.opencli/plugins/rate-limiter/package.json`:

```json
{ "type": "module" }
```

3. Create `~/.opencli/plugins/rate-limiter/index.js`:

```js
import { onAfterExecute } from '@jackwener/opencli/hooks'

const BROWSER_DOMAINS = [
'xiaohongshu', 'weibo', 'bilibili', 'douyin', 'tiktok',
'instagram', 'twitter', 'youtube', 'zhihu', 'douban',
'jike', 'weixin', 'xiaoyuzhou',
]

onAfterExecute(async (ctx) => {
if (process.env.OPENCLI_NO_RATE === '1') return

const site = ctx.command?.split('/')?.[0] ?? ''
if (!BROWSER_DOMAINS.includes(site)) return

const min = Number(process.env.OPENCLI_RATE_MIN ?? 5)
const max = Number(process.env.OPENCLI_RATE_MAX ?? 30)
const ms = Math.floor(Math.random() * (max - min + 1) + min) * 1000

process.stderr.write(`[rate-limiter] ${site}: sleeping ${(ms / 1000).toFixed(0)}s\n`)
await new Promise(r => setTimeout(r, ms))
})
```

4. Verify it loaded:

```bash
OPENCLI_NO_RATE=1 opencli xiaohongshu search "test" 2>&1 | grep rate-limiter
# → (no output — plugin loaded but rate limit skipped)

opencli xiaohongshu search "test" 2>&1 | grep rate-limiter
# → [rate-limiter] xiaohongshu: sleeping 12s
```

## Writing your own plugin

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.

```js
// ~/.opencli/plugins/my-plugin/index.js
import { onAfterExecute } from '@jackwener/opencli/hooks'

onAfterExecute(async (ctx) => {
// ctx.command — e.g. "bilibili/comments"
// ctx.args — coerced command arguments
// ctx.error — set if the command threw
console.error(`[my-plugin] finished: ${ctx.command}`)
})
```

See [hooks.ts](../../src/hooks.ts) for the full `HookContext` type.
102 changes: 102 additions & 0 deletions src/clis/bilibili/comments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const { mockApiGet } = vi.hoisted(() => ({
mockApiGet: vi.fn(),
}));

vi.mock('./utils.js', () => ({
apiGet: mockApiGet,
}));

import { getRegistry } from '../../registry.js';
import './comments.js';

describe('bilibili comments', () => {
const command = getRegistry().get('bilibili/comments');

beforeEach(() => {
mockApiGet.mockReset();
});

it('resolves bvid to aid and fetches replies', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 12345 } }) // view endpoint
.mockResolvedValueOnce({
data: {
replies: [
{
member: { uname: 'Alice' },
content: { message: 'Great video!' },
like: 42,
rcount: 3,
ctime: 1700000000,
},
],
},
});

const result = await command!.func!({} as any, { bvid: 'BV1WtAGzYEBm', limit: 5 });

expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
params: { oid: 12345, type: 1, mode: 3, ps: 5 },
signed: true,
});

expect(result).toEqual([
{
rank: 1,
author: 'Alice',
text: 'Great video!',
likes: 42,
replies: 3,
time: new Date(1700000000 * 1000).toISOString().slice(0, 16).replace('T', ' '),
},
]);
});

it('throws when aid cannot be resolved', async () => {
mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid

await expect(command!.func!({} as any, { bvid: 'BV_invalid', limit: 5 })).rejects.toThrow(
'Cannot resolve aid for bvid: BV_invalid',
);
});

it('returns empty array when replies is missing', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 99 } })
.mockResolvedValueOnce({ data: {} }); // no replies key

const result = await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 });
expect(result).toEqual([]);
});

it('caps limit at 50', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 1 } })
.mockResolvedValueOnce({ data: { replies: [] } });

await command!.func!({} as any, { bvid: 'BV1xxx', limit: 999 });

expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
params: { oid: 1, type: 1, mode: 3, ps: 50 },
signed: true,
});
});

it('collapses newlines in comment text', async () => {
mockApiGet
.mockResolvedValueOnce({ data: { aid: 1 } })
.mockResolvedValueOnce({
data: {
replies: [
{ member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
],
},
});

const result = (await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 })) as any[];
expect(result[0].text).toBe('line1 line2 line3');
});
});
44 changes: 44 additions & 0 deletions src/clis/bilibili/comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Bilibili comments — fetches top-level replies via the official API with WBI signing.
* Uses the /x/v2/reply/main endpoint which is stable and doesn't depend on DOM structure.
*/

import { cli, Strategy } from '../../registry.js';
import { apiGet } from './utils.js';

cli({
site: 'bilibili',
name: 'comments',
description: '获取 B站视频评论(使用官方 API + WBI 签名)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g. BV1WtAGzYEBm)' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
],
columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
func: async (page, kwargs) => {
const bvid = String(kwargs.bvid).trim();
const limit = Math.min(Number(kwargs.limit) || 20, 50);

// Resolve bvid → aid (required by reply API)
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
const aid = view?.data?.aid;
if (!aid) throw new Error(`Cannot resolve aid for bvid: ${bvid}`);

const payload = await apiGet(page, '/x/v2/reply/main', {
params: { oid: aid, type: 1, mode: 3, ps: limit },
signed: true,
});

const replies: any[] = payload?.data?.replies ?? [];
return replies.slice(0, limit).map((r: any, i: number) => ({
rank: i + 1,
author: r.member?.uname ?? '',
text: (r.content?.message ?? '').replace(/\n/g, ' ').trim(),
likes: r.like ?? 0,
replies: r.rcount ?? 0,
time: new Date(r.ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
}));
},
});
96 changes: 96 additions & 0 deletions src/clis/xiaohongshu/comments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest';
import type { IPage } from '../../types.js';
import { getRegistry } from '../../registry.js';
import './comments.js';

function createPageMock(evaluateResult: any): IPage {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(evaluateResult),
snapshot: vi.fn().mockResolvedValue(undefined),
click: vi.fn().mockResolvedValue(undefined),
typeText: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
scrollTo: vi.fn().mockResolvedValue(undefined),
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
wait: vi.fn().mockResolvedValue(undefined),
tabs: vi.fn().mockResolvedValue([]),
closeTab: vi.fn().mockResolvedValue(undefined),
newTab: vi.fn().mockResolvedValue(undefined),
selectTab: vi.fn().mockResolvedValue(undefined),
networkRequests: vi.fn().mockResolvedValue([]),
consoleMessages: vi.fn().mockResolvedValue([]),
scroll: vi.fn().mockResolvedValue(undefined),
autoScroll: vi.fn().mockResolvedValue(undefined),
installInterceptor: vi.fn().mockResolvedValue(undefined),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
};
}

describe('xiaohongshu comments', () => {
const command = getRegistry().get('xiaohongshu/comments');

it('returns ranked comment rows', async () => {
const page = createPageMock({
loginWall: false,
results: [
{ author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01' },
{ author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02' },
],
});

const result = (await command!.func!(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 })) as any[];

expect((page.goto as any).mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
expect(result).toEqual([
{ rank: 1, author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01' },
{ rank: 2, author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02' },
]);
expect(result[0]).not.toHaveProperty('loginWall');
});

it('strips /explore/ prefix from full URL input', async () => {
const page = createPageMock({
loginWall: false,
results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01' }],
});

await command!.func!(page, {
'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131',
limit: 5,
});

expect((page.goto as any).mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
});

it('throws AuthRequiredError when login wall is detected', async () => {
const page = createPageMock({ loginWall: true, results: [] });

await expect(command!.func!(page, { 'note-id': 'abc123', limit: 5 })).rejects.toThrow(
'Note comments require login',
);
});

it('returns empty array when no comments are found', async () => {
const page = createPageMock({ loginWall: false, results: [] });

await expect(command!.func!(page, { 'note-id': 'abc123', limit: 5 })).resolves.toEqual([]);
});

it('respects the limit', async () => {
const manyComments = Array.from({ length: 10 }, (_, i) => ({
author: `User${i}`,
text: `Comment ${i}`,
likes: i,
time: '2024-01-01',
}));
const page = createPageMock({ loginWall: false, results: manyComments });

const result = (await command!.func!(page, { 'note-id': 'abc123', limit: 3 })) as any[];
expect(result).toHaveLength(3);
expect(result[0].rank).toBe(1);
expect(result[2].rank).toBe(3);
});
});
Loading