/ clis / xianyu / chat.js
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  };