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