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