diff --git a/README.md b/README.md index dac94d11..c2e26489 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Run `opencli list` for the live registry. | **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | Desktop | | **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | Desktop | | **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop | -| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | Public / Browser | +| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser | | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | Browser | | **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | Desktop | | **chatgpt** | `status` `new` `send` `read` `ask` | Desktop | diff --git a/README.zh-CN.md b/README.zh-CN.md index 198cc416..720b1caf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -111,7 +111,7 @@ npm install -g @jackwener/opencli@latest | **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | 桌面端 | | **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | 桌面端 | | **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | 桌面端 | -| **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 公开 / 浏览器 | +| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 公开 / 浏览器 | | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` | 浏览器 | | **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 | | **chatgpt** | `status` `new` `send` `read` `ask` | 桌面端 | diff --git a/docs/adapters/browser/v2ex.md b/docs/adapters/browser/v2ex.md index 85f1d72b..6a4b441d 100644 --- a/docs/adapters/browser/v2ex.md +++ b/docs/adapters/browser/v2ex.md @@ -6,27 +6,48 @@ | Command | Description | |---------|-------------| -| `opencli v2ex hot` | | -| `opencli v2ex latest` | | -| `opencli v2ex topic` | | -| `opencli v2ex daily` | | -| `opencli v2ex me` | | -| `opencli v2ex notifications` | | +| `opencli v2ex hot` | Hot topics | +| `opencli v2ex latest` | Latest topics | +| `opencli v2ex topic ` | Topic detail | +| `opencli v2ex node ` | Topics by node | +| `opencli v2ex user ` | Topics by user | +| `opencli v2ex member ` | User profile | +| `opencli v2ex replies ` | Topic replies | +| `opencli v2ex nodes` | All nodes (sorted by topic count) | +| `opencli v2ex daily` | Daily hot | +| `opencli v2ex me` | My profile (auth required) | +| `opencli v2ex notifications` | My notifications (auth required) | ## Usage Examples ```bash -# Quick start +# Hot topics opencli v2ex hot --limit 5 +# Browse topics in a node +opencli v2ex node python + +# View topic replies +opencli v2ex replies 1000 + +# User's topics +opencli v2ex user Livid + +# User profile +opencli v2ex member Livid + +# List all nodes +opencli v2ex nodes --limit 10 + # JSON output opencli v2ex hot -f json - -# Verbose mode -opencli v2ex hot -v ``` ## Prerequisites +Most commands (`hot`, `latest`, `topic`, `node`, `user`, `member`, `replies`, `nodes`) use the public V2EX API and **require no browser or login**. + +For `daily`, `me`, and `notifications`: + - Chrome running and **logged into** v2ex.com - [Browser Bridge extension](/guide/browser-bridge) installed diff --git a/docs/adapters/index.md b/docs/adapters/index.md index dfce4633..1f24ae86 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -13,7 +13,7 @@ Run `opencli list` for the live registry. | **[xiaohongshu](/adapters/browser/xiaohongshu)** | `search` `notifications` `feed` `me` `user` `download` `publish` | 🔐 Browser | | **[xueqiu](/adapters/browser/xueqiu)** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser | | **[youtube](/adapters/browser/youtube)** | `search` `video` `transcript` | 🔐 Browser | -| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 / 🔐 | +| **[v2ex](/adapters/browser/v2ex)** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | 🌐 / 🔐 | | **[bloomberg](/adapters/browser/bloomberg)** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | 🌐 / 🔐 | | **[weibo](/adapters/browser/weibo)** | `hot` | 🔐 Browser | | **[linkedin](/adapters/browser/linkedin)** | `search` | 🔐 Browser | diff --git a/src/clis/v2ex/member.yaml b/src/clis/v2ex/member.yaml new file mode 100644 index 00000000..b2b21a3f --- /dev/null +++ b/src/clis/v2ex/member.yaml @@ -0,0 +1,29 @@ +site: v2ex +name: member +description: V2EX 用户资料 +domain: www.v2ex.com +strategy: public +browser: false + +args: + username: + positional: true + type: str + required: true + description: Username + +pipeline: + - fetch: + url: https://www.v2ex.com/api/members/show.json + params: + username: ${{ args.username }} + + - map: + username: ${{ item.username }} + tagline: ${{ item.tagline }} + website: ${{ item.website }} + github: ${{ item.github }} + twitter: ${{ item.twitter }} + location: ${{ item.location }} + +columns: [username, tagline, website, github, twitter, location] diff --git a/src/clis/v2ex/node.yaml b/src/clis/v2ex/node.yaml new file mode 100644 index 00000000..8c26b6de --- /dev/null +++ b/src/clis/v2ex/node.yaml @@ -0,0 +1,34 @@ +site: v2ex +name: node +description: V2EX 节点话题列表 +domain: www.v2ex.com +strategy: public +browser: false + +args: + name: + positional: true + type: str + required: true + description: Node name (e.g. python, javascript, apple) + limit: + type: int + default: 10 + description: Number of topics (API returns max 20) + +pipeline: + - fetch: + url: https://www.v2ex.com/api/topics/show.json + params: + node_name: ${{ args.name }} + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + author: ${{ item.member.username }} + replies: ${{ item.replies }} + url: ${{ item.url }} + + - limit: ${{ args.limit }} + +columns: [rank, title, author, replies, url] diff --git a/src/clis/v2ex/nodes.yaml b/src/clis/v2ex/nodes.yaml new file mode 100644 index 00000000..534a7696 --- /dev/null +++ b/src/clis/v2ex/nodes.yaml @@ -0,0 +1,31 @@ +site: v2ex +name: nodes +description: V2EX 所有节点列表 +domain: www.v2ex.com +strategy: public +browser: false + +args: + limit: + type: int + default: 30 + description: Number of nodes + +pipeline: + - fetch: + url: https://www.v2ex.com/api/nodes/all.json + + - sort: + by: topics + order: desc + + - map: + rank: ${{ index + 1 }} + name: ${{ item.name }} + title: ${{ item.title }} + topics: ${{ item.topics }} + stars: ${{ item.stars }} + + - limit: ${{ args.limit }} + +columns: [rank, name, title, topics, stars] diff --git a/src/clis/v2ex/replies.yaml b/src/clis/v2ex/replies.yaml new file mode 100644 index 00000000..deb24346 --- /dev/null +++ b/src/clis/v2ex/replies.yaml @@ -0,0 +1,32 @@ +site: v2ex +name: replies +description: V2EX 主题回复列表 +domain: www.v2ex.com +strategy: public +browser: false + +args: + id: + positional: true + type: str + required: true + description: Topic ID + limit: + type: int + default: 20 + description: Number of replies + +pipeline: + - fetch: + url: https://www.v2ex.com/api/replies/show.json + params: + topic_id: ${{ args.id }} + + - map: + floor: ${{ index + 1 }} + author: ${{ item.member.username }} + content: ${{ item.content }} + + - limit: ${{ args.limit }} + +columns: [floor, author, content] diff --git a/src/clis/v2ex/user.yaml b/src/clis/v2ex/user.yaml new file mode 100644 index 00000000..b60dd485 --- /dev/null +++ b/src/clis/v2ex/user.yaml @@ -0,0 +1,34 @@ +site: v2ex +name: user +description: V2EX 用户发帖列表 +domain: www.v2ex.com +strategy: public +browser: false + +args: + username: + positional: true + type: str + required: true + description: Username + limit: + type: int + default: 10 + description: Number of topics (API returns max 20) + +pipeline: + - fetch: + url: https://www.v2ex.com/api/topics/show.json + params: + username: ${{ args.username }} + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + node: ${{ item.node.title }} + replies: ${{ item.replies }} + url: ${{ item.url }} + + - limit: ${{ args.limit }} + +columns: [rank, title, node, replies, url] diff --git a/tests/e2e/public-commands.test.ts b/tests/e2e/public-commands.test.ts index 2790cc16..2de6da5e 100644 --- a/tests/e2e/public-commands.test.ts +++ b/tests/e2e/public-commands.test.ts @@ -239,6 +239,69 @@ describe('public commands E2E', () => { } }, 30_000); + it('v2ex node returns topics for a given node', async () => { + const { stdout, code } = await runCli(['v2ex', 'node', 'python', '--limit', '3', '-f', 'json']); + // V2EX may rate-limit; only assert when successful + if (code === 0) { + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data.length).toBeLessThanOrEqual(3); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('author'); + expect(data[0]).toHaveProperty('url'); + } + }, 30_000); + + it('v2ex user returns topics by username', async () => { + const { stdout, code } = await runCli(['v2ex', 'user', 'Livid', '--limit', '3', '-f', 'json']); + if (code === 0) { + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data.length).toBeLessThanOrEqual(3); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('node'); + expect(data[0]).toHaveProperty('url'); + } + }, 30_000); + + it('v2ex member returns user profile', async () => { + const { stdout, code } = await runCli(['v2ex', 'member', 'Livid', '-f', 'json']); + if (code === 0) { + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(1); + expect(data[0].username).toBe('Livid'); + } + }, 30_000); + + it('v2ex replies returns topic replies', async () => { + const { stdout, code } = await runCli(['v2ex', 'replies', '1000', '--limit', '3', '-f', 'json']); + if (code === 0) { + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data.length).toBeLessThanOrEqual(3); + expect(data[0]).toHaveProperty('author'); + expect(data[0]).toHaveProperty('content'); + } + }, 30_000); + + it('v2ex nodes returns node list sorted by topics', async () => { + const { stdout, code } = await runCli(['v2ex', 'nodes', '--limit', '5', '-f', 'json']); + if (code === 0) { + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(5); + expect(data[0]).toHaveProperty('name'); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('topics'); + // Verify descending sort by topic count + expect(Number(data[0].topics)).toBeGreaterThanOrEqual(Number(data[data.length - 1].topics)); + } + }, 30_000); + // ── xiaoyuzhou (Chinese site — may return empty on overseas CI runners) ── it('xiaoyuzhou podcast returns podcast profile', async () => { const { stdout, stderr, code } = await runCli(['xiaoyuzhou', 'podcast', '6013f9f58e2f7ee375cf4216', '-f', 'json']); diff --git a/tests/smoke/api-health.test.ts b/tests/smoke/api-health.test.ts index d83b46f6..5f0295ef 100644 --- a/tests/smoke/api-health.test.ts +++ b/tests/smoke/api-health.test.ts @@ -4,11 +4,10 @@ * These verify that external APIs haven't changed their structure. */ -import { describe, it, expect } from 'vitest'; -import { runCli, parseJsonOutput } from '../e2e/helpers.js'; +import { describe, expect, it } from 'vitest'; +import { parseJsonOutput, runCli } from '../e2e/helpers.js'; describe('API health smoke tests', () => { - // ── Public API commands (should always work) ── it('hackernews API is responsive and returns expected structure', async () => { const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '5', '-f', 'json']); @@ -46,6 +45,53 @@ describe('API health smoke tests', () => { } }, 30_000); + it('v2ex node API is responsive', async () => { + const { stdout, code } = await runCli(['v2ex', 'node', 'python', '--limit', '3', '-f', 'json']); + if (code === 0) { + const data = parseJsonOutput(stdout); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('author'); + } + }, 30_000); + + it('v2ex user API is responsive', async () => { + const { stdout, code } = await runCli(['v2ex', 'user', 'Livid', '--limit', '3', '-f', 'json']); + if (code === 0) { + const data = parseJsonOutput(stdout); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('url'); + } + }, 30_000); + + it('v2ex member API is responsive', async () => { + const { stdout, code } = await runCli(['v2ex', 'member', 'Livid', '-f', 'json']); + if (code === 0) { + const data = parseJsonOutput(stdout); + expect(data.length).toBe(1); + expect(data[0].username).toBe('Livid'); + } + }, 30_000); + + it('v2ex replies API is responsive', async () => { + const { stdout, code } = await runCli(['v2ex', 'replies', '1000', '--limit', '3', '-f', 'json']); + if (code === 0) { + const data = parseJsonOutput(stdout); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('author'); + } + }, 30_000); + + it('v2ex nodes API is responsive', async () => { + const { stdout, code } = await runCli(['v2ex', 'nodes', '--limit', '5', '-f', 'json']); + if (code === 0) { + const data = parseJsonOutput(stdout); + expect(data.length).toBe(5); + expect(data[0]).toHaveProperty('topics'); + } + }, 30_000); + // ── Validate all adapters ── it('all adapter definitions are valid', async () => { const { stdout, code } = await runCli(['validate']); @@ -61,9 +107,22 @@ describe('API health smoke tests', () => { const sites = new Set(data.map((d: any) => d.site)); // Verify all 17 sites are present for (const expected of [ - 'hackernews', 'bbc', 'bilibili', 'v2ex', 'weibo', 'zhihu', - 'twitter', 'reddit', 'xueqiu', 'reuters', 'youtube', - 'smzdm', 'boss', 'ctrip', 'coupang', 'xiaohongshu', + 'hackernews', + 'bbc', + 'bilibili', + 'v2ex', + 'weibo', + 'zhihu', + 'twitter', + 'reddit', + 'xueqiu', + 'reuters', + 'youtube', + 'smzdm', + 'boss', + 'ctrip', + 'coupang', + 'xiaohongshu', 'yahoo-finance', ]) { expect(sites.has(expected)).toBe(true);