Skip to content

Commit 0ce46b1

Browse files
BruceLoveDecimal刘启灏
andauthored
feat:add xianyu (#696)
* feat:add xianyu feat:add xianyu feat:add xianyu * chore:add xianyu docs * fix:update xianyu after review --------- Co-authored-by: 刘启灏 <liuqihao@liuqihaodeMacBook-Pro.local>
1 parent 37f1b46 commit 0ce46b1

12 files changed

Lines changed: 599 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
115115
## Prerequisites
116116

117117
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0)
118-
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
118+
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com, goofish.com).
119119

120120
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
121121
@@ -132,6 +132,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
132132
| **gemini** | `new` `ask` `image` |
133133
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` |
134134
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
135+
| **xianyu** | `search` `item` `chat` |
135136

136137
73+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
137138

README.zh-CN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
3535
## 前置要求
3636

3737
- **Node.js**: >= 20.0.0
38-
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com)
38+
- **Chrome** 浏览器正在运行,且**已登录目标网站**(如 bilibili.com、zhihu.com、xiaohongshu.com、goofish.com
3939

4040
> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。
4141
@@ -195,6 +195,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
195195
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
196196
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
197197
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
198+
| **xianyu** | `search` `item` `chat` | 浏览器 |
198199
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
199200

200201
73+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export default defineConfig({
9191
{ text: 'TikTok', link: '/adapters/browser/tiktok' },
9292
{ text: 'Web (Generic)', link: '/adapters/browser/web' },
9393
{ text: 'Weixin', link: '/adapters/browser/weixin' },
94+
{ text: 'Xianyu', link: '/adapters/browser/xianyu' },
9495
],
9596
},
9697
{

docs/adapters/browser/xianyu.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Xianyu (闲鱼)
2+
3+
**Mode**: 🔐 Browser · **Domain**: `goofish.com`
4+
5+
## Commands
6+
7+
| Command | Description |
8+
|---------|-------------|
9+
| `opencli xianyu search <query>` | Search Xianyu items by keyword and return item cards with `item_id` |
10+
| `opencli xianyu item <item_id>` | Fetch item details including title, price, condition, brand, seller, and image URLs |
11+
| `opencli xianyu chat <item_id> <user_id>` | Open a Xianyu chat session for the item/user pair and optionally send a message with `--text` |
12+
13+
## Usage Examples
14+
15+
```bash
16+
# Search items
17+
opencli xianyu search "macbook" --limit 5
18+
19+
# Read a single item's details
20+
opencli xianyu item 1040754408976
21+
22+
# Open a chat session
23+
opencli xianyu chat 1038951278192 3650092411
24+
25+
# Send a message in chat
26+
opencli xianyu chat 1038951278192 3650092411 --text "你好,这个还在吗?"
27+
28+
# JSON output
29+
opencli xianyu search "笔记本电脑" -f json
30+
opencli xianyu item 1040754408976 -f json
31+
```
32+
33+
## Prerequisites
34+
35+
- Chrome running and **logged into** `goofish.com`
36+
- [Browser Bridge extension](/guide/browser-bridge) installed
37+
38+
## Notes
39+
40+
- `search` returns `item_id`, which can be passed directly into `opencli xianyu item`
41+
- `chat` requires both the item ID and the target user's `user_id` / `peerUserId`
42+
- Browser-authenticated commands depend on the active Chrome login session remaining valid

docs/adapters/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Run `opencli list` for the live registry.
5454
| **[zsxq](./browser/zsxq)** | `groups` `dynamics` `topics` `topic` `search` | 🔐 Browser |
5555
| **[bluesky](./browser/bluesky)** | `search` `profile` `user` `feeds` `followers` `following` `thread` `trending` `starter-packs` | 🌐 Public |
5656
| **[douyin](./browser/douyin)** | `profile` `videos` `user-videos` `activities` `collections` `hashtag` `location` `stats` `publish` `draft` `drafts` `delete` `update` | 🔐 Browser |
57+
| **[xianyu](./browser/xianyu)** | `search` `item` `chat` | 🔐 Browser |
5758

5859
## Public API Adapters
5960

src/clis/xianyu/chat.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { __test__ } from './chat.js';
3+
4+
describe('xianyu chat helpers', () => {
5+
it('builds goofish im urls from ids', () => {
6+
expect(__test__.buildChatUrl('1038951278192', '3650092411')).toBe(
7+
'https://www.goofish.com/im?itemId=1038951278192&peerUserId=3650092411',
8+
);
9+
});
10+
11+
it('normalizes numeric ids', () => {
12+
expect(__test__.normalizeNumericId('1038951278192', 'item_id', '1038951278192')).toBe('1038951278192');
13+
expect(__test__.normalizeNumericId(3650092411, 'user_id', '3650092411')).toBe('3650092411');
14+
});
15+
16+
it('rejects non-numeric ids', () => {
17+
expect(() => __test__.normalizeNumericId('abc', 'item_id', '1038951278192')).toThrow();
18+
expect(() => __test__.normalizeNumericId('3650092411x', 'user_id', '3650092411')).toThrow();
19+
});
20+
});

src/clis/xianyu/chat.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { AuthRequiredError, SelectorError } from '../../errors.js';
2+
import { cli, Strategy } from '../../registry.js';
3+
import { normalizeNumericId } from './utils.js';
4+
5+
function buildChatUrl(itemId: string, peerUserId: string): string {
6+
return `https://www.goofish.com/im?itemId=${encodeURIComponent(itemId)}&peerUserId=${encodeURIComponent(peerUserId)}`;
7+
}
8+
9+
function buildExtractChatStateEvaluate(): string {
10+
return `
11+
(() => {
12+
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
13+
const bodyText = document.body?.innerText || '';
14+
const requiresAuth = /请先登录|登录后/.test(bodyText);
15+
16+
const textarea = document.querySelector('textarea');
17+
const sendButton = Array.from(document.querySelectorAll('button'))
18+
.find((btn) => clean(btn.textContent || '') === '发送');
19+
const topbar = document.querySelector('[class*="message-topbar"]');
20+
const itemCard = Array.from(document.querySelectorAll('a[href*="/item?id="]'))
21+
.find((el) => el.closest('main'));
22+
const itemTitleNode =
23+
document.querySelector('[class*="container"] [class*="title"]')
24+
|| document.querySelector('[class*="item-main-info"] [class*="desc"]')
25+
|| document.querySelector('[class*="headSkuInfo"]')
26+
|| itemCard?.querySelector('[class*="title"]')
27+
|| itemCard?.previousElementSibling?.querySelector?.('[class*="title"]');
28+
29+
const messageRoot = document.querySelector('#message-list-scrollable');
30+
const visibleMessages = Array.from(
31+
(messageRoot || document).querySelectorAll('[class*="message"], [class*="msg"], [class*="bubble"]')
32+
).map((el) => clean(el.textContent || ''))
33+
.filter(Boolean)
34+
.filter((text) => !['发送', '闲鱼号', '立即购买'].includes(text))
35+
.filter((text) => !/^消息\\d*\\+?$/.test(text))
36+
.slice(-20);
37+
38+
return {
39+
requiresAuth,
40+
title: clean(document.title || ''),
41+
peer_name: clean(topbar?.querySelector('[class*="text1"]')?.textContent || ''),
42+
peer_masked_id: clean(topbar?.querySelector('[class*="text2"]')?.textContent || '').replace(/^\\(|\\)$/g, ''),
43+
item_title: clean(itemTitleNode?.textContent || ''),
44+
item_url: itemCard?.href || '',
45+
price: clean(itemCard?.querySelector('[class*="money"]')?.textContent || ''),
46+
location: clean(itemCard?.querySelector('[class*="delivery"] + [class*="delivery"], [class*="delivery"]:last-child')?.textContent || ''),
47+
can_input: Boolean(textarea && !textarea.disabled),
48+
can_send: Boolean(sendButton),
49+
visible_messages: visibleMessages,
50+
};
51+
})()
52+
`;
53+
}
54+
55+
function buildSendMessageEvaluate(text: string): string {
56+
return `
57+
(() => {
58+
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
59+
const textarea = document.querySelector('textarea');
60+
if (!textarea || textarea.disabled) {
61+
return { ok: false, reason: 'input-not-found' };
62+
}
63+
64+
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
65+
if (!setter) {
66+
return { ok: false, reason: 'textarea-setter-not-found' };
67+
}
68+
69+
textarea.focus();
70+
setter.call(textarea, ${JSON.stringify(text)});
71+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
72+
textarea.dispatchEvent(new Event('change', { bubbles: true }));
73+
74+
const sendButton = Array.from(document.querySelectorAll('button'))
75+
.find((btn) => clean(btn.textContent || '') === '发送');
76+
if (!sendButton) {
77+
return { ok: false, reason: 'send-button-not-found' };
78+
}
79+
80+
sendButton.click();
81+
return { ok: true };
82+
})()
83+
`;
84+
}
85+
86+
cli({
87+
site: 'xianyu',
88+
name: 'chat',
89+
description: '打开闲鱼聊一聊会话,并可选发送消息',
90+
domain: 'www.goofish.com',
91+
strategy: Strategy.COOKIE,
92+
navigateBefore: false,
93+
browser: true,
94+
args: [
95+
{ name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' },
96+
{ name: 'user_id', required: true, positional: true, help: '聊一聊对方的 user_id / peerUserId' },
97+
{ name: 'text', help: 'Message to send after opening the chat' },
98+
],
99+
columns: ['status', 'peer_name', 'item_title', 'price', 'location', 'message'],
100+
func: async (page, kwargs) => {
101+
const itemId = normalizeNumericId(kwargs.item_id, 'item_id', '1038951278192');
102+
const userId = normalizeNumericId(kwargs.user_id, 'user_id', '3650092411');
103+
const url = buildChatUrl(itemId, userId);
104+
const text = String(kwargs.text || '').trim();
105+
106+
await page.goto(url);
107+
await page.wait(2);
108+
109+
const state = await page.evaluate(buildExtractChatStateEvaluate()) as {
110+
requiresAuth?: boolean;
111+
title?: string;
112+
peer_name?: string;
113+
peer_masked_id?: string;
114+
item_title?: string;
115+
item_url?: string;
116+
price?: string;
117+
location?: string;
118+
can_input?: boolean;
119+
can_send?: boolean;
120+
visible_messages?: string[];
121+
};
122+
123+
if (state?.requiresAuth) {
124+
throw new AuthRequiredError('www.goofish.com', 'Xianyu chat requires a logged-in browser session');
125+
}
126+
127+
if (!state?.can_input) {
128+
throw new SelectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载');
129+
}
130+
131+
if (!text) {
132+
return [{
133+
status: 'ready',
134+
peer_name: state.peer_name || '',
135+
item_title: state.item_title || '',
136+
price: state.price || '',
137+
location: state.location || '',
138+
message: (state.visible_messages || []).slice(-1)[0] || '',
139+
peer_user_id: userId,
140+
item_id: itemId,
141+
url,
142+
item_url: state.item_url || '',
143+
}];
144+
}
145+
146+
const sent = await page.evaluate(buildSendMessageEvaluate(text)) as {
147+
ok?: boolean;
148+
reason?: string;
149+
};
150+
151+
if (!sent?.ok) {
152+
throw new SelectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
153+
}
154+
155+
await page.wait(1);
156+
157+
return [{
158+
status: 'sent',
159+
peer_name: state.peer_name || '',
160+
item_title: state.item_title || '',
161+
price: state.price || '',
162+
location: state.location || '',
163+
message: text,
164+
peer_user_id: userId,
165+
item_id: itemId,
166+
url,
167+
item_url: state.item_url || '',
168+
}];
169+
},
170+
});
171+
172+
export const __test__ = {
173+
normalizeNumericId,
174+
buildChatUrl,
175+
};

src/clis/xianyu/item.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { __test__ } from './item.js';
3+
4+
describe('xianyu item helpers', () => {
5+
it('normalizes numeric item ids', () => {
6+
expect(__test__.normalizeNumericId('1040754408976', 'item_id', '1040754408976')).toBe('1040754408976');
7+
expect(__test__.normalizeNumericId(1040754408976, 'item_id', '1040754408976')).toBe('1040754408976');
8+
});
9+
10+
it('builds item urls', () => {
11+
expect(__test__.buildItemUrl('1040754408976')).toBe(
12+
'https://www.goofish.com/item?id=1040754408976',
13+
);
14+
});
15+
16+
it('rejects invalid item ids', () => {
17+
expect(() => __test__.normalizeNumericId('abc', 'item_id', '1040754408976')).toThrow();
18+
});
19+
});

0 commit comments

Comments
 (0)