diff --git a/README.md b/README.md index c3aee64b..f408b353 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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)** diff --git a/README.zh-CN.md b/README.zh-CN.md index 0d7342b1..f64f869a 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 中打开目标网站并完成登录。如果获取到空数据或报错,请先检查你的浏览器登录状态。 @@ -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)** 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/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 diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 342e9f62..1db40e1a 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -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 diff --git a/src/clis/xianyu/chat.test.ts b/src/clis/xianyu/chat.test.ts new file mode 100644 index 00000000..480016b9 --- /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__.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(); + }); +}); diff --git a/src/clis/xianyu/chat.ts b/src/clis/xianyu/chat.ts new file mode 100644 index 00000000..90be602d --- /dev/null +++ b/src/clis/xianyu/chat.ts @@ -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, +}; diff --git a/src/clis/xianyu/item.test.ts b/src/clis/xianyu/item.test.ts new file mode 100644 index 00000000..c613c7f6 --- /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__.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(); + }); +}); diff --git a/src/clis/xianyu/item.ts b/src/clis/xianyu/item.ts new file mode 100644 index 00000000..4a6e4135 --- /dev/null +++ b/src/clis/xianyu/item.ts @@ -0,0 +1,155 @@ +import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import { normalizeNumericId } from './utils.js'; + +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(); + 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' }; + } + + 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 || {}; + 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, + 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 = 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; + + if (result?.error === 'mtop-not-ready') { + throw new SelectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口'); + } + + if (!result || typeof result !== 'object') { + throw new EmptyResultError('xianyu item', '闲鱼商品详情接口未返回有效数据'); + } + + 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'); + } + + return [result]; + }, +}); + +export const __test__ = { + normalizeNumericId, + 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..692c2a4a --- /dev/null +++ b/src/clis/xianyu/search.ts @@ -0,0 +1,151 @@ +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, + 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: ['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); + + 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, +}; 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; +}