Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions clis/boss/ask-resume.js
Original file line number Diff line number Diff line change
@@ -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})` : ''}`,
}];
},
});
99 changes: 99 additions & 0 deletions clis/boss/ask-resume.test.js
Original file line number Diff line number Diff line change
@@ -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(/未找到该候选人/);
});
});