From 7e973ca59270029f33021a483ca4974dc3975d36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E6=AF=94=E4=BA=91?= Date: Fri, 20 Mar 2026 17:10:48 +0800 Subject: [PATCH] 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). --- src/clis/boss/batchgreet.ts | 167 ++++++++++++++++++++++++++++++ src/clis/boss/exchange.ts | 126 +++++++++++++++++++++++ src/clis/boss/greet.ts | 198 ++++++++++++++++++++++++++++++++++++ src/clis/boss/invite.ts | 177 ++++++++++++++++++++++++++++++++ src/clis/boss/joblist.ts | 63 ++++++++++++ src/clis/boss/mark.ts | 155 ++++++++++++++++++++++++++++ src/clis/boss/recommend.ts | 94 +++++++++++++++++ src/clis/boss/stats.ts | 130 +++++++++++++++++++++++ 8 files changed, 1110 insertions(+) create mode 100644 src/clis/boss/batchgreet.ts create mode 100644 src/clis/boss/exchange.ts create mode 100644 src/clis/boss/greet.ts create mode 100644 src/clis/boss/invite.ts create mode 100644 src/clis/boss/joblist.ts create mode 100644 src/clis/boss/mark.ts create mode 100644 src/clis/boss/recommend.ts create mode 100644 src/clis/boss/stats.ts diff --git a/src/clis/boss/batchgreet.ts b/src/clis/boss/batchgreet.ts new file mode 100644 index 0000000..42a09ff --- /dev/null +++ b/src/clis/boss/batchgreet.ts @@ -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; + }, +}); diff --git a/src/clis/boss/exchange.ts b/src/clis/boss/exchange.ts new file mode 100644 index 0000000..cc4686d --- /dev/null +++ b/src/clis/boss/exchange.ts @@ -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}交换请求`, + }]; + }, +}); diff --git a/src/clis/boss/greet.ts b/src/clis/boss/greet.ts new file mode 100644 index 0000000..7d155ae --- /dev/null +++ b/src/clis/boss/greet.ts @@ -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}` }]; + }, +}); diff --git a/src/clis/boss/invite.ts b/src/clis/boss/invite.ts new file mode 100644 index 0000000..cc3dad8 --- /dev/null +++ b/src/clis/boss/invite.ts @@ -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}`, + }]; + }, +}); diff --git a/src/clis/boss/joblist.ts b/src/clis/boss/joblist.ts new file mode 100644 index 0000000..467d9a3 --- /dev/null +++ b/src/clis/boss/joblist.ts @@ -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 || '', + })); + }, +}); diff --git a/src/clis/boss/mark.ts b/src/clis/boss/mark.ts new file mode 100644 index 0000000..7545c23 --- /dev/null +++ b/src/clis/boss/mark.ts @@ -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 = { + '新招呼': 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}」`, + }]; + }, +}); diff --git a/src/clis/boss/recommend.ts b/src/clis/boss/recommend.ts new file mode 100644 index 0000000..0230ed6 --- /dev/null +++ b/src/clis/boss/recommend.ts @@ -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 = {}; + 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 || '', + })); + }, +}); diff --git a/src/clis/boss/stats.ts b/src/clis/boss/stats.ts new file mode 100644 index 0000000..a8c57ef --- /dev/null +++ b/src/clis/boss/stats.ts @@ -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 = {}; + 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; + }, +});