@jackwener/opencli 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +36 -10
  3. package/README.zh-CN.md +3 -0
  4. package/SKILL.md +7 -2
  5. package/dist/bilibili.js +4 -2
  6. package/dist/cli-manifest.json +506 -6
  7. package/dist/cli.js +51 -1
  8. package/dist/clis/antigravity/serve.js +296 -47
  9. package/dist/clis/arxiv/paper.d.ts +1 -0
  10. package/dist/clis/arxiv/paper.js +21 -0
  11. package/dist/clis/arxiv/search.d.ts +1 -0
  12. package/dist/clis/arxiv/search.js +24 -0
  13. package/dist/clis/arxiv/utils.d.ts +18 -0
  14. package/dist/clis/arxiv/utils.js +49 -0
  15. package/dist/clis/boss/batchgreet.d.ts +1 -0
  16. package/dist/clis/boss/batchgreet.js +147 -0
  17. package/dist/clis/boss/exchange.d.ts +1 -0
  18. package/dist/clis/boss/exchange.js +111 -0
  19. package/dist/clis/boss/greet.d.ts +1 -0
  20. package/dist/clis/boss/greet.js +175 -0
  21. package/dist/clis/boss/invite.d.ts +1 -0
  22. package/dist/clis/boss/invite.js +158 -0
  23. package/dist/clis/boss/joblist.d.ts +1 -0
  24. package/dist/clis/boss/joblist.js +55 -0
  25. package/dist/clis/boss/mark.d.ts +1 -0
  26. package/dist/clis/boss/mark.js +141 -0
  27. package/dist/clis/boss/recommend.d.ts +1 -0
  28. package/dist/clis/boss/recommend.js +83 -0
  29. package/dist/clis/boss/stats.d.ts +1 -0
  30. package/dist/clis/boss/stats.js +116 -0
  31. package/dist/clis/sinafinance/news.d.ts +7 -0
  32. package/dist/clis/sinafinance/news.js +61 -0
  33. package/dist/clis/wikipedia/search.d.ts +1 -0
  34. package/dist/clis/wikipedia/search.js +30 -0
  35. package/dist/clis/wikipedia/summary.d.ts +1 -0
  36. package/dist/clis/wikipedia/summary.js +28 -0
  37. package/dist/clis/wikipedia/utils.d.ts +8 -0
  38. package/dist/clis/wikipedia/utils.js +18 -0
  39. package/dist/clis/xiaohongshu/creator-note-detail.d.ts +64 -5
  40. package/dist/clis/xiaohongshu/creator-note-detail.js +258 -69
  41. package/dist/clis/xiaohongshu/creator-note-detail.test.d.ts +1 -0
  42. package/dist/clis/xiaohongshu/creator-note-detail.test.js +211 -0
  43. package/dist/clis/xiaohongshu/creator-notes-summary.d.ts +28 -0
  44. package/dist/clis/xiaohongshu/creator-notes-summary.js +92 -0
  45. package/dist/clis/xiaohongshu/creator-notes-summary.test.d.ts +1 -0
  46. package/dist/clis/xiaohongshu/creator-notes-summary.test.js +49 -0
  47. package/dist/clis/xiaohongshu/creator-notes.d.ts +18 -5
  48. package/dist/clis/xiaohongshu/creator-notes.js +159 -71
  49. package/dist/clis/xiaohongshu/creator-notes.test.d.ts +1 -0
  50. package/dist/clis/xiaohongshu/creator-notes.test.js +162 -0
  51. package/dist/external.d.ts +20 -0
  52. package/dist/external.js +159 -0
  53. package/docs/.vitepress/config.mts +1 -0
  54. package/docs/public/CNAME +1 -0
  55. package/package.json +1 -1
  56. package/src/bilibili.ts +4 -2
  57. package/src/browser/cdp.ts +3 -3
  58. package/src/cli.ts +56 -1
  59. package/src/clis/antigravity/README.md +3 -46
  60. package/src/clis/antigravity/serve.ts +323 -50
  61. package/src/clis/arxiv/paper.ts +21 -0
  62. package/src/clis/arxiv/search.ts +24 -0
  63. package/src/clis/arxiv/utils.ts +63 -0
  64. package/src/clis/boss/batchgreet.ts +167 -0
  65. package/src/clis/boss/exchange.ts +126 -0
  66. package/src/clis/boss/greet.ts +198 -0
  67. package/src/clis/boss/invite.ts +177 -0
  68. package/src/clis/boss/joblist.ts +63 -0
  69. package/src/clis/boss/mark.ts +155 -0
  70. package/src/clis/boss/recommend.ts +94 -0
  71. package/src/clis/boss/stats.ts +130 -0
  72. package/src/clis/chaoxing/README.md +2 -24
  73. package/src/clis/chatgpt/README.md +3 -42
  74. package/src/clis/chatwise/README.md +3 -36
  75. package/src/clis/codex/README.md +3 -32
  76. package/src/clis/cursor/README.md +3 -31
  77. package/src/clis/discord-app/README.md +2 -25
  78. package/src/clis/feishu/README.md +2 -17
  79. package/src/clis/neteasemusic/README.md +3 -29
  80. package/src/clis/notion/README.md +2 -26
  81. package/src/clis/sinafinance/news.ts +76 -0
  82. package/src/clis/wechat/README.md +2 -25
  83. package/src/clis/wikipedia/search.ts +32 -0
  84. package/src/clis/wikipedia/summary.ts +28 -0
  85. package/src/clis/wikipedia/utils.ts +20 -0
  86. package/src/clis/xiaohongshu/creator-note-detail.test.ts +223 -0
  87. package/src/clis/xiaohongshu/creator-note-detail.ts +340 -72
  88. package/src/clis/xiaohongshu/creator-notes-summary.test.ts +54 -0
  89. package/src/clis/xiaohongshu/creator-notes-summary.ts +120 -0
  90. package/src/clis/xiaohongshu/creator-notes.test.ts +178 -0
  91. package/src/clis/xiaohongshu/creator-notes.ts +215 -75
  92. package/src/daemon.ts +3 -3
  93. package/src/external-clis.yaml +39 -0
  94. package/src/external.ts +182 -0
  95. package/CDP.md +0 -103
  96. package/CDP.zh-CN.md +0 -103
  97. package/CLI-ELECTRON.md +0 -125
@@ -0,0 +1,177 @@
1
+ /**
2
+ * BOSS直聘 invite — send interview invitation to a candidate.
3
+ *
4
+ * Uses POST /wapi/zpinterview/boss/interview/invite to send interview invitations.
5
+ * Address and contact info come from the boss's saved settings.
6
+ */
7
+ import { cli, Strategy } from '../../registry.js';
8
+ import type { IPage } from '../../types.js';
9
+
10
+ cli({
11
+ site: 'boss',
12
+ name: 'invite',
13
+ description: 'BOSS直聘发送面试邀请',
14
+ domain: 'www.zhipin.com',
15
+ strategy: Strategy.COOKIE,
16
+ browser: true,
17
+ args: [
18
+ { name: 'uid', required: true, help: 'Encrypted UID of the candidate' },
19
+ { name: 'time', required: true, help: 'Interview time (e.g. 2025-04-01 14:00)' },
20
+ { name: 'address', default: '', help: 'Interview address (uses saved address if empty)' },
21
+ { name: 'contact', default: '', help: 'Contact person name (uses saved contact if empty)' },
22
+ ],
23
+ columns: ['status', 'detail'],
24
+ func: async (page: IPage | null, kwargs) => {
25
+ if (!page) throw new Error('Browser page required');
26
+
27
+ const uid = kwargs.uid;
28
+ const timeStr = kwargs.time;
29
+ const address = kwargs.address;
30
+ const contact = kwargs.contact;
31
+
32
+ if (process.env.OPENCLI_VERBOSE) {
33
+ console.error(`[opencli:boss] Sending interview invitation to ${uid}...`);
34
+ }
35
+
36
+ await page.goto('https://www.zhipin.com/web/chat/index');
37
+ await page.wait({ time: 2 });
38
+
39
+ // Get candidate info
40
+ let friend: any = null;
41
+
42
+ // Check greet list first
43
+ const greetData: any = await page.evaluate(`
44
+ async () => {
45
+ return new Promise((resolve, reject) => {
46
+ const xhr = new XMLHttpRequest();
47
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList', true);
48
+ xhr.withCredentials = true;
49
+ xhr.timeout = 15000;
50
+ xhr.setRequestHeader('Accept', 'application/json');
51
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
52
+ xhr.onerror = () => reject(new Error('Network Error'));
53
+ xhr.send();
54
+ });
55
+ }
56
+ `);
57
+
58
+ if (greetData.code === 0) {
59
+ friend = (greetData.zpData?.friendList || []).find((f: any) => f.encryptUid === uid);
60
+ }
61
+
62
+ if (!friend) {
63
+ const friendData: any = await page.evaluate(`
64
+ async () => {
65
+ return new Promise((resolve, reject) => {
66
+ const xhr = new XMLHttpRequest();
67
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
68
+ xhr.withCredentials = true;
69
+ xhr.timeout = 15000;
70
+ xhr.setRequestHeader('Accept', 'application/json');
71
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
72
+ xhr.onerror = () => reject(new Error('Network Error'));
73
+ xhr.send();
74
+ });
75
+ }
76
+ `);
77
+ if (friendData.code === 0) {
78
+ friend = (friendData.zpData?.friendList || []).find((f: any) => f.encryptUid === uid);
79
+ }
80
+ }
81
+
82
+ if (!friend) {
83
+ throw new Error('未找到该候选人');
84
+ }
85
+
86
+ const numericUid = friend.uid;
87
+ const friendName = friend.name || '候选人';
88
+ const securityId = friend.securityId || '';
89
+ const encJobId = friend.encryptJobId || '';
90
+
91
+ // Get saved contact info
92
+ const contactData: any = await page.evaluate(`
93
+ async () => {
94
+ return new Promise((resolve, reject) => {
95
+ const xhr = new XMLHttpRequest();
96
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zpinterview/boss/interview/contactInit', true);
97
+ xhr.withCredentials = true;
98
+ xhr.timeout = 10000;
99
+ xhr.setRequestHeader('Accept', 'application/json');
100
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
101
+ xhr.onerror = () => reject(new Error('Network Error'));
102
+ xhr.send();
103
+ });
104
+ }
105
+ `);
106
+
107
+ const contactId = contactData.zpData?.contactId || '';
108
+ const contactName = contact || contactData.zpData?.contactName || '';
109
+ const contactPhone = contactData.zpData?.contactPhone || '';
110
+
111
+ // Get saved address
112
+ const addressData: any = await page.evaluate(`
113
+ async () => {
114
+ return new Promise((resolve, reject) => {
115
+ const xhr = new XMLHttpRequest();
116
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zpinterview/boss/interview/listAddress', true);
117
+ xhr.withCredentials = true;
118
+ xhr.timeout = 10000;
119
+ xhr.setRequestHeader('Accept', 'application/json');
120
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
121
+ xhr.onerror = () => reject(new Error('Network Error'));
122
+ xhr.send();
123
+ });
124
+ }
125
+ `);
126
+
127
+ const savedAddress = addressData.zpData?.list?.[0] || {};
128
+ const addressText = address || savedAddress.cityAddressText || savedAddress.addressText || '';
129
+
130
+ // Parse interview time
131
+ const interviewTime = new Date(timeStr).getTime();
132
+ if (isNaN(interviewTime)) {
133
+ throw new Error(`时间格式错误: ${timeStr},请使用格式如 2025-04-01 14:00`);
134
+ }
135
+
136
+ // Send interview invitation
137
+ const params = new URLSearchParams({
138
+ uid: String(numericUid),
139
+ securityId: securityId,
140
+ encryptJobId: encJobId,
141
+ interviewTime: String(interviewTime),
142
+ contactId: contactId,
143
+ contactName: contactName,
144
+ contactPhone: contactPhone,
145
+ address: addressText,
146
+ interviewType: '1', // 1 = onsite
147
+ });
148
+
149
+ const data: any = await page.evaluate(`
150
+ async () => {
151
+ return new Promise((resolve, reject) => {
152
+ const xhr = new XMLHttpRequest();
153
+ xhr.open('POST', 'https://www.zhipin.com/wapi/zpinterview/boss/interview/invite.json', true);
154
+ xhr.withCredentials = true;
155
+ xhr.timeout = 15000;
156
+ xhr.setRequestHeader('Accept', 'application/json');
157
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
158
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(new Error('JSON parse failed')); } };
159
+ xhr.onerror = () => reject(new Error('Network Error'));
160
+ xhr.send(${JSON.stringify(params.toString())});
161
+ });
162
+ }
163
+ `);
164
+
165
+ if (data.code !== 0) {
166
+ if (data.code === 7 || data.code === 37) {
167
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
168
+ }
169
+ throw new Error(`面试邀请发送失败: ${data.message} (code=${data.code})`);
170
+ }
171
+
172
+ return [{
173
+ status: '✅ 面试邀请已发送',
174
+ detail: `已向 ${friendName} 发送面试邀请\n时间: ${timeStr}\n地点: ${addressText}\n联系人: ${contactName}`,
175
+ }];
176
+ },
177
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * BOSS直聘 job list — list my published jobs via boss API.
3
+ *
4
+ * Uses /wapi/zpjob/job/chatted/jobList to get job list with status info.
5
+ */
6
+ import { cli, Strategy } from '../../registry.js';
7
+ import type { IPage } from '../../types.js';
8
+
9
+ cli({
10
+ site: 'boss',
11
+ name: 'joblist',
12
+ description: 'BOSS直聘查看我发布的职位列表',
13
+ domain: 'www.zhipin.com',
14
+ strategy: Strategy.COOKIE,
15
+ browser: true,
16
+ args: [],
17
+ columns: ['job_name', 'salary', 'city', 'status', 'encrypt_job_id'],
18
+ func: async (page: IPage | null, kwargs) => {
19
+ if (!page) throw new Error('Browser page required');
20
+
21
+ if (process.env.OPENCLI_VERBOSE) {
22
+ console.error('[opencli:boss] Fetching job list...');
23
+ }
24
+
25
+ await page.goto('https://www.zhipin.com/web/chat/index');
26
+ await page.wait({ time: 2 });
27
+
28
+ const targetUrl = 'https://www.zhipin.com/wapi/zpjob/job/chatted/jobList';
29
+
30
+ const data: any = await page.evaluate(`
31
+ async () => {
32
+ return new Promise((resolve, reject) => {
33
+ const xhr = new XMLHttpRequest();
34
+ xhr.open('GET', '${targetUrl}', true);
35
+ xhr.withCredentials = true;
36
+ xhr.timeout = 15000;
37
+ xhr.setRequestHeader('Accept', 'application/json');
38
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(new Error('JSON parse failed')); } };
39
+ xhr.onerror = () => reject(new Error('Network Error'));
40
+ xhr.ontimeout = () => reject(new Error('Timeout'));
41
+ xhr.send();
42
+ });
43
+ }
44
+ `);
45
+
46
+ if (data.code !== 0) {
47
+ if (data.code === 7 || data.code === 37) {
48
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
49
+ }
50
+ throw new Error(`API error: ${data.message} (code=${data.code})`);
51
+ }
52
+
53
+ const jobs = data.zpData || [];
54
+
55
+ return jobs.map((j: any) => ({
56
+ job_name: j.jobName || '',
57
+ salary: j.salaryDesc || '',
58
+ city: j.address || '',
59
+ status: j.jobOnlineStatus === 1 ? '在线' : '已关闭',
60
+ encrypt_job_id: j.encryptJobId || '',
61
+ }));
62
+ },
63
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * BOSS直聘 mark — label/mark a candidate.
3
+ *
4
+ * Uses /wapi/zprelation/friend/label/addMark to add a label to a candidate,
5
+ * and /wapi/zprelation/friend/label/deleteMark to remove one.
6
+ *
7
+ * Available labels (from /wapi/zprelation/friend/label/get):
8
+ * 1=新招呼, 2=沟通中, 3=已约面, 4=已获取简历, 5=已交换电话,
9
+ * 6=已交换微信, 7=不合适, 8=牛人发起, 11=收藏
10
+ */
11
+ import { cli, Strategy } from '../../registry.js';
12
+ import type { IPage } from '../../types.js';
13
+
14
+ const LABEL_MAP: Record<string, number> = {
15
+ '新招呼': 1, '沟通中': 2, '已约面': 3, '已获取简历': 4,
16
+ '已交换电话': 5, '已交换微信': 6, '不合适': 7, '牛人发起': 8, '收藏': 11,
17
+ };
18
+
19
+ cli({
20
+ site: 'boss',
21
+ name: 'mark',
22
+ description: 'BOSS直聘给候选人添加标签',
23
+ domain: 'www.zhipin.com',
24
+ strategy: Strategy.COOKIE,
25
+ browser: true,
26
+ args: [
27
+ { name: 'uid', required: true, help: 'Encrypted UID of the candidate' },
28
+ { name: 'label', required: true, help: 'Label name (新招呼/沟通中/已约面/已获取简历/已交换电话/已交换微信/不合适/收藏) or label ID' },
29
+ { name: 'remove', type: 'boolean', default: false, help: 'Remove the label instead of adding' },
30
+ ],
31
+ columns: ['status', 'detail'],
32
+ func: async (page: IPage | null, kwargs) => {
33
+ if (!page) throw new Error('Browser page required');
34
+
35
+ const uid = kwargs.uid;
36
+ const labelInput = kwargs.label;
37
+ const remove = kwargs.remove || false;
38
+
39
+ // Resolve label to ID
40
+ let labelId: number;
41
+ if (LABEL_MAP[labelInput]) {
42
+ labelId = LABEL_MAP[labelInput];
43
+ } else if (!isNaN(Number(labelInput))) {
44
+ labelId = Number(labelInput);
45
+ } else {
46
+ // Try partial match
47
+ const entry = Object.entries(LABEL_MAP).find(([k]) => k.includes(labelInput));
48
+ if (entry) {
49
+ labelId = entry[1];
50
+ } else {
51
+ throw new Error(`未知标签: ${labelInput}。可用标签: ${Object.keys(LABEL_MAP).join(', ')}`);
52
+ }
53
+ }
54
+
55
+ if (process.env.OPENCLI_VERBOSE) {
56
+ console.error(`[opencli:boss] ${remove ? 'Removing' : 'Adding'} label ${labelId} for ${uid}...`);
57
+ }
58
+
59
+ await page.goto('https://www.zhipin.com/web/chat/index');
60
+ await page.wait({ time: 2 });
61
+
62
+ // First get numeric UID from friend list
63
+ const friendData: any = await page.evaluate(`
64
+ async () => {
65
+ return new Promise((resolve, reject) => {
66
+ const xhr = new XMLHttpRequest();
67
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
68
+ xhr.withCredentials = true;
69
+ xhr.timeout = 15000;
70
+ xhr.setRequestHeader('Accept', 'application/json');
71
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
72
+ xhr.onerror = () => reject(new Error('Network Error'));
73
+ xhr.send();
74
+ });
75
+ }
76
+ `);
77
+
78
+ if (friendData.code !== 0) {
79
+ if (friendData.code === 7 || friendData.code === 37) {
80
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
81
+ }
82
+ throw new Error(`获取好友列表失败: ${friendData.message}`);
83
+ }
84
+
85
+ // Find in friend list (check multiple pages)
86
+ let friend: any = null;
87
+ let allFriends = friendData.zpData?.friendList || [];
88
+ friend = allFriends.find((f: any) => f.encryptUid === uid);
89
+
90
+ if (!friend) {
91
+ // Also check greetRecSortList
92
+ const greetData: any = await page.evaluate(`
93
+ async () => {
94
+ return new Promise((resolve, reject) => {
95
+ const xhr = new XMLHttpRequest();
96
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList', true);
97
+ xhr.withCredentials = true;
98
+ xhr.timeout = 15000;
99
+ xhr.setRequestHeader('Accept', 'application/json');
100
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(e); } };
101
+ xhr.onerror = () => reject(new Error('Network Error'));
102
+ xhr.send();
103
+ });
104
+ }
105
+ `);
106
+ if (greetData.code === 0) {
107
+ friend = (greetData.zpData?.friendList || []).find((f: any) => f.encryptUid === uid);
108
+ }
109
+ }
110
+
111
+ if (!friend) {
112
+ throw new Error('未找到该候选人');
113
+ }
114
+
115
+ const numericUid = friend.uid;
116
+ const friendName = friend.name || '候选人';
117
+ const friendSource = friend.friendSource ?? 0;
118
+
119
+ const action = remove ? 'deleteMark' : 'addMark';
120
+ const targetUrl = `https://www.zhipin.com/wapi/zprelation/friend/label/${action}`;
121
+
122
+ // The API uses friendId + friendSource + labelId (discovered from JS bundles)
123
+ const params = new URLSearchParams({
124
+ friendId: String(numericUid),
125
+ friendSource: String(friendSource),
126
+ labelId: String(labelId),
127
+ });
128
+
129
+ // Try GET first (the N() wrapper in boss JS uses GET with query params)
130
+ const data: any = await page.evaluate(`
131
+ async () => {
132
+ return new Promise((resolve, reject) => {
133
+ const xhr = new XMLHttpRequest();
134
+ xhr.open('GET', '${targetUrl}?${params.toString()}', true);
135
+ xhr.withCredentials = true;
136
+ xhr.timeout = 15000;
137
+ xhr.setRequestHeader('Accept', 'application/json');
138
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(new Error('JSON parse failed')); } };
139
+ xhr.onerror = () => reject(new Error('Network Error'));
140
+ xhr.send();
141
+ });
142
+ }
143
+ `);
144
+
145
+ if (data.code !== 0) {
146
+ throw new Error(`标签操作失败: ${data.message} (code=${data.code})`);
147
+ }
148
+
149
+ const labelName = Object.entries(LABEL_MAP).find(([, v]) => v === labelId)?.[0] || String(labelId);
150
+ return [{
151
+ status: remove ? '✅ 标签已移除' : '✅ 标签已添加',
152
+ detail: `${friendName}: ${remove ? '移除' : '添加'}标签「${labelName}」`,
153
+ }];
154
+ },
155
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * BOSS直聘 recommend — view recommended candidates (新招呼/greet sort list).
3
+ *
4
+ * Uses /wapi/zprelation/friend/greetRecSortList to get system-recommended candidates.
5
+ * These are candidates who have greeted or been recommended by the system.
6
+ */
7
+ import { cli, Strategy } from '../../registry.js';
8
+ import type { IPage } from '../../types.js';
9
+
10
+ cli({
11
+ site: 'boss',
12
+ name: 'recommend',
13
+ description: 'BOSS直聘查看推荐候选人(新招呼列表)',
14
+ domain: 'www.zhipin.com',
15
+ strategy: Strategy.COOKIE,
16
+ browser: true,
17
+ args: [
18
+ { name: 'limit', type: 'int', default: 20, help: 'Number of results to return' },
19
+ ],
20
+ columns: ['name', 'job_name', 'last_time', 'labels', 'encrypt_uid', 'security_id', 'encrypt_job_id'],
21
+ func: async (page: IPage | null, kwargs) => {
22
+ if (!page) throw new Error('Browser page required');
23
+
24
+ const limit = kwargs.limit || 20;
25
+
26
+ if (process.env.OPENCLI_VERBOSE) {
27
+ console.error('[opencli:boss] Fetching recommended candidates...');
28
+ }
29
+
30
+ await page.goto('https://www.zhipin.com/web/chat/index');
31
+ await page.wait({ time: 2 });
32
+
33
+ // Get label definitions for mapping
34
+ const labelData: any = await page.evaluate(`
35
+ async () => {
36
+ return new Promise((resolve, reject) => {
37
+ const xhr = new XMLHttpRequest();
38
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/label/get', true);
39
+ xhr.withCredentials = true;
40
+ xhr.timeout = 10000;
41
+ xhr.setRequestHeader('Accept', 'application/json');
42
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { resolve({}); } };
43
+ xhr.onerror = () => resolve({});
44
+ xhr.send();
45
+ });
46
+ }
47
+ `);
48
+
49
+ const labelMap: Record<number, string> = {};
50
+ if (labelData.code === 0 && labelData.zpData?.labels) {
51
+ for (const l of labelData.zpData.labels) {
52
+ labelMap[l.labelId] = l.label;
53
+ }
54
+ }
55
+
56
+ // Get recommended candidates
57
+ const targetUrl = 'https://www.zhipin.com/wapi/zprelation/friend/greetRecSortList';
58
+
59
+ const data: any = await page.evaluate(`
60
+ async () => {
61
+ return new Promise((resolve, reject) => {
62
+ const xhr = new XMLHttpRequest();
63
+ xhr.open('GET', '${targetUrl}', true);
64
+ xhr.withCredentials = true;
65
+ xhr.timeout = 15000;
66
+ xhr.setRequestHeader('Accept', 'application/json');
67
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(new Error('JSON parse failed')); } };
68
+ xhr.onerror = () => reject(new Error('Network Error'));
69
+ xhr.ontimeout = () => reject(new Error('Timeout'));
70
+ xhr.send();
71
+ });
72
+ }
73
+ `);
74
+
75
+ if (data.code !== 0) {
76
+ if (data.code === 7 || data.code === 37) {
77
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
78
+ }
79
+ throw new Error(`API error: ${data.message} (code=${data.code})`);
80
+ }
81
+
82
+ const friends = (data.zpData?.friendList || []).slice(0, limit);
83
+
84
+ return friends.map((f: any) => ({
85
+ name: f.name || '',
86
+ job_name: f.jobName || '',
87
+ last_time: f.lastTime || '',
88
+ labels: (f.relationLabelList || []).map((id: number) => labelMap[id] || String(id)).join(', '),
89
+ encrypt_uid: f.encryptUid || '',
90
+ security_id: f.securityId || '',
91
+ encrypt_job_id: f.encryptJobId || '',
92
+ }));
93
+ },
94
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * BOSS直聘 stats — job statistics overview.
3
+ *
4
+ * Uses /wapi/zpchat/chatHelper/statistics for total friend count,
5
+ * and /wapi/zpjob/job/chatted/jobList for per-job info.
6
+ * Since BOSS doesn't expose detailed per-job stats via API,
7
+ * we show what's available: job status, chat info, and total stats.
8
+ */
9
+ import { cli, Strategy } from '../../registry.js';
10
+ import type { IPage } from '../../types.js';
11
+
12
+ cli({
13
+ site: 'boss',
14
+ name: 'stats',
15
+ description: 'BOSS直聘职位数据统计',
16
+ domain: 'www.zhipin.com',
17
+ strategy: Strategy.COOKIE,
18
+ browser: true,
19
+ args: [
20
+ { name: 'job_id', default: '', help: 'Encrypted job ID (show all if empty)' },
21
+ ],
22
+ columns: ['job_name', 'salary', 'city', 'status', 'total_chats', 'encrypt_job_id'],
23
+ func: async (page: IPage | null, kwargs) => {
24
+ if (!page) throw new Error('Browser page required');
25
+
26
+ const filterJobId = kwargs.job_id || '';
27
+
28
+ if (process.env.OPENCLI_VERBOSE) {
29
+ console.error('[opencli:boss] Fetching job statistics...');
30
+ }
31
+
32
+ await page.goto('https://www.zhipin.com/web/chat/index');
33
+ await page.wait({ time: 2 });
34
+
35
+ // Get job list
36
+ const jobData: any = await page.evaluate(`
37
+ async () => {
38
+ return new Promise((resolve, reject) => {
39
+ const xhr = new XMLHttpRequest();
40
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zpjob/job/chatted/jobList', true);
41
+ xhr.withCredentials = true;
42
+ xhr.timeout = 15000;
43
+ xhr.setRequestHeader('Accept', 'application/json');
44
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { reject(new Error('JSON parse failed')); } };
45
+ xhr.onerror = () => reject(new Error('Network Error'));
46
+ xhr.ontimeout = () => reject(new Error('Timeout'));
47
+ xhr.send();
48
+ });
49
+ }
50
+ `);
51
+
52
+ if (jobData.code !== 0) {
53
+ if (jobData.code === 7 || jobData.code === 37) {
54
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
55
+ }
56
+ throw new Error(`API error: ${jobData.message} (code=${jobData.code})`);
57
+ }
58
+
59
+ // Get total chat stats
60
+ const chatStats: any = await page.evaluate(`
61
+ async () => {
62
+ return new Promise((resolve, reject) => {
63
+ const xhr = new XMLHttpRequest();
64
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zpchat/chatHelper/statistics', true);
65
+ xhr.withCredentials = true;
66
+ xhr.timeout = 10000;
67
+ xhr.setRequestHeader('Accept', 'application/json');
68
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { resolve({}); } };
69
+ xhr.onerror = () => resolve({});
70
+ xhr.send();
71
+ });
72
+ }
73
+ `);
74
+
75
+ const totalFriends = chatStats.zpData?.totalFriendCount || 0;
76
+
77
+ // Get per-job chat counts from friend list
78
+ const friendData: any = await page.evaluate(`
79
+ async () => {
80
+ return new Promise((resolve, reject) => {
81
+ const xhr = new XMLHttpRequest();
82
+ xhr.open('GET', 'https://www.zhipin.com/wapi/zprelation/friend/getBossFriendListV2.json?page=1&status=0&jobId=0', true);
83
+ xhr.withCredentials = true;
84
+ xhr.timeout = 15000;
85
+ xhr.setRequestHeader('Accept', 'application/json');
86
+ xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)); } catch(e) { resolve({}); } };
87
+ xhr.onerror = () => resolve({});
88
+ xhr.send();
89
+ });
90
+ }
91
+ `);
92
+
93
+ // Count chats per job
94
+ const jobChatCounts: Record<string, number> = {};
95
+ if (friendData.code === 0) {
96
+ for (const f of (friendData.zpData?.friendList || [])) {
97
+ const jobName = f.jobName || 'unknown';
98
+ jobChatCounts[jobName] = (jobChatCounts[jobName] || 0) + 1;
99
+ }
100
+ }
101
+
102
+ let jobs = jobData.zpData || [];
103
+ if (filterJobId) {
104
+ jobs = jobs.filter((j: any) => j.encryptJobId === filterJobId);
105
+ }
106
+
107
+ const results = jobs.map((j: any) => ({
108
+ job_name: j.jobName || '',
109
+ salary: j.salaryDesc || '',
110
+ city: j.address || '',
111
+ status: j.jobOnlineStatus === 1 ? '在线' : '已关闭',
112
+ total_chats: String(jobChatCounts[j.jobName] || 0),
113
+ encrypt_job_id: j.encryptJobId || '',
114
+ }));
115
+
116
+ // Add summary row
117
+ if (!filterJobId && results.length > 0) {
118
+ results.push({
119
+ job_name: '--- 总计 ---',
120
+ salary: '',
121
+ city: '',
122
+ status: `${jobs.length} 个职位`,
123
+ total_chats: String(totalFriends),
124
+ encrypt_job_id: '',
125
+ });
126
+ }
127
+
128
+ return results;
129
+ },
130
+ });
@@ -2,35 +2,13 @@
2
2
 
3
3
  View your Chaoxing assignments and exams from the terminal by reusing your Chrome login session.
4
4
 
5
- ## Prerequisites
6
-
7
- 1. Chrome must be running and already logged into Chaoxing (`i.chaoxing.com`).
8
- 2. The opencli Browser Bridge extension must be installed.
5
+ 📖 **Full documentation**: See commands and usage below.
9
6
 
10
7
  ## Commands
11
8
 
12
9
  | Command | Description |
13
10
  |---------|-------------|
14
11
  | `opencli chaoxing assignments` | List assignments across all courses |
15
- | `opencli chaoxing assignments --course "数学"` | Filter by course name (fuzzy match) |
16
- | `opencli chaoxing assignments --status pending` | Filter: `all` / `pending` / `submitted` / `graded` |
17
12
  | `opencli chaoxing exams` | List exams across all courses |
18
- | `opencli chaoxing exams --course "数学"` | Filter by course name |
19
- | `opencli chaoxing exams --status upcoming` | Filter: `all` / `upcoming` / `ongoing` / `finished` |
20
-
21
- ## How It Works
22
-
23
- Chaoxing has no flat API for listing assignments/exams. The adapter follows the same
24
- flow a student would in the browser:
25
-
26
- 1. Establish session via the interaction page
27
- 2. Fetch enrolled course list (`backclazzdata` JSON API)
28
- 3. Enter each course via `stucoursemiddle` redirect (obtains session `enc`)
29
- 4. Click the 作业/考试 tab and capture the iframe URL
30
- 5. Navigate to that URL and parse the DOM
31
-
32
- ## Limitations
33
13
 
34
- - Requires `--course` filter for practical use (scanning all 40+ courses is slow)
35
- - Does not submit homework or exams
36
- - If Chaoxing changes page structure, the DOM parser may need updates
14
+ Supports `--course` and `--status` filters. Requires Chrome logged into `i.chaoxing.com` with Browser Bridge extension installed.