feat(boss): add 8 new recruitment management commands (#133)

New commands:
- joblist: view my published jobs
- recommend: view recommended candidates (new greetings list)
- greet: send greeting to initiate chat with candidate
- mark: add/remove labels on candidates
- invite: send interview invitation
- stats: job statistics (chats count)
- batchgreet: batch greet recommended candidates
- exchange: request phone/wechat exchange

All commands tested and build passing (222 entries).
This commit is contained in:
云比云 authored and GitHub committed 2026-03-20 17:10:48 +08:00
1 parent 4600b9d46d
commit 7e973ca592
8 files changed
+1110

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
/**
* BOSS直聘 batchgreet — batch greet recommended candidates.
*
* Combines recommend (greetRecSortList) + greet (UI automation).
* Sends greeting messages to multiple candidates sequentially.
*/
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
cli({
site: 'boss',
name: 'batchgreet',
description: 'BOSS直聘批量向推荐候选人发送招呼',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'job_id', default: '', help: 'Filter by encrypted job ID (greet all jobs if empty)' },
{ name: 'limit', type: 'int', default: 5, help: 'Max candidates to greet' },
{ name: 'text', default: '', help: 'Custom greeting message (uses default if empty)' },
],
columns: ['name', 'status', 'detail'],
func: async (page: IPage | null, kwargs) => {
if (!page) throw new Error('Browser page required');
const filterJobId = kwargs.job_id || '';
const limit = kwargs.limit || 5;
const text = kwargs.text || '你好,请问您对这个职位感兴趣吗?';
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli:boss] Batch greeting up to ${limit} candidates...`);
}
await page.goto('https://www.zhipin.com/web/chat/index');
await page.wait({ time: 3 });
// Get recommended candidates
const listData: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList', 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 (listData.code !== 0) {
if (listData.code === 7 || listData.code === 37) {
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
}
throw new Error(`获取推荐列表失败: ${listData.message}`);
}
let candidates = listData.zpData?.friendList || [];
if (filterJobId) {
candidates = candidates.filter((f: any) => f.encryptJobId === filterJobId);
}
candidates = candidates.slice(0, limit);
if (candidates.length === 0) {
return [{ name: '-', status: '⚠️ 无候选人', detail: '当前没有待招呼的推荐候选人' }];
}
const results: any[] = [];
for (const candidate of candidates) {
const numericUid = candidate.uid;
const friendName = candidate.name || '候选人';
try {
// Click on candidate
const clicked: any = await page.evaluate(`
async () => {
const item = document.querySelector('#_${numericUid}-0') || document.querySelector('[id^="_${numericUid}"]');
if (item) {
item.click();
return { clicked: true };
}
const items = document.querySelectorAll('.geek-item');
for (const el of items) {
if (el.id && el.id.startsWith('_${numericUid}')) {
el.click();
return { clicked: true };
}
}
return { clicked: false };
}
`);
if (!clicked.clicked) {
results.push({ name: friendName, status: '❌ 跳过', detail: '在聊天列表中未找到' });
continue;
}
await page.wait({ time: 2 });
// Type message
const typed: any = await page.evaluate(`
async () => {
const selectors = [
'.chat-editor [contenteditable="true"]',
'.chat-input [contenteditable="true"]',
'[contenteditable="true"]',
'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 }));
} else {
el.textContent = '';
el.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
el.dispatchEvent(new Event('input', { bubbles: true }));
}
return { found: true };
}
}
return { found: false };
}
`);
if (!typed.found) {
results.push({ name: friendName, status: '❌ 失败', detail: '找不到消息输入框' });
continue;
}
await page.wait({ time: 0.5 });
// Click send
const sent: any = await page.evaluate(`
async () => {
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) {
await page.pressKey('Enter');
}
await page.wait({ time: 1.5 });
results.push({ name: friendName, status: '✅ 已发送', detail: text });
} catch (e: any) {
results.push({ name: friendName, status: '❌ 失败', detail: e.message?.substring(0, 80) || '未知错误' });
}
}
return results;
},
});
+126
View File
@@ -0,0 +1,126 @@
/**
* BOSS直聘 exchange — request phone/wechat exchange with a candidate.
*
* Uses POST /wapi/zpchat/exchange/request to send an exchange request.
*/
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
cli({
site: 'boss',
name: 'exchange',
description: 'BOSS直聘交换联系方式(请求手机/微信)',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'uid', required: true, help: 'Encrypted UID of the candidate' },
{ name: 'type', default: 'phone', choices: ['phone', 'wechat'], help: 'Exchange type: phone or wechat' },
],
columns: ['status', 'detail'],
func: async (page: IPage | null, kwargs) => {
if (!page) throw new Error('Browser page required');
const uid = kwargs.uid;
const exchangeType = kwargs.type || 'phone';
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli:boss] Requesting ${exchangeType} exchange for ${uid}...`);
}
await page.goto('https://www.zhipin.com/web/chat/index');
await page.wait({ time: 2 });
// Find candidate
let friend: any = null;
// Check greet list
const greetData: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList', 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 (greetData.code === 0) {
friend = (greetData.zpData?.friendList || []).find((f: any) => f.encryptUid === uid);
}
if (!friend) {
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) {
friend = (friendData.zpData?.friendList || []).find((f: any) => f.encryptUid === uid);
}
}
if (!friend) {
throw new Error('未找到该候选人');
}
const numericUid = friend.uid;
const friendName = friend.name || '候选人';
const securityId = friend.securityId || '';
// type mapping from JS source: 1=phone, 2=wechat, 4=resume
const typeId = exchangeType === 'wechat' ? 2 : 1;
// Params from JS: {type, securityId, uniqueId, name}
const params = new URLSearchParams({
type: String(typeId),
securityId: securityId,
uniqueId: String(numericUid),
name: friendName,
});
// POST with form-urlencoded (discovered from 336.js bundle)
const data: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://www.zhipin.com/wapi/zpchat/exchange/request', true);
xhr.withCredentials = true;
xhr.timeout = 15000;
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
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(${JSON.stringify(params.toString())});
});
}
`);
if (data.code !== 0) {
if (data.code === 7 || data.code === 37) {
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
}
throw new Error(`交换请求失败: ${data.message} (code=${data.code})`);
}
const typeLabel = exchangeType === 'wechat' ? '微信' : '手机号';
return [{
status: '✅ 交换请求已发送',
detail: `已向 ${friendName} 发送${typeLabel}交换请求`,
}];
},
});
+198
View File
@@ -0,0 +1,198 @@
/**
* BOSS直聘 greet — send greeting to a new candidate (initiate chat).
*
* This is different from send.ts which messages existing contacts.
* For new candidates (from recommend list), we navigate to their chat page
* and use UI automation to send the greeting message.
*
* The greetRecSortList provides candidates who have applied or been recommended.
* We click on them in the list and send a greeting.
*/
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
cli({
site: 'boss',
name: 'greet',
description: 'BOSS直聘向新候选人发送招呼(开始聊天)',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'uid', required: true, help: 'Encrypted UID of the candidate (from recommend)' },
{ name: 'security_id', required: true, help: 'Security ID of the candidate' },
{ name: 'job_id', required: true, help: 'Encrypted job ID' },
{ name: 'text', default: '', help: 'Custom greeting message (uses default template if empty)' },
],
columns: ['status', 'detail'],
func: async (page: IPage | null, kwargs) => {
if (!page) throw new Error('Browser page required');
const uid = kwargs.uid;
const securityId = kwargs.security_id;
const jobId = kwargs.job_id;
const text = kwargs.text;
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli:boss] Greeting candidate ${uid}...`);
}
// Navigate to chat page
await page.goto('https://www.zhipin.com/web/chat/index');
await page.wait({ time: 3 });
// Find the candidate in the greet list by encryptUid
const listData: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList', 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 (listData.code !== 0) {
if (listData.code === 7 || listData.code === 37) {
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
}
throw new Error(`获取候选人列表失败: ${listData.message}`);
}
// Also check the regular friend list
let target: any = null;
const greetList = listData.zpData?.friendList || [];
target = greetList.find((f: any) => f.encryptUid === uid);
let numericUid: string | null = null;
let friendName = '候选人';
if (target) {
numericUid = target.uid;
friendName = target.name || friendName;
}
if (!numericUid) {
// Try to find in friend list
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) {
const allFriends = friendData.zpData?.friendList || [];
const found = allFriends.find((f: any) => f.encryptUid === uid);
if (found) {
numericUid = found.uid;
friendName = found.name || friendName;
}
}
}
if (!numericUid) {
throw new Error('未找到该候选人,请确认 uid 是否正确(可从 recommend 命令获取)');
}
// Click on the candidate in the chat list
const clicked: any = await page.evaluate(`
async () => {
const item = document.querySelector('#_${numericUid}-0') || document.querySelector('[id^="_${numericUid}"]');
if (item) {
item.click();
return { clicked: true, id: item.id };
}
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('无法在聊天列表中找到该用户,候选人可能不在当前列表中');
}
await page.wait({ time: 2 });
// Type the message
const msgText = text || '你好,请问您对这个职位感兴趣吗?';
const typed: any = await page.evaluate(`
async () => {
const selectors = [
'.chat-editor [contenteditable="true"]',
'.chat-input [contenteditable="true"]',
'.message-editor [contenteditable="true"]',
'.chat-conversation [contenteditable="true"]',
'[contenteditable="true"]',
'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(msgText)};
el.dispatchEvent(new Event('input', { bubbles: true }));
} else {
el.textContent = '';
el.focus();
document.execCommand('insertText', false, ${JSON.stringify(msgText)});
el.dispatchEvent(new Event('input', { bubbles: true }));
}
return { found: true, selector: sel };
}
}
return { found: false };
}
`);
if (!typed.found) {
throw new Error('找不到消息输入框');
}
await page.wait({ time: 0.5 });
// Click send button
const sent: any = await page.evaluate(`
async () => {
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) {
await page.pressKey('Enter');
}
await page.wait({ time: 1 });
return [{ status: '✅ 招呼已发送', detail: `已向 ${friendName} 发送: ${msgText}` }];
},
});
+177
View File
@@ -0,0 +1,177 @@
/**
* BOSS直聘 invite — send interview invitation to a candidate.
*
* Uses POST /wapi/zpinterview/boss/interview/invite to send interview invitations.
* Address and contact info come from the boss's saved settings.
*/
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
cli({
site: 'boss',
name: 'invite',
description: 'BOSS直聘发送面试邀请',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'uid', required: true, help: 'Encrypted UID of the candidate' },
{ name: 'time', required: true, help: 'Interview time (e.g. 2025-04-01 14:00)' },
{ name: 'address', default: '', help: 'Interview address (uses saved address if empty)' },
{ name: 'contact', default: '', help: 'Contact person name (uses saved contact if empty)' },
],
columns: ['status', 'detail'],
func: async (page: IPage | null, kwargs) => {
if (!page) throw new Error('Browser page required');
const uid = kwargs.uid;
const timeStr = kwargs.time;
const address = kwargs.address;
const contact = kwargs.contact;
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli:boss] Sending interview invitation to ${uid}...`);
}
await page.goto('https://www.zhipin.com/web/chat/index');
await page.wait({ time: 2 });
// Get candidate info
let friend: any = null;
// Check greet list first
const greetData: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList', 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 (greetData.code === 0) {
friend = (greetData.zpData?.friendList || []).find((f: any) => f.encryptUid === uid);
}
if (!friend) {
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) {
friend = (friendData.zpData?.friendList || []).find((f: any) => f.encryptUid === uid);
}
}
if (!friend) {
throw new Error('未找到该候选人');
}
const numericUid = friend.uid;
const friendName = friend.name || '候选人';
const securityId = friend.securityId || '';
const encJobId = friend.encryptJobId || '';
// Get saved contact info
const contactData: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.zhipin.com/wapi/zpinterview/boss/interview/contactInit', true);
xhr.withCredentials = true;
xhr.timeout = 10000;
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();
});
}
`);
const contactId = contactData.zpData?.contactId || '';
const contactName = contact || contactData.zpData?.contactName || '';
const contactPhone = contactData.zpData?.contactPhone || '';
// Get saved address
const addressData: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.zhipin.com/wapi/zpinterview/boss/interview/listAddress', true);
xhr.withCredentials = true;
xhr.timeout = 10000;
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();
});
}
`);
const savedAddress = addressData.zpData?.list?.[0] || {};
const addressText = address || savedAddress.cityAddressText || savedAddress.addressText || '';
// Parse interview time
const interviewTime = new Date(timeStr).getTime();
if (isNaN(interviewTime)) {
throw new Error(`时间格式错误: ${timeStr},请使用格式如 2025-04-01 14:00`);
}
// Send interview invitation
const params = new URLSearchParams({
uid: String(numericUid),
securityId: securityId,
encryptJobId: encJobId,
interviewTime: String(interviewTime),
contactId: contactId,
contactName: contactName,
contactPhone: contactPhone,
address: addressText,
interviewType: '1', // 1 = onsite
});
const data: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://www.zhipin.com/wapi/zpinterview/boss/interview/invite.json', true);
xhr.withCredentials = true;
xhr.timeout = 15000;
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
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(${JSON.stringify(params.toString())});
});
}
`);
if (data.code !== 0) {
if (data.code === 7 || data.code === 37) {
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
}
throw new Error(`面试邀请发送失败: ${data.message} (code=${data.code})`);
}
return [{
status: '✅ 面试邀请已发送',
detail: `已向 ${friendName} 发送面试邀请\n时间: ${timeStr}\n地点: ${addressText}\n联系人: ${contactName}`,
}];
},
});
+63
View File
@@ -0,0 +1,63 @@
/**
* BOSS直聘 job list — list my published jobs via boss API.
*
* Uses /wapi/zpjob/job/chatted/jobList to get job list with status info.
*/
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
cli({
site: 'boss',
name: 'joblist',
description: 'BOSS直聘查看我发布的职位列表',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [],
columns: ['job_name', 'salary', 'city', 'status', 'encrypt_job_id'],
func: async (page: IPage | null, kwargs) => {
if (!page) throw new Error('Browser page required');
if (process.env.OPENCLI_VERBOSE) {
console.error('[opencli:boss] Fetching job list...');
}
await page.goto('https://www.zhipin.com/web/chat/index');
await page.wait({ time: 2 });
const targetUrl = 'https://www.zhipin.com/wapi/zpjob/job/chatted/jobList';
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.ontimeout = () => reject(new Error('Timeout'));
xhr.send();
});
}
`);
if (data.code !== 0) {
if (data.code === 7 || data.code === 37) {
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
}
throw new Error(`API error: ${data.message} (code=${data.code})`);
}
const jobs = data.zpData || [];
return jobs.map((j: any) => ({
job_name: j.jobName || '',
salary: j.salaryDesc || '',
city: j.address || '',
status: j.jobOnlineStatus === 1 ? '在线' : '已关闭',
encrypt_job_id: j.encryptJobId || '',
}));
},
});
+155
View File
@@ -0,0 +1,155 @@
/**
* BOSS直聘 mark — label/mark a candidate.
*
* Uses /wapi/zprelation/friend/label/addMark to add a label to a candidate,
* and /wapi/zprelation/friend/label/deleteMark to remove one.
*
* Available labels (from /wapi/zprelation/friend/label/get):
* 1=新招呼, 2=沟通中, 3=已约面, 4=已获取简历, 5=已交换电话,
* 6=已交换微信, 7=不合适, 8=牛人发起, 11=收藏
*/
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
const LABEL_MAP: Record<string, number> = {
'新招呼': 1, '沟通中': 2, '已约面': 3, '已获取简历': 4,
'已交换电话': 5, '已交换微信': 6, '不合适': 7, '牛人发起': 8, '收藏': 11,
};
cli({
site: 'boss',
name: 'mark',
description: 'BOSS直聘给候选人添加标签',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'uid', required: true, help: 'Encrypted UID of the candidate' },
{ name: 'label', required: true, help: 'Label name (新招呼/沟通中/已约面/已获取简历/已交换电话/已交换微信/不合适/收藏) or label ID' },
{ name: 'remove', type: 'boolean', default: false, help: 'Remove the label instead of adding' },
],
columns: ['status', 'detail'],
func: async (page: IPage | null, kwargs) => {
if (!page) throw new Error('Browser page required');
const uid = kwargs.uid;
const labelInput = kwargs.label;
const remove = kwargs.remove || false;
// Resolve label to ID
let labelId: number;
if (LABEL_MAP[labelInput]) {
labelId = LABEL_MAP[labelInput];
} else if (!isNaN(Number(labelInput))) {
labelId = Number(labelInput);
} else {
// Try partial match
const entry = Object.entries(LABEL_MAP).find(([k]) => k.includes(labelInput));
if (entry) {
labelId = entry[1];
} else {
throw new Error(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
}
}
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli:boss] ${remove ? 'Removing' : 'Adding'} label ${labelId} for ${uid}...`);
}
await page.goto('https://www.zhipin.com/web/chat/index');
await page.wait({ time: 2 });
// First get numeric UID from friend list
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}`);
}
// Find in friend list (check multiple pages)
let friend: any = null;
let allFriends = friendData.zpData?.friendList || [];
friend = allFriends.find((f: any) => f.encryptUid === uid);
if (!friend) {
// Also check greetRecSortList
const greetData: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList', 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 (greetData.code === 0) {
friend = (greetData.zpData?.friendList || []).find((f: any) => f.encryptUid === uid);
}
}
if (!friend) {
throw new Error('未找到该候选人');
}
const numericUid = friend.uid;
const friendName = friend.name || '候选人';
const friendSource = friend.friendSource ?? 0;
const action = remove ? 'deleteMark' : 'addMark';
const targetUrl = `https://www.zhipin.com/wapi/zprelation/friend/label/${action}`;
// The API uses friendId + friendSource + labelId (discovered from JS bundles)
const params = new URLSearchParams({
friendId: String(numericUid),
friendSource: String(friendSource),
labelId: String(labelId),
});
// Try GET first (the N() wrapper in boss JS uses GET with query params)
const data: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', '${targetUrl}?${params.toString()}', 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(`标签操作失败: ${data.message} (code=${data.code})`);
}
const labelName = Object.entries(LABEL_MAP).find(([, v]) => v === labelId)?.[0] || String(labelId);
return [{
status: remove ? '✅ 标签已移除' : '✅ 标签已添加',
detail: `${friendName}: ${remove ? '移除' : '添加'}标签「${labelName}」`,
}];
},
});
+94
View File
@@ -0,0 +1,94 @@
/**
* BOSS直聘 recommend — view recommended candidates (新招呼/greet sort list).
*
* Uses /wapi/zprelation/friend/greetRecSortList to get system-recommended candidates.
* These are candidates who have greeted or been recommended by the system.
*/
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
cli({
site: 'boss',
name: 'recommend',
description: 'BOSS直聘查看推荐候选人(新招呼列表)',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of results to return' },
],
columns: ['name', 'job_name', 'last_time', 'labels', 'encrypt_uid', 'security_id', 'encrypt_job_id'],
func: async (page: IPage | null, kwargs) => {
if (!page) throw new Error('Browser page required');
const limit = kwargs.limit || 20;
if (process.env.OPENCLI_VERBOSE) {
console.error('[opencli:boss] Fetching recommended candidates...');
}
await page.goto('https://www.zhipin.com/web/chat/index');
await page.wait({ time: 2 });
// Get label definitions for mapping
const labelData: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/label/get', true);
xhr.withCredentials = true;
xhr.timeout = 10000;
xhr.setRequestHeader('Accept', 'application/json');
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { resolve({}); } };
xhr.onerror = () => resolve({});
xhr.send();
});
}
`);
const labelMap: Record<number, string> = {};
if (labelData.code === 0 && labelData.zpData?.labels) {
for (const l of labelData.zpData.labels) {
labelMap[l.labelId] = l.label;
}
}
// Get recommended candidates
const targetUrl = 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList';
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.ontimeout = () => reject(new Error('Timeout'));
xhr.send();
});
}
`);
if (data.code !== 0) {
if (data.code === 7 || data.code === 37) {
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
}
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_name: f.jobName || '',
last_time: f.lastTime || '',
labels: (f.relationLabelList || []).map((id: number) => labelMap[id] || String(id)).join(', '),
encrypt_uid: f.encryptUid || '',
security_id: f.securityId || '',
encrypt_job_id: f.encryptJobId || '',
}));
},
});
+130
View File
@@ -0,0 +1,130 @@
/**
* BOSS直聘 stats — job statistics overview.
*
* Uses /wapi/zpchat/chatHelper/statistics for total friend count,
* and /wapi/zpjob/job/chatted/jobList for per-job info.
* Since BOSS doesn't expose detailed per-job stats via API,
* we show what's available: job status, chat info, and total stats.
*/
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
cli({
site: 'boss',
name: 'stats',
description: 'BOSS直聘职位数据统计',
domain: 'www.zhipin.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'job_id', default: '', help: 'Encrypted job ID (show all if empty)' },
],
columns: ['job_name', 'salary', 'city', 'status', 'total_chats', 'encrypt_job_id'],
func: async (page: IPage | null, kwargs) => {
if (!page) throw new Error('Browser page required');
const filterJobId = kwargs.job_id || '';
if (process.env.OPENCLI_VERBOSE) {
console.error('[opencli:boss] Fetching job statistics...');
}
await page.goto('https://www.zhipin.com/web/chat/index');
await page.wait({ time: 2 });
// Get job list
const jobData: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.zhipin.com/wapi/zpjob/job/chatted/jobList', 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.ontimeout = () => reject(new Error('Timeout'));
xhr.send();
});
}
`);
if (jobData.code !== 0) {
if (jobData.code === 7 || jobData.code === 37) {
throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
}
throw new Error(`API error: ${jobData.message} (code=${jobData.code})`);
}
// Get total chat stats
const chatStats: any = await page.evaluate(`
async () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://www.zhipin.com/wapi/zpchat/chatHelper/statistics', true);
xhr.withCredentials = true;
xhr.timeout = 10000;
xhr.setRequestHeader('Accept', 'application/json');
xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { resolve({}); } };
xhr.onerror = () => resolve({});
xhr.send();
});
}
`);
const totalFriends = chatStats.zpData?.totalFriendCount || 0;
// Get per-job chat counts from friend list
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) { resolve({}); } };
xhr.onerror = () => resolve({});
xhr.send();
});
}
`);
// Count chats per job
const jobChatCounts: Record<string, number> = {};
if (friendData.code === 0) {
for (const f of (friendData.zpData?.friendList || [])) {
const jobName = f.jobName || 'unknown';
jobChatCounts[jobName] = (jobChatCounts[jobName] || 0) + 1;
}
}
let jobs = jobData.zpData || [];
if (filterJobId) {
jobs = jobs.filter((j: any) => j.encryptJobId === filterJobId);
}
const results = jobs.map((j: any) => ({
job_name: j.jobName || '',
salary: j.salaryDesc || '',
city: j.address || '',
status: j.jobOnlineStatus === 1 ? '在线' : '已关闭',
total_chats: String(jobChatCounts[j.jobName] || 0),
encrypt_job_id: j.encryptJobId || '',
}));
// Add summary row
if (!filterJobId && results.length > 0) {
results.push({
job_name: '--- 总计 ---',
salary: '',
city: '',
status: `${jobs.length} 个职位`,
total_chats: String(totalFriends),
encrypt_job_id: '',
});
}
return results;
},
});