From c07c735fbfcc0d5ab808ccbb948789000cd8890e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=90=AF=E7=81=8F?= Date: Thu, 2 Apr 2026 20:30:08 +0800 Subject: [PATCH 1/3] feat:add xianyu feat:add xianyu feat:add xianyu --- README.md | 11 ++- README.zh-CN.md | 11 ++- src/clis/xianyu/chat.test.ts | 20 ++++ src/clis/xianyu/chat.ts | 176 +++++++++++++++++++++++++++++++++ src/clis/xianyu/item.test.ts | 19 ++++ src/clis/xianyu/item.ts | 121 +++++++++++++++++++++++ src/clis/xianyu/search.test.ts | 22 +++++ src/clis/xianyu/search.ts | 150 ++++++++++++++++++++++++++++ 8 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 src/clis/xianyu/chat.test.ts create mode 100644 src/clis/xianyu/chat.ts create mode 100644 src/clis/xianyu/item.test.ts create mode 100644 src/clis/xianyu/item.ts create mode 100644 src/clis/xianyu/search.test.ts create mode 100644 src/clis/xianyu/search.ts diff --git a/README.md b/README.md index a2dcb448..81ddc3ff 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,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. @@ -150,6 +150,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n | Site | Commands | |------|----------| +| **xianyu** | `search` `item` `chat` | | **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `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` | @@ -162,6 +163,14 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n 66+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)** +Example: + +```bash +opencli xianyu search "macbook" --limit 5 +opencli xianyu item 1040754408976 +opencli xianyu chat 1038951278192 3650092411 --text "Hi, is this still available?" +``` + ## CLI Hub OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install ` automatically before re-running the command). diff --git a/README.zh-CN.md b/README.zh-CN.md index 6f5d7d3d..6de765d0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。 @@ -205,10 +205,19 @@ 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` | 浏览器 | 66+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)** +示例: + +```bash +opencli xianyu search "macbook" --limit 5 +opencli xianyu item 1040754408976 +opencli xianyu chat 1038951278192 3650092411 --text "你好,这个还在吗?" +``` + ### 外部 CLI 枢纽 OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、自动安装和纯透传执行。 diff --git a/src/clis/xianyu/chat.test.ts b/src/clis/xianyu/chat.test.ts new file mode 100644 index 00000000..9e950d80 --- /dev/null +++ b/src/clis/xianyu/chat.test.ts @@ -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__.normalizeId('1038951278192', 'item_id')).toBe('1038951278192'); + expect(__test__.normalizeId(3650092411, 'user_id')).toBe('3650092411'); + }); + + it('rejects non-numeric ids', () => { + expect(() => __test__.normalizeId('abc', 'item_id')).toThrow(); + expect(() => __test__.normalizeId('3650092411x', 'user_id')).toThrow(); + }); +}); diff --git a/src/clis/xianyu/chat.ts b/src/clis/xianyu/chat.ts new file mode 100644 index 00000000..3d2be147 --- /dev/null +++ b/src/clis/xianyu/chat.ts @@ -0,0 +1,176 @@ +import { AuthRequiredError, SelectorError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; + +function normalizeId(value: unknown, label: string): string { + const normalized = String(value || '').trim(); + if (!/^\d+$/.test(normalized)) { + throw new SelectorError(label, `${label} 必须是纯数字 ID`); + } + return normalized; +} + +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 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)) + .filter((text, index, arr) => arr.indexOf(text) === index) + .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: '', + 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, + 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 = normalizeId(kwargs.item_id, 'item_id'); + const userId = normalizeId(kwargs.user_id, 'user_id'); + 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__ = { + normalizeId, + buildChatUrl, +}; diff --git a/src/clis/xianyu/item.test.ts b/src/clis/xianyu/item.test.ts new file mode 100644 index 00000000..5961de59 --- /dev/null +++ b/src/clis/xianyu/item.test.ts @@ -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__.normalizeItemId('1040754408976')).toBe('1040754408976'); + expect(__test__.normalizeItemId(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__.normalizeItemId('abc')).toThrow(); + }); +}); diff --git a/src/clis/xianyu/item.ts b/src/clis/xianyu/item.ts new file mode 100644 index 00000000..a27ce91b --- /dev/null +++ b/src/clis/xianyu/item.ts @@ -0,0 +1,121 @@ +import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; + +function normalizeItemId(value: unknown): string { + const normalized = String(value || '').trim(); + if (!/^\d+$/.test(normalized)) { + throw new SelectorError('item_id', 'item_id 必须是纯数字 ID'); + } + return normalized; +} + +function buildItemUrl(itemId: string): string { + return `https://www.goofish.com/item?id=${encodeURIComponent(itemId)}`; +} + +function buildFetchItemEvaluate(itemId: string): string { + return ` + (async () => { + const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + + if (!window.lib || !window.lib.mtop || typeof window.lib.mtop.request !== 'function') { + return { error: 'mtop-not-ready' }; + } + + const response = await window.lib.mtop.request({ + api: 'mtop.taobao.idle.pc.detail', + data: { itemId: ${JSON.stringify(itemId)} }, + type: 'POST', + v: '1.0', + dataType: 'json', + needLogin: false, + needLoginPC: false, + sessionOption: 'AutoLoginOnly', + ecode: 0, + }); + + const data = response?.data || {}; + const item = data.itemDO || {}; + const seller = data.sellerDO || {}; + const labels = Array.isArray(item.itemLabelExtList) ? item.itemLabelExtList : []; + const findLabel = (name) => labels.find((label) => clean(label.propertyText) === name)?.text || ''; + const images = Array.isArray(item.imageInfos) + ? item.imageInfos.map((entry) => entry?.url).filter(Boolean) + : []; + + return { + item_id: clean(item.itemId || ${JSON.stringify(itemId)}), + title: clean(item.title || ''), + description: clean(item.desc || ''), + price: clean('¥' + (item.soldPrice || item.defaultPrice || '')).replace(/^¥\\s*$/, ''), + original_price: clean(item.originalPrice || ''), + want_count: String(item.wantCnt ?? ''), + collect_count: String(item.collectCnt ?? ''), + browse_count: String(item.browseCnt ?? ''), + status: clean(item.itemStatusStr || ''), + condition: clean(findLabel('成色')), + brand: clean(findLabel('品牌')), + category: clean(findLabel('分类')), + location: clean(seller.publishCity || seller.city || ''), + seller_name: clean(seller.nick || seller.uniqueName || ''), + seller_id: String(seller.sellerId || ''), + seller_score: clean(seller.xianyuSummary || ''), + reply_ratio_24h: clean(seller.replyRatio24h || ''), + reply_interval: clean(seller.replyInterval || ''), + item_url: ${JSON.stringify(buildItemUrl(itemId))}, + seller_url: seller.sellerId ? 'https://www.goofish.com/personal?userId=' + seller.sellerId : '', + image_count: String(images.length), + image_urls: images, + }; + })() + `; +} + +cli({ + site: 'xianyu', + name: 'item', + description: '查看闲鱼商品详情', + domain: 'www.goofish.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' }, + ], + columns: ['item_id', 'title', 'price', 'condition', 'brand', 'location', 'seller_name', 'want_count'], + func: async (page, kwargs) => { + const itemId = normalizeItemId(kwargs.item_id); + + await page.goto(buildItemUrl(itemId)); + await page.wait(2); + + const result = await page.evaluate(buildFetchItemEvaluate(itemId)) as { + error?: string; + title?: string; + item_id?: string; + } & Record; + + if (result?.error === 'mtop-not-ready') { + throw new SelectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口'); + } + + if (!result || typeof result !== 'object') { + throw new EmptyResultError('xianyu item', '闲鱼商品详情接口未返回有效数据'); + } + + const message = String((result as any).error || ''); + if (/SESSION_EXPIRED|FAIL_SYS/.test(message)) { + throw new AuthRequiredError('www.goofish.com', 'Xianyu item detail requires a logged-in browser session'); + } + + if (!String(result.title || '').trim()) { + throw new EmptyResultError('xianyu item', 'No item detail was returned for the specified item_id'); + } + + return [result]; + }, +}); + +export const __test__ = { + normalizeItemId, + buildItemUrl, +}; diff --git a/src/clis/xianyu/search.test.ts b/src/clis/xianyu/search.test.ts new file mode 100644 index 00000000..75554056 --- /dev/null +++ b/src/clis/xianyu/search.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { __test__ } from './search.js'; + +describe('xianyu search helpers', () => { + it('normalizes limit into supported range', () => { + expect(__test__.normalizeLimit(undefined)).toBe(20); + expect(__test__.normalizeLimit(0)).toBe(1); + expect(__test__.normalizeLimit(3.8)).toBe(3); + expect(__test__.normalizeLimit(999)).toBe(__test__.MAX_LIMIT); + }); + + it('builds search URLs with encoded queries', () => { + expect(__test__.buildSearchUrl('笔记本电脑')).toBe( + 'https://www.goofish.com/search?q=%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%B5%E8%84%91', + ); + }); + + it('extracts item ids from detail URLs', () => { + expect(__test__.itemIdFromUrl('https://www.goofish.com/item?id=954988715389&categoryId=126854525')).toBe('954988715389'); + expect(__test__.itemIdFromUrl('https://www.goofish.com/search?q=test')).toBe(''); + }); +}); diff --git a/src/clis/xianyu/search.ts b/src/clis/xianyu/search.ts new file mode 100644 index 00000000..b9b9e156 --- /dev/null +++ b/src/clis/xianyu/search.ts @@ -0,0 +1,150 @@ +import { AuthRequiredError, EmptyResultError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; + +const MAX_LIMIT = 50; + +function normalizeLimit(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n)) return 20; + return Math.min(MAX_LIMIT, Math.max(1, Math.floor(n))); +} + +function buildSearchUrl(query: string): string { + return `https://www.goofish.com/search?q=${encodeURIComponent(query)}`; +} + +function itemIdFromUrl(url: string): string { + const match = url.match(/[?&]id=(\d+)/); + return match ? match[1] : ''; +} + +function buildExtractResultsEvaluate(limit: number): string { + return ` + (async () => { + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const waitFor = async (predicate, timeoutMs = 8000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return true; + await wait(150); + } + return false; + }; + + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const selectors = { + card: 'a[href*="/item?id="]', + title: '[class*="row1-wrap-title"], [class*="main-title"]', + attrs: '[class*="row2-wrap-cpv"] span[class*="cpv--"]', + priceWrap: '[class*="price-wrap"]', + priceNum: '[class*="number"]', + priceDec: '[class*="decimal"]', + priceDesc: '[class*="price-desc"] [title], [class*="price-desc"] [style*="line-through"]', + sellerWrap: '[class*="row4-wrap-seller"]', + sellerText: '[class*="seller-text"]', + badge: '[class*="credit-container"] [title], [class*="credit-container"] span', + }; + + await waitFor(() => { + const bodyText = document.body?.innerText || ''; + return Boolean( + document.querySelector(selectors.card) + || /请先登录|登录后|验证码|安全验证|异常访问/.test(bodyText) + || /暂无相关宝贝|未找到相关宝贝|没有找到/.test(bodyText) + ); + }); + + const bodyText = document.body?.innerText || ''; + const requiresAuth = /请先登录|登录后/.test(bodyText); + const blocked = /验证码|安全验证|异常访问/.test(bodyText); + const empty = /暂无相关宝贝|未找到相关宝贝|没有找到/.test(bodyText); + + const items = Array.from(document.querySelectorAll(selectors.card)) + .slice(0, ${limit}) + .map((card) => { + const href = card.href || card.getAttribute('href') || ''; + const title = clean(card.querySelector(selectors.title)?.textContent || ''); + const attrs = Array.from(card.querySelectorAll(selectors.attrs)) + .map((node) => clean(node.textContent || '')) + .filter(Boolean); + const priceWrap = card.querySelector(selectors.priceWrap); + const priceNumber = clean(priceWrap?.querySelector(selectors.priceNum)?.textContent || ''); + const priceDecimal = clean(priceWrap?.querySelector(selectors.priceDec)?.textContent || ''); + const location = clean(card.querySelector(selectors.sellerWrap)?.querySelector(selectors.sellerText)?.textContent || ''); + const originalPriceNode = card.querySelector(selectors.priceDesc); + const badgeNode = card.querySelector(selectors.badge); + + return { + title, + url: href, + item_id: '', + price: clean('¥' + priceNumber + priceDecimal).replace(/^¥\\s*$/, ''), + original_price: clean(originalPriceNode?.getAttribute('title') || originalPriceNode?.textContent || ''), + condition: attrs[0] || '', + brand: attrs[1] || '', + extra: attrs.slice(2).join(' | '), + location, + badge: clean(badgeNode?.getAttribute('title') || badgeNode?.textContent || ''), + }; + }) + .filter((item) => item.title && item.url); + + return { requiresAuth, blocked, empty, items }; + })() + `; +} + +cli({ + site: 'xianyu', + name: 'search', + description: '搜索闲鱼商品', + domain: 'www.goofish.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'query', required: true, positional: true, help: 'Search keyword' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of results to return' }, + ], + columns: ['rank', 'title', 'price', 'condition', 'brand', 'location', 'badge'], + func: async (page, kwargs) => { + const query = String(kwargs.query || '').trim(); + const limit = normalizeLimit(kwargs.limit); + + await page.goto(buildSearchUrl(query)); + await page.wait(2); + await page.autoScroll({ times: 2 }); + + const payload = await page.evaluate(buildExtractResultsEvaluate(limit)) as { + requiresAuth?: boolean; + blocked?: boolean; + empty?: boolean; + items?: Array>; + }; + + if (payload?.requiresAuth) { + throw new AuthRequiredError('www.goofish.com', 'Xianyu search results require a logged-in browser session'); + } + + if (payload?.blocked) { + throw new EmptyResultError('xianyu search', 'Xianyu returned a verification page or blocked the current browser session'); + } + + const items = Array.isArray(payload?.items) ? payload.items : []; + if (!items.length && !payload?.empty) { + throw new EmptyResultError('xianyu search', 'No item cards were found on the current Xianyu search page'); + } + + return items.map((item, index) => ({ + rank: index + 1, + ...item, + item_id: itemIdFromUrl(item.url), + })); + }, +}); + +export const __test__ = { + MAX_LIMIT, + normalizeLimit, + buildSearchUrl, + itemIdFromUrl, +}; From 417121880e99d0a26660d02c86ffb5ed7fb75ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=90=AF=E7=81=8F?= Date: Thu, 2 Apr 2026 21:13:15 +0800 Subject: [PATCH 2/3] chore:add xianyu docs --- docs/adapters/browser/xianyu.md | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/adapters/browser/xianyu.md diff --git a/docs/adapters/browser/xianyu.md b/docs/adapters/browser/xianyu.md new file mode 100644 index 00000000..9b70f614 --- /dev/null +++ b/docs/adapters/browser/xianyu.md @@ -0,0 +1,42 @@ +# Xianyu (闲鱼) + +**Mode**: 🔐 Browser · **Domain**: `goofish.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli xianyu search ` | Search Xianyu items by keyword and return item cards with `item_id` | +| `opencli xianyu item ` | Fetch item details including title, price, condition, brand, seller, and image URLs | +| `opencli xianyu chat ` | 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 From d9dc008381f12bfc4237daa1bc705d5f292d31ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=90=AF=E7=81=8F?= Date: Fri, 3 Apr 2026 00:41:41 +0800 Subject: [PATCH 3/3] fix:update xianyu after review --- README.md | 10 +---- README.zh-CN.md | 8 ---- docs/.vitepress/config.mts | 1 + docs/adapters/index.md | 1 + src/clis/xianyu/chat.test.ts | 8 ++-- src/clis/xianyu/chat.ts | 25 ++++++----- src/clis/xianyu/item.test.ts | 6 +-- src/clis/xianyu/item.ts | 80 +++++++++++++++++++++++++----------- src/clis/xianyu/search.ts | 3 +- src/clis/xianyu/utils.ts | 9 ++++ 10 files changed, 90 insertions(+), 61 deletions(-) create mode 100644 src/clis/xianyu/utils.ts diff --git a/README.md b/README.md index 81ddc3ff..3d0b4318 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,6 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n | Site | Commands | |------|----------| -| **xianyu** | `search` `item` `chat` | | **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `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` | @@ -160,17 +159,10 @@ 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` | 66+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)** -Example: - -```bash -opencli xianyu search "macbook" --limit 5 -opencli xianyu item 1040754408976 -opencli xianyu chat 1038951278192 3650092411 --text "Hi, is this still available?" -``` - ## CLI Hub OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install ` automatically before re-running the command). diff --git a/README.zh-CN.md b/README.zh-CN.md index 6de765d0..2c8627cc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -210,14 +210,6 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参 66+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)** -示例: - -```bash -opencli xianyu search "macbook" --limit 5 -opencli xianyu item 1040754408976 -opencli xianyu chat 1038951278192 3650092411 --text "你好,这个还在吗?" -``` - ### 外部 CLI 枢纽 OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、自动安装和纯透传执行。 diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 45da8876..4a84b3cf 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -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' }, ], }, { diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 5b5b4cc2..b000c9b8 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -50,6 +50,7 @@ Run `opencli list` for the live registry. | **[36kr](./browser/36kr)** | `news` `hot` `search` `article` | 🌐 / 🔐 | | **[producthunt](./browser/producthunt)** | `posts` `today` `hot` `browse` | 🌐 / 🔐 | | **[ones](./browser/ones)** | `login` `me` `token-info` `tasks` `my-tasks` `task` `worklog` `logout` | 🔐 Browser Bridge + `ONES_BASE_URL` | +| **[xianyu](./browser/xianyu)** | `search` `item` `chat` | 🔐 Browser | ## Public API Adapters diff --git a/src/clis/xianyu/chat.test.ts b/src/clis/xianyu/chat.test.ts index 9e950d80..480016b9 100644 --- a/src/clis/xianyu/chat.test.ts +++ b/src/clis/xianyu/chat.test.ts @@ -9,12 +9,12 @@ describe('xianyu chat helpers', () => { }); it('normalizes numeric ids', () => { - expect(__test__.normalizeId('1038951278192', 'item_id')).toBe('1038951278192'); - expect(__test__.normalizeId(3650092411, 'user_id')).toBe('3650092411'); + 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__.normalizeId('abc', 'item_id')).toThrow(); - expect(() => __test__.normalizeId('3650092411x', 'user_id')).toThrow(); + expect(() => __test__.normalizeNumericId('abc', 'item_id', '1038951278192')).toThrow(); + expect(() => __test__.normalizeNumericId('3650092411x', 'user_id', '3650092411')).toThrow(); }); }); diff --git a/src/clis/xianyu/chat.ts b/src/clis/xianyu/chat.ts index 3d2be147..90be602d 100644 --- a/src/clis/xianyu/chat.ts +++ b/src/clis/xianyu/chat.ts @@ -1,13 +1,6 @@ import { AuthRequiredError, SelectorError } from '../../errors.js'; import { cli, Strategy } from '../../registry.js'; - -function normalizeId(value: unknown, label: string): string { - const normalized = String(value || '').trim(); - if (!/^\d+$/.test(normalized)) { - throw new SelectorError(label, `${label} 必须是纯数字 ID`); - } - return normalized; -} +import { normalizeNumericId } from './utils.js'; function buildChatUrl(itemId: string, peerUserId: string): string { return `https://www.goofish.com/im?itemId=${encodeURIComponent(itemId)}&peerUserId=${encodeURIComponent(peerUserId)}`; @@ -26,6 +19,12 @@ function buildExtractChatStateEvaluate(): string { 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( @@ -34,7 +33,6 @@ function buildExtractChatStateEvaluate(): string { .filter(Boolean) .filter((text) => !['发送', '闲鱼号', '立即购买'].includes(text)) .filter((text) => !/^消息\\d*\\+?$/.test(text)) - .filter((text, index, arr) => arr.indexOf(text) === index) .slice(-20); return { @@ -42,7 +40,7 @@ function buildExtractChatStateEvaluate(): string { 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: '', + 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 || ''), @@ -91,6 +89,7 @@ cli({ description: '打开闲鱼聊一聊会话,并可选发送消息', domain: 'www.goofish.com', strategy: Strategy.COOKIE, + navigateBefore: false, browser: true, args: [ { name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' }, @@ -99,8 +98,8 @@ cli({ ], columns: ['status', 'peer_name', 'item_title', 'price', 'location', 'message'], func: async (page, kwargs) => { - const itemId = normalizeId(kwargs.item_id, 'item_id'); - const userId = normalizeId(kwargs.user_id, 'user_id'); + 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(); @@ -171,6 +170,6 @@ cli({ }); export const __test__ = { - normalizeId, + normalizeNumericId, buildChatUrl, }; diff --git a/src/clis/xianyu/item.test.ts b/src/clis/xianyu/item.test.ts index 5961de59..c613c7f6 100644 --- a/src/clis/xianyu/item.test.ts +++ b/src/clis/xianyu/item.test.ts @@ -3,8 +3,8 @@ import { __test__ } from './item.js'; describe('xianyu item helpers', () => { it('normalizes numeric item ids', () => { - expect(__test__.normalizeItemId('1040754408976')).toBe('1040754408976'); - expect(__test__.normalizeItemId(1040754408976)).toBe('1040754408976'); + expect(__test__.normalizeNumericId('1040754408976', 'item_id', '1040754408976')).toBe('1040754408976'); + expect(__test__.normalizeNumericId(1040754408976, 'item_id', '1040754408976')).toBe('1040754408976'); }); it('builds item urls', () => { @@ -14,6 +14,6 @@ describe('xianyu item helpers', () => { }); it('rejects invalid item ids', () => { - expect(() => __test__.normalizeItemId('abc')).toThrow(); + expect(() => __test__.normalizeNumericId('abc', 'item_id', '1040754408976')).toThrow(); }); }); diff --git a/src/clis/xianyu/item.ts b/src/clis/xianyu/item.ts index a27ce91b..4a6e4135 100644 --- a/src/clis/xianyu/item.ts +++ b/src/clis/xianyu/item.ts @@ -1,13 +1,6 @@ import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js'; import { cli, Strategy } from '../../registry.js'; - -function normalizeItemId(value: unknown): string { - const normalized = String(value || '').trim(); - if (!/^\d+$/.test(normalized)) { - throw new SelectorError('item_id', 'item_id 必须是纯数字 ID'); - } - return normalized; -} +import { normalizeNumericId } from './utils.js'; function buildItemUrl(itemId: string): string { return `https://www.goofish.com/item?id=${encodeURIComponent(itemId)}`; @@ -17,22 +10,55 @@ function buildFetchItemEvaluate(itemId: string): string { return ` (async () => { const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim(); + const extractRetCode = (ret) => { + const first = Array.isArray(ret) ? ret[0] : ''; + return clean(first).split('::')[0] || ''; + }; + + const waitFor = async (predicate, timeoutMs = 5000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return true; + await new Promise((r) => setTimeout(r, 150)); + } + return false; + }; + await waitFor(() => window.lib?.mtop?.request); if (!window.lib || !window.lib.mtop || typeof window.lib.mtop.request !== 'function') { return { error: 'mtop-not-ready' }; } - const response = await window.lib.mtop.request({ - api: 'mtop.taobao.idle.pc.detail', - data: { itemId: ${JSON.stringify(itemId)} }, - type: 'POST', - v: '1.0', - dataType: 'json', - needLogin: false, - needLoginPC: false, - sessionOption: 'AutoLoginOnly', - ecode: 0, - }); + let response; + try { + response = await window.lib.mtop.request({ + api: 'mtop.taobao.idle.pc.detail', + data: { itemId: ${JSON.stringify(itemId)} }, + type: 'POST', + v: '1.0', + dataType: 'json', + needLogin: false, + needLoginPC: false, + sessionOption: 'AutoLoginOnly', + ecode: 0, + }); + } catch (error) { + const ret = error?.ret || []; + return { + error: 'mtop-request-failed', + error_code: extractRetCode(ret), + error_message: clean(Array.isArray(ret) ? ret.join(' | ') : error?.message || error), + }; + } + + const retCode = extractRetCode(response?.ret || []); + if (retCode && retCode !== 'SUCCESS') { + return { + error: 'mtop-response-error', + error_code: retCode, + error_message: clean((response?.ret || []).join(' | ')), + }; + } const data = response?.data || {}; const item = data.itemDO || {}; @@ -77,19 +103,22 @@ cli({ description: '查看闲鱼商品详情', domain: 'www.goofish.com', strategy: Strategy.COOKIE, + navigateBefore: false, browser: true, args: [ { name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' }, ], columns: ['item_id', 'title', 'price', 'condition', 'brand', 'location', 'seller_name', 'want_count'], func: async (page, kwargs) => { - const itemId = normalizeItemId(kwargs.item_id); + const itemId = normalizeNumericId(kwargs.item_id, 'item_id', '1040754408976'); await page.goto(buildItemUrl(itemId)); await page.wait(2); const result = await page.evaluate(buildFetchItemEvaluate(itemId)) as { error?: string; + error_code?: string; + error_message?: string; title?: string; item_id?: string; } & Record; @@ -102,11 +131,16 @@ cli({ throw new EmptyResultError('xianyu item', '闲鱼商品详情接口未返回有效数据'); } - const message = String((result as any).error || ''); - if (/SESSION_EXPIRED|FAIL_SYS/.test(message)) { + const errorCode = String(result.error_code || ''); + const errorMessage = String(result.error_message || ''); + if (/FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED|FAIL_SYS/.test(errorCode) || /FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED/.test(errorMessage)) { throw new AuthRequiredError('www.goofish.com', 'Xianyu item detail requires a logged-in browser session'); } + if (result.error) { + throw new EmptyResultError('xianyu item', errorMessage || `Xianyu item detail request failed: ${result.error}`); + } + if (!String(result.title || '').trim()) { throw new EmptyResultError('xianyu item', 'No item detail was returned for the specified item_id'); } @@ -116,6 +150,6 @@ cli({ }); export const __test__ = { - normalizeItemId, + normalizeNumericId, buildItemUrl, }; diff --git a/src/clis/xianyu/search.ts b/src/clis/xianyu/search.ts index b9b9e156..692c2a4a 100644 --- a/src/clis/xianyu/search.ts +++ b/src/clis/xianyu/search.ts @@ -100,12 +100,13 @@ cli({ description: '搜索闲鱼商品', domain: 'www.goofish.com', strategy: Strategy.COOKIE, + navigateBefore: false, browser: true, args: [ { name: 'query', required: true, positional: true, help: 'Search keyword' }, { name: 'limit', type: 'int', default: 20, help: 'Number of results to return' }, ], - columns: ['rank', 'title', 'price', 'condition', 'brand', 'location', 'badge'], + columns: ['item_id', 'rank', 'title', 'price', 'condition', 'brand', 'location', 'badge', 'url'], func: async (page, kwargs) => { const query = String(kwargs.query || '').trim(); const limit = normalizeLimit(kwargs.limit); diff --git a/src/clis/xianyu/utils.ts b/src/clis/xianyu/utils.ts new file mode 100644 index 00000000..ff869aa6 --- /dev/null +++ b/src/clis/xianyu/utils.ts @@ -0,0 +1,9 @@ +import { ArgumentError } from '../../errors.js'; + +export function normalizeNumericId(value: unknown, label: string, example: string): string { + const normalized = String(value || '').trim(); + if (!/^\d+$/.test(normalized)) { + throw new ArgumentError(`${label} must be a numeric ID`, `Pass a numeric ${label}, for example: ${example}`); + } + return normalized; +}