feat(boss): add chatlist, chatmsg, and send commands (#95)

- boss/chatlist: List chat conversations (招聘端聊天列表)
  Uses getBossFriendListV2 API with pagination and job filter support.

- boss/chatmsg: Read chat message history with a candidate
  Resolves encryptUid to numeric uid/securityId, fetches via historyMsg API.

- boss/send: Send chat message to a candidate via UI automation
  BOSS chat uses MQTT protocol (not HTTP), so this command automates the
  web chat UI: clicks on user in list → types in contenteditable editor →
  clicks the send button.

All three commands use Strategy.COOKIE and require an active BOSS直聘
login session in Chrome.
This commit is contained in:
Wing Huang authored and GitHub committed 2026-03-20 10:40:20 +08:00
1 parent 67474bb6db
commit f8dea7d8fc
3 files changed
+313

No files matched your search

+50
View File
@@ -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 || '',
}));
},
});
+70
View File
@@ -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<number, string> = {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') : '',
};
});
},
});
+193
View File
@@ -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}` }];
},
});