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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
|------|----------|
| **xiaohongshu** | `search` `feed` `user` `download` `publish` `comments` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
| **tieba** | `hot` `posts` `search` `read` |
| **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |

Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ npm install -g @jackwener/opencli@latest
|------|------|------|
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | ζ΅θ§ˆε™¨ |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | ζ΅θ§ˆε™¨ |
| **tieba** | `hot` `posts` `search` `read` | ζ΅θ§ˆε™¨ |
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 摌青端 |
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | ζ΅θ§ˆε™¨ |
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 摌青端 |
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default defineConfig({
items: [
{ text: 'Twitter / X', link: '/adapters/browser/twitter' },
{ text: 'Reddit', link: '/adapters/browser/reddit' },
{ text: 'Tieba', link: '/adapters/browser/tieba' },
{ text: 'Bilibili', link: '/adapters/browser/bilibili' },
{ text: 'Zhihu', link: '/adapters/browser/zhihu' },
{ text: 'Xiaohongshu', link: '/adapters/browser/xiaohongshu' },
Expand Down
45 changes: 45 additions & 0 deletions docs/adapters/browser/tieba.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Tieba

**Mode**: πŸ” Browser Β· **Domain**: `tieba.baidu.com`

## Commands

| Command | Description |
|---------|-------------|
| `opencli tieba hot` | Read Tieba trending topics |
| `opencli tieba posts <forum>` | List threads in one forum |
| `opencli tieba search <keyword>` | Search threads across Tieba |
| `opencli tieba read <thread-id>` | Read one thread page |

## Usage Examples

```bash
# Trending topics
opencli tieba hot --limit 5

# List forum threads
opencli tieba posts ζŽζ―… --limit 10

# Search Tieba
opencli tieba search 编程 --limit 10

# Read one thread
opencli tieba read 10163164720 --limit 10

# Read page 2 of a thread
opencli tieba read 10163164720 --page 2 --limit 10

# JSON output
opencli tieba hot -f json
```

## Notes

- `tieba search` currently supports only `--page 1`
- `tieba read --limit` counts reply rows; page 1 may also include the main post

## Prerequisites

- Chrome running and able to open `tieba.baidu.com`
- [Browser Bridge extension](/guide/browser-bridge) installed
- For `posts`, `search`, and `read`, a valid Tieba login session in Chrome is recommended
1 change: 1 addition & 0 deletions docs/adapters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Run `opencli list` for the live registry.
|------|----------|------|
| **[twitter](/adapters/browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | πŸ” Browser |
| **[reddit](/adapters/browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | πŸ” Browser |
| **[tieba](/adapters/browser/tieba)** | `hot` `posts` `search` `read` | πŸ” Browser |
| **[bilibili](/adapters/browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | πŸ” Browser |
| **[zhihu](/adapters/browser/zhihu)** | `hot` `search` `question` `download` | πŸ” Browser |
| **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | πŸ” Browser |
Expand Down
86 changes: 86 additions & 0 deletions src/clis/tieba/commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { Strategy, getRegistry } from '../../registry.js';
import './hot.js';
import './posts.js';
import './read.js';
import './search.js';

describe('tieba commands', () => {
it('registers all tieba commands as TypeScript adapters', () => {
const hot = getRegistry().get('tieba/hot');
const posts = getRegistry().get('tieba/posts');
const search = getRegistry().get('tieba/search');
const read = getRegistry().get('tieba/read');

expect(hot).toBeDefined();
expect(posts).toBeDefined();
expect(search).toBeDefined();
expect(read).toBeDefined();
expect(typeof hot?.func).toBe('function');
expect(typeof posts?.func).toBe('function');
expect(typeof search?.func).toBe('function');
expect(typeof read?.func).toBe('function');
});

it('keeps the intended browser strategies', () => {
const hot = getRegistry().get('tieba/hot');
const posts = getRegistry().get('tieba/posts');
const search = getRegistry().get('tieba/search');
const read = getRegistry().get('tieba/read');

expect(hot?.strategy).toBe(Strategy.PUBLIC);
expect(posts?.strategy).toBe(Strategy.COOKIE);
expect(search?.strategy).toBe(Strategy.COOKIE);
expect(read?.strategy).toBe(Strategy.COOKIE);
expect(hot?.browser).toBe(true);
expect(posts?.browser).toBe(true);
expect(search?.browser).toBe(true);
expect(read?.browser).toBe(true);
});

it('keeps the public limit contract at 20 items for list commands', () => {
const hot = getRegistry().get('tieba/hot');
const posts = getRegistry().get('tieba/posts');
const search = getRegistry().get('tieba/search');

expect(hot?.args.find((arg) => arg.name === 'limit')?.default).toBe(20);
expect(posts?.args.find((arg) => arg.name === 'limit')?.default).toBe(20);
expect(search?.args.find((arg) => arg.name === 'limit')?.default).toBe(20);
});

it('rejects tieba read results when navigation lands on the wrong page number', async () => {
const read = getRegistry().get('tieba/read');
expect(read).toBeDefined();
expect(typeof read?.func).toBe('function');
const run = read?.func;
if (!run) throw new Error('tieba/read did not register a handler');
const page = {
goto: async () => undefined,
evaluate: async () => ({
pageMeta: {
pathname: '/p/10163164720',
pn: '1',
},
mainPost: {
title: '桋试帖子',
author: 'δ½œθ€…',
contentText: 'ζ­£ζ–‡',
structuredText: '',
visibleTime: '2026-03-29 12:00',
structuredTime: 0,
hasMedia: false,
},
replies: [],
}),
};

await expect(run(page as never, {
id: '10163164720',
page: 2,
limit: 5,
})).rejects.toMatchObject({
code: 'EMPTY_RESULT',
hint: expect.stringMatching(/requested page/i),
});
});
});
52 changes: 52 additions & 0 deletions src/clis/tieba/hot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { EmptyResultError } from '../../errors.js';
import { cli, Strategy } from '../../registry.js';
import { normalizeTiebaLimit } from './utils.js';

cli({
site: 'tieba',
name: 'hot',
description: 'Tieba hot topics',
domain: 'tieba.baidu.com',
strategy: Strategy.PUBLIC,
browser: true,
navigateBefore: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of items to return' },
],
columns: ['rank', 'title', 'discussions', 'description'],
func: async (page, kwargs) => {
const limit = normalizeTiebaLimit(kwargs.limit);
// Use the default browser settle path so we do not scrape the previous page.
await page.goto('https://tieba.baidu.com/hottopic/browse/topicList?res_type=1');

const raw = await page.evaluate(`(() => {
const items = document.querySelectorAll('li.topic-top-item');
return Array.from(items).map((item) => {
const titleEl = item.querySelector('a.topic-text');
const numEl = item.querySelector('span.topic-num');
const descEl = item.querySelector('p.topic-top-item-desc');
const href = titleEl?.getAttribute('href') || '';

return {
title: titleEl?.textContent?.trim() || '',
discussions: numEl?.textContent?.trim() || '',
description: descEl?.textContent?.trim() || '',
url: href.startsWith('http') ? href : 'https://tieba.baidu.com' + href,
};
}).filter((item) => item.title).slice(0, ${limit});
})()`);

const items = Array.isArray(raw) ? raw as Array<Record<string, string>> : [];
if (!items.length) {
throw new EmptyResultError('tieba hot', 'Tieba may have blocked the hot page, or the DOM structure may have changed');
}

return items.map((item, index) => ({
rank: index + 1,
title: item.title || '',
discussions: item.discussions || '',
description: item.description || '',
url: item.url || '',
}));
},
});
108 changes: 108 additions & 0 deletions src/clis/tieba/posts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { EmptyResultError } from '../../errors.js';
import { cli, Strategy, type CommandArgs } from '../../registry.js';
import type { IPage } from '../../types.js';
import {
buildTiebaPostCardsFromPagePc,
buildTiebaPostItems,
normalizeTiebaLimit,
signTiebaPcParams,
type RawTiebaPagePcFeedEntry,
} from './utils.js';

interface TiebaPagePcResponse {
error_code?: number;
page_data?: {
feed_list?: RawTiebaPagePcFeedEntry[];
};
}

function getForumPageNumber(kwargs: CommandArgs): number {
return Math.max(1, Number(kwargs.page || 1));
}

function getForumUrl(kwargs: CommandArgs): string {
const forum = String(kwargs.forum || '');
return `https://tieba.baidu.com/f?kw=${encodeURIComponent(forum)}&ie=utf-8&pn=${(getForumPageNumber(kwargs) - 1) * 50}`;
}

/**
* Rebuild the signed page_pc request instead of scraping only the visible thread cards.
*/
function buildTiebaPagePcParams(kwargs: CommandArgs, limit: number): Record<string, string> {
return {
kw: encodeURIComponent(String(kwargs.forum || '')),
pn: String(getForumPageNumber(kwargs)),
sort_type: '-1',
is_newfrs: '1',
is_newfeed: '1',
rn: '30',
rn_need: String(Math.min(Math.max(limit + 10, 10), 30)),
tbs: '',
subapp_type: 'pc',
_client_type: '20',
};
}

/**
* Tieba expects the signed forum-list request to be replayed with the browser's cookies.
*/
async function fetchTiebaPagePc(page: IPage, kwargs: CommandArgs, limit: number): Promise<TiebaPagePcResponse> {
await page.goto(getForumUrl(kwargs), { waitUntil: 'none' });
await page.wait(2);

const params = buildTiebaPagePcParams(kwargs, limit);
const cookies = await page.getCookies({ domain: 'tieba.baidu.com' });
const cookieHeader = cookies.map((item) => `${item.name}=${item.value}`).join('; ');
const body = new URLSearchParams({
...params,
sign: signTiebaPcParams(params),
}).toString();

const response = await fetch('https://tieba.baidu.com/c/f/frs/page_pc', {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
cookie: cookieHeader,
'x-requested-with': 'XMLHttpRequest',
referer: getForumUrl(kwargs),
'user-agent': 'Mozilla/5.0',
},
body,
});

const text = await response.text();
try {
return JSON.parse(text) as TiebaPagePcResponse;
} catch {
return {};
}
}

cli({
site: 'tieba',
name: 'posts',
description: 'Browse posts in a tieba forum',
domain: 'tieba.baidu.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'forum', positional: true, required: true, type: 'string', help: 'Forum name in Chinese' },
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of items to return' },
],
columns: ['rank', 'title', 'author', 'replies'],
func: async (page, kwargs) => {
const limit = normalizeTiebaLimit(kwargs.limit);
const payload = await fetchTiebaPagePc(page, kwargs, limit);
const rawFeeds = Array.isArray(payload.page_data?.feed_list) ? payload.page_data.feed_list : [];
const rawCards = buildTiebaPostCardsFromPagePc(rawFeeds);
const items = buildTiebaPostItems(rawCards, limit);

if (!items.length || payload.error_code) {
throw new EmptyResultError('tieba posts', 'Tieba may have blocked the forum page, or the DOM structure may have changed');
}

return items;
},
});
Loading
Loading