diff --git a/src/clis/boss/chatlist.ts b/src/clis/boss/chatlist.ts new file mode 100644 index 0000000..c475541 --- /dev/null +++ b/src/clis/boss/chatlist.ts @@ -0,0 +1,50 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +cli({ + site: 'boss', + name: 'chatlist', + description: 'BOSS直聘查看聊天列表(招聘端)', + domain: 'www.zhipin.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'page', type: 'int', default: 1, help: 'Page number' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, + { name: 'job_id', default: '0', help: 'Filter by job ID (0=all)' }, + ], + columns: ['name', 'job', 'last_msg', 'last_time', 'uid', 'security_id'], + func: async (page: IPage | null, kwargs) => { + if (!page) throw new Error('Browser page required'); + await page.goto('https://www.zhipin.com/web/chat/index'); + await page.wait({ time: 2 }); + const jobId = kwargs.job_id || '0'; + const pageNum = kwargs.page || 1; + const limit = kwargs.limit || 20; + const targetUrl = `https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=${pageNum}&status=0&jobId=${jobId}`; + const data: any = await page.evaluate(` + async () => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '${targetUrl}', true); + xhr.withCredentials = true; + xhr.timeout = 15000; + xhr.setRequestHeader('Accept', 'application/json'); + xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(new Error('JSON parse failed')); } }; + xhr.onerror = () => reject(new Error('Network Error')); + xhr.send(); + }); + } + `); + if (data.code !== 0) throw new Error(`API error: ${data.message} (code=${data.code})`); + const friends = (data.zpData?.friendList || []).slice(0, limit); + return friends.map((f: any) => ({ + name: f.name || '', + job: f.jobName || '', + last_msg: f.lastMessageInfo?.text || '', + last_time: f.lastTime || '', + uid: f.encryptUid || '', + security_id: f.securityId || '', + })); + }, +}); diff --git a/src/clis/boss/chatmsg.ts b/src/clis/boss/chatmsg.ts new file mode 100644 index 0000000..cd431f5 --- /dev/null +++ b/src/clis/boss/chatmsg.ts @@ -0,0 +1,70 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +cli({ + site: 'boss', + name: 'chatmsg', + description: 'BOSS直聘查看与候选人的聊天消息', + domain: 'www.zhipin.com', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'uid', required: true, help: 'Encrypted UID (from chatlist)' }, + { name: 'page', type: 'int', default: 1, help: 'Page number' }, + ], + columns: ['from', 'type', 'text', 'time'], + func: async (page: IPage | null, kwargs) => { + if (!page) throw new Error('Browser page required'); + await page.goto('https://www.zhipin.com/web/chat/index'); + await page.wait({ time: 2 }); + const uid = kwargs.uid; + const friendData: any = await page.evaluate(` + async () => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true); + xhr.withCredentials = true; + xhr.timeout = 15000; + xhr.setRequestHeader('Accept', 'application/json'); + xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } }; + xhr.onerror = () => reject(new Error('Network Error')); + xhr.send(); + }); + } + `); + if (friendData.code !== 0) throw new Error('获取好友列表失败'); + const friend = (friendData.zpData?.friendList || []).find((f: any) => f.encryptUid === uid); + if (!friend) throw new Error('未找到该候选人'); + const gid = friend.uid; + const securityId = encodeURIComponent(friend.securityId); + const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`; + const msgData: any = await page.evaluate(` + async () => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '${msgUrl}', true); + xhr.withCredentials = true; + xhr.timeout = 15000; + xhr.setRequestHeader('Accept', 'application/json'); + xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { resolve({raw: xhr.responseText.substring(0,500)}); } }; + xhr.onerror = () => reject(new Error('Network Error')); + xhr.send(); + }); + } + `); + if (msgData.raw) throw new Error('Non-JSON: ' + msgData.raw); + if (msgData.code !== 0) throw new Error('API error: ' + (msgData.message || msgData.code)); + const TYPE_MAP: Record = {1: '文本', 2: '图片', 3: '招呼', 4: '简历', 5: '系统', 6: '名片', 7: '语音', 8: '视频', 9: '表情'}; + const messages = msgData.zpData?.messages || msgData.zpData?.historyMsgList || []; + return messages.map((m: any) => { + const fromObj = m.from || {}; + const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false; + return { + from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name), + type: TYPE_MAP[m.type] || '其他(' + m.type + ')', + text: m.text || m.body?.text || '', + time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '', + }; + }); + }, +}); diff --git a/src/clis/boss/send.ts b/src/clis/boss/send.ts new file mode 100644 index 0000000..de28cfb --- /dev/null +++ b/src/clis/boss/send.ts @@ -0,0 +1,193 @@ +/** + * BOSS直聘 send message — via UI automation on chat page. + * + * Flow: navigate to chat → click on user in list → type in editor → send. + * BOSS chat uses MQTT (not HTTP) for messaging, so we must go through the UI. + */ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +cli({ + site: 'boss', + name: 'send', + description: 'BOSS直聘发送聊天消息', + domain: 'www.zhipin.com', + strategy: Strategy.COOKIE, + + browser: true, + args: [ + { name: 'uid', required: true, help: 'Encrypted UID of the candidate (from chatlist)' }, + { name: 'text', required: true, help: 'Message text to send' }, + ], + columns: ['status', 'detail'], + func: async (page: IPage | null, kwargs) => { + if (!page) throw new Error('Browser page required'); + + const uid = kwargs.uid; + const text = kwargs.text; + + // Step 1: Navigate to chat page + await page.goto('https://www.zhipin.com/web/chat/index'); + await page.wait({ time: 3 }); + + // Step 2: Find friend in list to get their numeric uid, then click + const friendData: any = await page.evaluate(` + async () => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true); + xhr.withCredentials = true; + xhr.timeout = 15000; + xhr.setRequestHeader('Accept', 'application/json'); + xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } }; + xhr.onerror = () => reject(new Error('Network Error')); + xhr.send(); + }); + } + `); + + if (friendData.code !== 0) { + if (friendData.code === 7 || friendData.code === 37) { + throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。'); + } + throw new Error('获取好友列表失败: ' + (friendData.message || friendData.code)); + } + + let target: any = null; + const allFriends = friendData.zpData?.friendList || []; + target = allFriends.find((f: any) => f.encryptUid === uid); + + if (!target) { + for (let p = 2; p <= 5; p++) { + const moreUrl = `https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=${p}&status=0&jobId=0`; + const moreData: any = await page.evaluate(` + async () => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '${moreUrl}', true); + xhr.withCredentials = true; + xhr.timeout = 15000; + xhr.setRequestHeader('Accept', 'application/json'); + xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } }; + xhr.onerror = () => reject(new Error('Network Error')); + xhr.send(); + }); + } + `); + if (moreData.code === 0) { + const list = moreData.zpData?.friendList || []; + target = list.find((f: any) => f.encryptUid === uid); + if (target) break; + if (list.length === 0) break; + } + } + } + + if (!target) throw new Error('未找到该候选人,请确认 uid 是否正确'); + + const numericUid = target.uid; + const friendName = target.name || '候选人'; + + // Step 3: Click on the user in the chat list to open conversation + const clicked: any = await page.evaluate(` + async () => { + // The geek-item has id like _748787762-0 + const item = document.querySelector('#_${numericUid}-0') || document.querySelector('[id^="_${numericUid}"]'); + if (item) { + item.click(); + return { clicked: true, id: item.id }; + } + // Fallback: try clicking by iterating geek items + const items = document.querySelectorAll('.geek-item'); + for (const el of items) { + if (el.id && el.id.startsWith('_${numericUid}')) { + el.click(); + return { clicked: true, id: el.id }; + } + } + return { clicked: false }; + } + `); + + if (!clicked.clicked) { + throw new Error('无法在聊天列表中找到该用户,请确认聊天列表中有此人'); + } + + // Step 4: Wait for the conversation to load and input area to appear + await page.wait({ time: 2 }); + + // Step 5: Find the message editor and type + const typed: any = await page.evaluate(` + async () => { + // Look for the chat editor - BOSS uses contenteditable div or textarea + const selectors = [ + '.chat-editor [contenteditable="true"]', + '.chat-input [contenteditable="true"]', + '.message-editor [contenteditable="true"]', + '.chat-conversation [contenteditable="true"]', + '[contenteditable="true"]', + '.chat-editor textarea', + '.chat-input textarea', + 'textarea', + ]; + + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el && el.offsetParent !== null) { + el.focus(); + + if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { + el.value = ${JSON.stringify(text)}; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } else { + // contenteditable + el.textContent = ''; + el.focus(); + document.execCommand('insertText', false, ${JSON.stringify(text)}); + el.dispatchEvent(new Event('input', { bubbles: true })); + } + + return { found: true, selector: sel, tag: el.tagName }; + } + } + + // Debug: list all visible elements in chat-conversation + const conv = document.querySelector('.chat-conversation'); + const allEls = conv ? Array.from(conv.querySelectorAll('*')).filter(e => e.offsetParent !== null).map(e => e.tagName + '.' + (e.className?.substring?.(0, 50) || '')).slice(0, 30) : []; + + return { found: false, visibleElements: allEls }; + } + `); + + if (!typed.found) { + throw new Error('找不到消息输入框。可能的元素: ' + JSON.stringify(typed.visibleElements || [])); + } + + await page.wait({ time: 0.5 }); + + // Step 6: Click the send button (Enter key doesn't trigger send on BOSS) + const sent: any = await page.evaluate(` + async () => { + // The send button is .submit inside .submit-content + const btn = document.querySelector('.conversation-editor .submit') + || document.querySelector('.submit-content .submit') + || document.querySelector('.conversation-operate .submit'); + if (btn) { + btn.click(); + return { clicked: true }; + } + return { clicked: false }; + } + `); + + if (!sent.clicked) { + // Fallback: try Enter key + await page.pressKey('Enter'); + } + + await page.wait({ time: 1 }); + + return [{ status: '✅ 发送成功', detail: `已向 ${friendName} 发送: ${text}` }]; + }, +});