diff --git a/cli-manifest.json b/cli-manifest.json index 3ec013b26..b4748de25 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -3440,6 +3440,39 @@ "modulePath": "bluesky/user.js", "sourceFile": "bluesky/user.js" }, + { + "site": "boss", + "name": "ask-resume", + "description": "BOSS直聘请求候选人分享附件简历(招聘端)", + "access": "write", + "domain": "www.zhipin.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "uid", + "type": "str", + "required": true, + "positional": true, + "help": "Encrypted UID of the candidate (from chatlist)" + }, + { + "name": "job-id", + "type": "str", + "default": "", + "required": false, + "help": "Encrypted job id (optional; falls back to friend.encryptJobId)" + } + ], + "columns": [ + "status", + "detail" + ], + "type": "js", + "modulePath": "boss/ask-resume.js", + "sourceFile": "boss/ask-resume.js", + "navigateBefore": false + }, { "site": "boss", "name": "batchgreet", diff --git a/clis/boss/ask-resume.js b/clis/boss/ask-resume.js new file mode 100644 index 000000000..f0ed6542a --- /dev/null +++ b/clis/boss/ask-resume.js @@ -0,0 +1,62 @@ +/** + * BOSS直聘 ask-resume — request a candidate to share their attachment resume. + * + * Backed by the same /wapi/zpchat/exchange/request endpoint that exchange.js + * already uses for phone (type=1) and wechat (type=2). This command issues + * type=3, which the BOSS web UI surfaces as the "求简历" button. + * + * Field schema notes (#1068): + * - OpenCLI exchange.js convention: type + securityId + uniqueId + name + * - boss-agent-cli (Python) recruiter_client.exchange_request convention: + * type + uid + jobId + gid + * - To stay forward compatible with both observations, this command sends + * the union of both schemas. Extra fields are ignored by the BOSS server + * for type=1/2 today; type=3 has been observed to require jobId on the + * Python side, so we keep it explicit here. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { requirePage, navigateToChat, bossFetch, findFriendByUid, verbose } from './utils.js'; +cli({ + site: 'boss', + name: 'ask-resume', + access: 'write', + description: 'BOSS直聘请求候选人分享附件简历(招聘端)', + domain: 'www.zhipin.com', + strategy: Strategy.COOKIE, + navigateBefore: false, + browser: true, + args: [ + { name: 'uid', positional: true, required: true, help: 'Encrypted UID of the candidate (from chatlist)' }, + { name: 'job-id', default: '', help: 'Encrypted job id (optional; falls back to friend.encryptJobId)' }, + ], + columns: ['status', 'detail'], + func: async (page, kwargs) => { + requirePage(page); + verbose(`Requesting resume for ${kwargs.uid}...`); + await navigateToChat(page); + const friend = await findFriendByUid(page, kwargs.uid, { checkGreetList: true }); + if (!friend) + throw new Error('未找到该候选人,请确认 uid 是否正确(可从 chatlist / recommend 命令获取)'); + const friendName = friend.name || '候选人'; + const jobId = kwargs['job-id'] || friend.encryptJobId || ''; + const params = new URLSearchParams({ + type: '3', + // OpenCLI exchange.js convention + securityId: friend.securityId || '', + uniqueId: String(friend.uid), + name: friendName, + // boss-agent-cli recruiter_client convention (verified for type=3) + uid: String(friend.uid), + jobId, + gid: String(friend.uid), + }); + await bossFetch(page, 'https://www.zhipin.com/wapi/zpchat/exchange/request', { + method: 'POST', + body: params.toString(), + }); + return [{ + status: '✅ 简历请求已发送', + detail: `已向 ${friendName} 请求附件简历${jobId ? `(关联职位 ${jobId})` : ''}`, + }]; + }, +}); diff --git a/clis/boss/ask-resume.test.js b/clis/boss/ask-resume.test.js new file mode 100644 index 000000000..2894542ca --- /dev/null +++ b/clis/boss/ask-resume.test.js @@ -0,0 +1,99 @@ +/** + * Tests for clis/boss/ask-resume.js + * + * Mock pattern follows clis/boss/search.test.js: mock page.evaluate to return + * canned BOSS responses, then assert against the XHR scripts that bossFetch + * generates. + */ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './ask-resume.js'; +function createPageMock(responses) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation(async () => { + const next = responses.shift(); + if (next === undefined) + throw new Error('page.evaluate called more times than mocked'); + return next; + }), + }; +} +describe('boss ask-resume', () => { + const command = getRegistry().get('boss/ask-resume'); + const friend = { + uid: 1234567, + encryptUid: 'enc-uid-aaa', + securityId: 'sec-aaa', + encryptJobId: 'enc-job-bbb', + name: '测试候选人', + }; + function ok(zpData) { + return { code: 0, zpData }; + } + it('registers under the boss/ask-resume key', () => { + expect(command).toBeDefined(); + expect(command?.site).toBe('boss'); + expect(command?.name).toBe('ask-resume'); + expect(command?.access).toBe('write'); + }); + it('issues an exchange/request POST with type=3 for the located friend', async () => { + const page = createPageMock([ + // first bossFetch: friend list page 1 + ok({ friendList: [friend] }), + // second bossFetch: the exchange/request POST itself + ok({}), + ]); + const rows = await command.func(page, { uid: friend.encryptUid }); + // The XHR script for the POST is the LAST page.evaluate call. + const postScript = page.evaluate.mock.calls.at(-1)[0]; + expect(postScript).toContain('"POST"'); + expect(postScript).toContain('https://www.zhipin.com/wapi/zpchat/exchange/request'); + expect(postScript).toContain('type=3'); + // Both schema conventions are present (defensive union). + expect(postScript).toContain(`uniqueId=${friend.uid}`); + expect(postScript).toContain(`uid=${friend.uid}`); + expect(postScript).toContain(`gid=${friend.uid}`); + expect(postScript).toContain(`securityId=${friend.securityId}`); + // jobId falls back to friend.encryptJobId when not supplied. + expect(postScript).toContain(`jobId=${friend.encryptJobId}`); + expect(rows).toHaveLength(1); + expect(rows[0].status).toContain('简历请求已发送'); + expect(rows[0].detail).toContain(friend.name); + expect(rows[0].detail).toContain(friend.encryptJobId); + }); + it('uses the explicit --job-id when provided, overriding friend.encryptJobId', async () => { + const page = createPageMock([ + ok({ friendList: [friend] }), + ok({}), + ]); + await command.func(page, { uid: friend.encryptUid, 'job-id': 'enc-job-explicit' }); + const postScript = page.evaluate.mock.calls.at(-1)[0]; + expect(postScript).toContain('jobId=enc-job-explicit'); + expect(postScript).not.toContain(`jobId=${friend.encryptJobId}`); + }); + it('falls back to greet list when the friend is not in chat list, then sends type=3', async () => { + const page = createPageMock([ + // friend list empty + ok({ friendList: [] }), + // greet list (recommend) contains the friend + ok({ friendList: [friend] }), + // the POST + ok({}), + ]); + const rows = await command.func(page, { uid: friend.encryptUid }); + const postScript = page.evaluate.mock.calls.at(-1)[0]; + expect(postScript).toContain('type=3'); + expect(rows[0].status).toContain('简历请求已发送'); + }); + it('throws a clear error when the candidate is not found anywhere', async () => { + const page = createPageMock([ + // friend list empty + ok({ friendList: [] }), + // greet list also empty + ok({ friendList: [] }), + ]); + await expect(command.func(page, { uid: 'unknown-uid' })).rejects.toThrow(/未找到该候选人/); + }); +});