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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
## Prerequisites

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

> **⚠️ 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.

Expand All @@ -160,6 +160,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
| **gemini** | `new` `ask` `image` |
| **notebooklm** | `status` `list` `open` `select` `current` `get` `metadata` `source-list` `source-get` `source-fulltext` `source-guide` `history` `note-list` `notes-list` `notes-get` `summary` |
| **spotify** | `auth` `status` `play` `pause` `next` `prev` `volume` `search` `queue` `shuffle` `repeat` |
| **xianyu** | `search` `item` `chat` |

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

Expand Down
3 changes: 2 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合
## 前置要求

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

> **⚠️ 重要**:大多数命令复用你的 Chrome 登录状态。运行命令前,你必须已在 Chrome 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。

Expand Down Expand Up @@ -206,6 +206,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
| **xianyu** | `search` `item` `chat` | 浏览器 |
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |

73+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export default defineConfig({
{ text: 'TikTok', link: '/adapters/browser/tiktok' },
{ text: 'Web (Generic)', link: '/adapters/browser/web' },
{ text: 'Weixin', link: '/adapters/browser/weixin' },
{ text: 'Xianyu', link: '/adapters/browser/xianyu' },
],
},
{
Expand Down
42 changes: 42 additions & 0 deletions docs/adapters/browser/xianyu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Xianyu (闲鱼)

**Mode**: 🔐 Browser · **Domain**: `goofish.com`

## Commands

| Command | Description |
|---------|-------------|
| `opencli xianyu search <query>` | Search Xianyu items by keyword and return item cards with `item_id` |
| `opencli xianyu item <item_id>` | Fetch item details including title, price, condition, brand, seller, and image URLs |
| `opencli xianyu chat <item_id> <user_id>` | Open a Xianyu chat session for the item/user pair and optionally send a message with `--text` |

## Usage Examples

```bash
# Search items
opencli xianyu search "macbook" --limit 5

# Read a single item's details
opencli xianyu item 1040754408976

# Open a chat session
opencli xianyu chat 1038951278192 3650092411

# Send a message in chat
opencli xianyu chat 1038951278192 3650092411 --text "你好,这个还在吗?"

# JSON output
opencli xianyu search "笔记本电脑" -f json
opencli xianyu item 1040754408976 -f json
```

## Prerequisites

- Chrome running and **logged into** `goofish.com`
- [Browser Bridge extension](/guide/browser-bridge) installed

## Notes

- `search` returns `item_id`, which can be passed directly into `opencli xianyu item`
- `chat` requires both the item ID and the target user's `user_id` / `peerUserId`
- Browser-authenticated commands depend on the active Chrome login session remaining valid
1 change: 1 addition & 0 deletions docs/adapters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Run `opencli list` for the live registry.
| **[zsxq](./browser/zsxq)** | `groups` `dynamics` `topics` `topic` `search` | 🔐 Browser |
| **[bluesky](./browser/bluesky)** | `search` `profile` `user` `feeds` `followers` `following` `thread` `trending` `starter-packs` | 🌐 Public |
| **[douyin](./browser/douyin)** | `profile` `videos` `user-videos` `activities` `collections` `hashtag` `location` `stats` `publish` `draft` `drafts` `delete` `update` | 🔐 Browser |
| **[xianyu](./browser/xianyu)** | `search` `item` `chat` | 🔐 Browser |

## Public API Adapters

Expand Down
20 changes: 20 additions & 0 deletions src/clis/xianyu/chat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './chat.js';

describe('xianyu chat helpers', () => {
it('builds goofish im urls from ids', () => {
expect(__test__.buildChatUrl('1038951278192', '3650092411')).toBe(
'https://www.goofish.com/im?itemId=1038951278192&peerUserId=3650092411',
);
});

it('normalizes numeric ids', () => {
expect(__test__.normalizeNumericId('1038951278192', 'item_id', '1038951278192')).toBe('1038951278192');
expect(__test__.normalizeNumericId(3650092411, 'user_id', '3650092411')).toBe('3650092411');
});

it('rejects non-numeric ids', () => {
expect(() => __test__.normalizeNumericId('abc', 'item_id', '1038951278192')).toThrow();
expect(() => __test__.normalizeNumericId('3650092411x', 'user_id', '3650092411')).toThrow();
});
});
175 changes: 175 additions & 0 deletions src/clis/xianyu/chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { AuthRequiredError, SelectorError } from '../../errors.js';
import { cli, Strategy } from '../../registry.js';
import { normalizeNumericId } from './utils.js';

function buildChatUrl(itemId: string, peerUserId: string): string {
return `https://www.goofish.com/im?itemId=${encodeURIComponent(itemId)}&peerUserId=${encodeURIComponent(peerUserId)}`;
}

function buildExtractChatStateEvaluate(): string {
return `
(() => {
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const bodyText = document.body?.innerText || '';
const requiresAuth = /请先登录|登录后/.test(bodyText);

const textarea = document.querySelector('textarea');
const sendButton = Array.from(document.querySelectorAll('button'))
.find((btn) => clean(btn.textContent || '') === '发送');
const topbar = document.querySelector('[class*="message-topbar"]');
const itemCard = Array.from(document.querySelectorAll('a[href*="/item?id="]'))
.find((el) => el.closest('main'));
const itemTitleNode =
document.querySelector('[class*="container"] [class*="title"]')
|| document.querySelector('[class*="item-main-info"] [class*="desc"]')
|| document.querySelector('[class*="headSkuInfo"]')
|| itemCard?.querySelector('[class*="title"]')
|| itemCard?.previousElementSibling?.querySelector?.('[class*="title"]');

const messageRoot = document.querySelector('#message-list-scrollable');
const visibleMessages = Array.from(
(messageRoot || document).querySelectorAll('[class*="message"], [class*="msg"], [class*="bubble"]')
).map((el) => clean(el.textContent || ''))
.filter(Boolean)
.filter((text) => !['发送', '闲鱼号', '立即购买'].includes(text))
.filter((text) => !/^消息\\d*\\+?$/.test(text))
.slice(-20);

return {
requiresAuth,
title: clean(document.title || ''),
peer_name: clean(topbar?.querySelector('[class*="text1"]')?.textContent || ''),
peer_masked_id: clean(topbar?.querySelector('[class*="text2"]')?.textContent || '').replace(/^\\(|\\)$/g, ''),
item_title: clean(itemTitleNode?.textContent || ''),
item_url: itemCard?.href || '',
price: clean(itemCard?.querySelector('[class*="money"]')?.textContent || ''),
location: clean(itemCard?.querySelector('[class*="delivery"] + [class*="delivery"], [class*="delivery"]:last-child')?.textContent || ''),
can_input: Boolean(textarea && !textarea.disabled),
can_send: Boolean(sendButton),
visible_messages: visibleMessages,
};
})()
`;
}

function buildSendMessageEvaluate(text: string): string {
return `
(() => {
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
const textarea = document.querySelector('textarea');
if (!textarea || textarea.disabled) {
return { ok: false, reason: 'input-not-found' };
}

const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
if (!setter) {
return { ok: false, reason: 'textarea-setter-not-found' };
}

textarea.focus();
setter.call(textarea, ${JSON.stringify(text)});
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true }));

const sendButton = Array.from(document.querySelectorAll('button'))
.find((btn) => clean(btn.textContent || '') === '发送');
if (!sendButton) {
return { ok: false, reason: 'send-button-not-found' };
}

sendButton.click();
return { ok: true };
})()
`;
}

cli({
site: 'xianyu',
name: 'chat',
description: '打开闲鱼聊一聊会话,并可选发送消息',
domain: 'www.goofish.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
browser: true,
args: [
{ name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' },
{ name: 'user_id', required: true, positional: true, help: '聊一聊对方的 user_id / peerUserId' },
{ name: 'text', help: 'Message to send after opening the chat' },
],
columns: ['status', 'peer_name', 'item_title', 'price', 'location', 'message'],
func: async (page, kwargs) => {
const itemId = normalizeNumericId(kwargs.item_id, 'item_id', '1038951278192');
const userId = normalizeNumericId(kwargs.user_id, 'user_id', '3650092411');
const url = buildChatUrl(itemId, userId);
const text = String(kwargs.text || '').trim();

await page.goto(url);
await page.wait(2);

const state = await page.evaluate(buildExtractChatStateEvaluate()) as {
requiresAuth?: boolean;
title?: string;
peer_name?: string;
peer_masked_id?: string;
item_title?: string;
item_url?: string;
price?: string;
location?: string;
can_input?: boolean;
can_send?: boolean;
visible_messages?: string[];
};

if (state?.requiresAuth) {
throw new AuthRequiredError('www.goofish.com', 'Xianyu chat requires a logged-in browser session');
}

if (!state?.can_input) {
throw new SelectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载');
}

if (!text) {
return [{
status: 'ready',
peer_name: state.peer_name || '',
item_title: state.item_title || '',
price: state.price || '',
location: state.location || '',
message: (state.visible_messages || []).slice(-1)[0] || '',
peer_user_id: userId,
item_id: itemId,
url,
item_url: state.item_url || '',
}];
}

const sent = await page.evaluate(buildSendMessageEvaluate(text)) as {
ok?: boolean;
reason?: string;
};

if (!sent?.ok) {
throw new SelectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
}

await page.wait(1);

return [{
status: 'sent',
peer_name: state.peer_name || '',
item_title: state.item_title || '',
price: state.price || '',
location: state.location || '',
message: text,
peer_user_id: userId,
item_id: itemId,
url,
item_url: state.item_url || '',
}];
},
});

export const __test__ = {
normalizeNumericId,
buildChatUrl,
};
19 changes: 19 additions & 0 deletions src/clis/xianyu/item.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { __test__ } from './item.js';

describe('xianyu item helpers', () => {
it('normalizes numeric item ids', () => {
expect(__test__.normalizeNumericId('1040754408976', 'item_id', '1040754408976')).toBe('1040754408976');
expect(__test__.normalizeNumericId(1040754408976, 'item_id', '1040754408976')).toBe('1040754408976');
});

it('builds item urls', () => {
expect(__test__.buildItemUrl('1040754408976')).toBe(
'https://www.goofish.com/item?id=1040754408976',
);
});

it('rejects invalid item ids', () => {
expect(() => __test__.normalizeNumericId('abc', 'item_id', '1040754408976')).toThrow();
});
});
Loading