@jackwener/opencli 1.7.20 → 1.7.22

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 (75) hide show
  1. package/README.md +2 -1
  2. package/README.zh-CN.md +2 -1
  3. package/cli-manifest.json +233 -72
  4. package/clis/_shared/search-adapter.js +70 -0
  5. package/clis/boss/chatlist.js +96 -14
  6. package/clis/boss/chatlist.test.js +211 -0
  7. package/clis/boss/chatmsg.js +98 -24
  8. package/clis/boss/chatmsg.test.js +230 -0
  9. package/clis/boss/utils.js +257 -12
  10. package/clis/boss/utils.test.js +34 -0
  11. package/clis/brave/search.js +80 -0
  12. package/clis/brave/search.test.js +76 -0
  13. package/clis/duckduckgo/search.js +131 -0
  14. package/clis/duckduckgo/search.test.js +128 -0
  15. package/clis/duckduckgo/suggest.js +45 -0
  16. package/clis/duckduckgo/suggest.test.js +66 -0
  17. package/clis/facebook/feed.js +301 -56
  18. package/clis/facebook/feed.test.js +169 -0
  19. package/clis/reddit/comment.js +0 -1
  20. package/clis/reddit/frontpage.js +0 -1
  21. package/clis/reddit/home.js +0 -1
  22. package/clis/reddit/popular.js +0 -1
  23. package/clis/reddit/read.js +0 -1
  24. package/clis/reddit/read.test.js +2 -2
  25. package/clis/reddit/save.js +0 -1
  26. package/clis/reddit/saved.js +0 -1
  27. package/clis/reddit/search.js +0 -1
  28. package/clis/reddit/subreddit-info.js +0 -1
  29. package/clis/reddit/subreddit.js +0 -1
  30. package/clis/reddit/subscribe.js +0 -1
  31. package/clis/reddit/upvote.js +0 -1
  32. package/clis/reddit/upvoted.js +0 -1
  33. package/clis/reddit/user-comments.js +0 -1
  34. package/clis/reddit/user-posts.js +0 -1
  35. package/clis/reddit/user.js +0 -1
  36. package/clis/reddit/whoami.js +0 -1
  37. package/clis/rednote/rednote.test.js +65 -0
  38. package/clis/rednote/search.js +11 -5
  39. package/clis/twitter/article.js +0 -1
  40. package/clis/twitter/bookmark-folder.js +0 -1
  41. package/clis/twitter/bookmark-folders.js +0 -1
  42. package/clis/twitter/bookmarks.js +0 -1
  43. package/clis/twitter/download.js +0 -1
  44. package/clis/twitter/followers.js +0 -1
  45. package/clis/twitter/following.js +0 -1
  46. package/clis/twitter/likes.js +0 -1
  47. package/clis/twitter/list-tweets.js +0 -1
  48. package/clis/twitter/lists.js +0 -1
  49. package/clis/twitter/notifications.js +0 -1
  50. package/clis/twitter/profile.js +0 -1
  51. package/clis/twitter/search.js +0 -1
  52. package/clis/twitter/thread.js +0 -1
  53. package/clis/twitter/timeline.js +0 -1
  54. package/clis/twitter/trending.js +0 -1
  55. package/clis/twitter/tweets.js +0 -1
  56. package/clis/weibo/comments.js +3 -4
  57. package/clis/weibo/envelope.test.js +85 -0
  58. package/clis/weibo/favorites.js +4 -4
  59. package/clis/weibo/feed.js +3 -5
  60. package/clis/weibo/hot.js +3 -4
  61. package/clis/weibo/me.js +3 -5
  62. package/clis/weibo/post.js +3 -4
  63. package/clis/weibo/search.js +4 -3
  64. package/clis/weibo/user.js +3 -4
  65. package/clis/weibo/utils.js +34 -5
  66. package/clis/weibo/utils.test.js +36 -0
  67. package/clis/xiaohongshu/search.js +34 -16
  68. package/clis/xiaohongshu/search.test.js +66 -11
  69. package/clis/yahoo/search.js +92 -0
  70. package/clis/yahoo/search.test.js +94 -0
  71. package/dist/src/cli.js +1 -1
  72. package/dist/src/external-clis.yaml +12 -0
  73. package/dist/src/external.d.ts +6 -1
  74. package/dist/src/external.test.js +19 -0
  75. package/package.json +1 -1
@@ -0,0 +1,230 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import './chatmsg.js';
5
+
6
+ const BOSS_FRIEND = {
7
+ uid: 12345,
8
+ encryptUid: 'enc-boss-uid',
9
+ securityId: 'boss-sec-id',
10
+ name: '候选人甲',
11
+ };
12
+ const BOSS_MSGS = [
13
+ { type: 1, text: 'Hello', from: { uid: 99999, name: 'HR' }, time: 1704067200000 },
14
+ { type: 1, text: '感谢', from: { uid: 12345, name: '候选人甲' }, time: 1704067201000 },
15
+ ];
16
+
17
+ const GEEK_FRIEND_LABEL = {
18
+ friendId: 11111,
19
+ encryptFriendId: 'enc-geek-uid',
20
+ name: 'Boss张',
21
+ brandName: '公司A',
22
+ };
23
+ const GEEK_FRIEND_ENRICHED = {
24
+ friendId: 11111,
25
+ uid: 67890,
26
+ encryptUid: 'enc-geek-uid',
27
+ securityId: 'geek-sec-id',
28
+ name: 'Boss张',
29
+ };
30
+ const GEEK_MSGS = [
31
+ { type: 1, text: '欢迎投递', received: true, time: 1704067200000, from: { uid: 67890, name: 'Boss张' } },
32
+ { type: 1, text: '谢谢', received: true, time: 1704067201000, from: { uid: 99999, name: '我' } },
33
+ ];
34
+
35
+ function createPageMock(evaluateImpl) {
36
+ return {
37
+ goto: vi.fn().mockResolvedValue(undefined),
38
+ wait: vi.fn().mockResolvedValue(undefined),
39
+ evaluate: vi.fn().mockImplementation(evaluateImpl),
40
+ };
41
+ }
42
+
43
+ describe('boss chatmsg', () => {
44
+ const command = getRegistry().get('boss/chatmsg');
45
+
46
+ it('rejects empty uid before navigating', async () => {
47
+ const page = createPageMock(async () => ({}));
48
+ await expect(
49
+ command.func(page, { uid: ' ', page: 1, side: 'geek' })
50
+ ).rejects.toBeInstanceOf(ArgumentError);
51
+ expect(page.goto).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('rejects invalid --page before navigating', async () => {
55
+ const page = createPageMock(async () => ({}));
56
+ await expect(
57
+ command.func(page, { uid: 'enc-geek-uid', page: 0, side: 'geek' })
58
+ ).rejects.toBeInstanceOf(ArgumentError);
59
+ expect(page.goto).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it('--side boss preserves existing behavior', async () => {
63
+ const page = createPageMock(async (script) => {
64
+ if (script.includes('getBossFriendListV2')) {
65
+ return { code: 0, zpData: { friendList: [BOSS_FRIEND] } };
66
+ }
67
+ if (script.includes('boss/historyMsg')) {
68
+ return { code: 0, zpData: { messages: BOSS_MSGS } };
69
+ }
70
+ return {};
71
+ });
72
+ const rows = await command.func(page, { uid: 'enc-boss-uid', page: 1, side: 'boss' });
73
+ expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/chat/index'));
74
+ expect(rows).toHaveLength(2);
75
+ expect(rows[0].from).toBe('我');
76
+ expect(rows[1].from).toBe('候选人甲');
77
+ });
78
+
79
+ it('--side geek calls historyMsg with bossId, securityId, page, c=20, src=0', async () => {
80
+ const page = createPageMock(async (script) => {
81
+ if (script.includes('document.cookie')) return 'test-enc-sys-id';
82
+ if (script.includes('geekFilterByLabel')) {
83
+ return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } };
84
+ }
85
+ if (script.includes('getGeekFriendList.json')) {
86
+ return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } };
87
+ }
88
+ if (script.includes('geek/historyMsg')) {
89
+ return { code: 0, zpData: { messages: GEEK_MSGS } };
90
+ }
91
+ return {};
92
+ });
93
+ await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' });
94
+ const historyScript = page.evaluate.mock.calls.find((c) => c[0].includes('geek/historyMsg'))?.[0];
95
+ expect(historyScript).toBeDefined();
96
+ expect(historyScript).toContain('bossId=67890');
97
+ expect(historyScript).toContain('securityId=');
98
+ expect(historyScript).toContain('page=1');
99
+ expect(historyScript).toContain('c=20');
100
+ expect(historyScript).toContain('src=0');
101
+ });
102
+
103
+ it('--side geek uses from.uid to determine direction, not received flag', async () => {
104
+ // Both messages have received:true (mirrors real geek historyMsg API behaviour)
105
+ // Direction is determined by whether m.from.uid matches the boss's uid (67890)
106
+ const msgsAllReceived = [
107
+ { type: 1, text: '欢迎投递', received: true, time: 1704067200000, from: { uid: 67890, name: 'Boss张' } },
108
+ { type: 1, text: '谢谢', received: true, time: 1704067201000, from: { uid: 99999, name: '我' } },
109
+ ];
110
+ const page = createPageMock(async (script) => {
111
+ if (script.includes('document.cookie')) return 'test-enc-sys-id';
112
+ if (script.includes('geekFilterByLabel')) {
113
+ return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } };
114
+ }
115
+ if (script.includes('getGeekFriendList.json')) {
116
+ return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } };
117
+ }
118
+ if (script.includes('geek/historyMsg')) {
119
+ return { code: 0, zpData: { messages: msgsAllReceived } };
120
+ }
121
+ return {};
122
+ });
123
+ const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' });
124
+ // from.uid=67890 matches friend.uid=67890 → boss sent it → '对方'
125
+ expect(rows[0].from).toBe('对方');
126
+ // from.uid=99999 does not match → geek sent it → '我'
127
+ expect(rows[1].from).toBe('我');
128
+ });
129
+
130
+ it('non-text message body does not crash and produces truncated JSON', async () => {
131
+ const nonTextMsg = { type: 99, received: true, time: 1704067200000, body: { action: 'resume_request', detail: 'X' } };
132
+ const page = createPageMock(async (script) => {
133
+ if (script.includes('document.cookie')) return 'test-enc-sys-id';
134
+ if (script.includes('geekFilterByLabel')) {
135
+ return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } };
136
+ }
137
+ if (script.includes('getGeekFriendList.json')) {
138
+ return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } };
139
+ }
140
+ if (script.includes('geek/historyMsg')) {
141
+ return { code: 0, zpData: { messages: [nonTextMsg] } };
142
+ }
143
+ return {};
144
+ });
145
+ const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' });
146
+ expect(rows).toHaveLength(1);
147
+ expect(rows[0].text).toContain('resume_request');
148
+ });
149
+
150
+ it('--side auto falls back to geek when recruiter returns code 24', async () => {
151
+ const page = createPageMock(async (script) => {
152
+ if (script.includes('getBossFriendListV2')) {
153
+ return { code: 24, message: '请切换身份后再试' };
154
+ }
155
+ if (script.includes('document.cookie')) return 'test-enc-sys-id';
156
+ if (script.includes('geekFilterByLabel')) {
157
+ return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } };
158
+ }
159
+ if (script.includes('getGeekFriendList.json')) {
160
+ return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } };
161
+ }
162
+ if (script.includes('geek/historyMsg')) {
163
+ return { code: 0, zpData: { messages: GEEK_MSGS } };
164
+ }
165
+ return {};
166
+ });
167
+ const rows = await command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'auto' });
168
+ expect(rows).toHaveLength(2);
169
+ expect(rows[0].from).toBe('对方');
170
+ });
171
+
172
+ it('--side geek throws when uid is not found in geek chat list', async () => {
173
+ const page = createPageMock(async (script) => {
174
+ if (script.includes('document.cookie')) return 'test-enc-sys-id';
175
+ if (script.includes('geekFilterByLabel')) {
176
+ return { code: 0, zpData: { friendList: [] } };
177
+ }
178
+ return {};
179
+ });
180
+ await expect(
181
+ command.func(page, { uid: 'unknown-uid', page: 1, side: 'geek' })
182
+ ).rejects.toBeInstanceOf(EmptyResultError);
183
+ });
184
+
185
+ it('--side boss maps expired cookies to AuthRequiredError', async () => {
186
+ const page = createPageMock(async (script) => {
187
+ if (script.includes('getBossFriendListV2')) {
188
+ return { code: 7, message: 'Cookie 已过期' };
189
+ }
190
+ return {};
191
+ });
192
+ await expect(
193
+ command.func(page, { uid: 'enc-boss-uid', page: 1, side: 'boss' })
194
+ ).rejects.toBeInstanceOf(AuthRequiredError);
195
+ });
196
+
197
+ it('--side boss treats missing history list as parser drift', async () => {
198
+ const page = createPageMock(async (script) => {
199
+ if (script.includes('getBossFriendListV2')) {
200
+ return { code: 0, zpData: { friendList: [BOSS_FRIEND] } };
201
+ }
202
+ if (script.includes('boss/historyMsg')) {
203
+ return { code: 0, zpData: {} };
204
+ }
205
+ return {};
206
+ });
207
+ await expect(
208
+ command.func(page, { uid: 'enc-boss-uid', page: 1, side: 'boss' })
209
+ ).rejects.toBeInstanceOf(CommandExecutionError);
210
+ });
211
+
212
+ it('--side geek reports an empty history as EmptyResultError', async () => {
213
+ const page = createPageMock(async (script) => {
214
+ if (script.includes('document.cookie')) return 'test-enc-sys-id';
215
+ if (script.includes('geekFilterByLabel')) {
216
+ return { code: 0, zpData: { friendList: [GEEK_FRIEND_LABEL] } };
217
+ }
218
+ if (script.includes('getGeekFriendList.json')) {
219
+ return { code: 0, zpData: { result: [GEEK_FRIEND_ENRICHED] } };
220
+ }
221
+ if (script.includes('geek/historyMsg')) {
222
+ return { code: 0, zpData: { messages: [] } };
223
+ }
224
+ return {};
225
+ });
226
+ await expect(
227
+ command.func(page, { uid: 'enc-geek-uid', page: 1, side: 'geek' })
228
+ ).rejects.toBeInstanceOf(EmptyResultError);
229
+ });
230
+ });
@@ -1,8 +1,11 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
+
1
3
  // ── Constants ───────────────────────────────────────────────────────────────
2
4
  const BOSS_DOMAIN = 'www.zhipin.com';
3
5
  const CHAT_URL = `https://${BOSS_DOMAIN}/web/chat/index`;
4
6
  const COOKIE_EXPIRED_CODES = new Set([7, 37]);
5
7
  const COOKIE_EXPIRED_MSG = 'Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。';
8
+ const RECRUITER_ONLY_MSG = '该命令仅支持招聘端(BOSS 端)账号,请使用招聘者账号登录后重试。';
6
9
  const DEFAULT_TIMEOUT = 15_000;
7
10
  // ── Core helpers ────────────────────────────────────────────────────────────
8
11
  /**
@@ -10,7 +13,24 @@ const DEFAULT_TIMEOUT = 15_000;
10
13
  */
11
14
  export function requirePage(page) {
12
15
  if (!page)
13
- throw new Error('Browser page required');
16
+ throw new CommandExecutionError('Browser page required');
17
+ }
18
+ export function readPositiveInteger(raw, name, fallback, max) {
19
+ const value = raw === undefined || raw === null || raw === '' ? fallback : Number(raw);
20
+ if (!Number.isInteger(value) || value < 1) {
21
+ throw new ArgumentError(`boss ${name} must be a positive integer`);
22
+ }
23
+ if (max !== undefined && value > max) {
24
+ throw new ArgumentError(`boss ${name} must be <= ${max}`);
25
+ }
26
+ return value;
27
+ }
28
+ export function readRequiredString(raw, name) {
29
+ const value = String(raw ?? '').trim();
30
+ if (!value) {
31
+ throw new ArgumentError(`boss ${name} cannot be empty`);
32
+ }
33
+ return value;
14
34
  }
15
35
  /**
16
36
  * Navigate to BOSS chat page and wait for it to settle.
@@ -33,19 +53,37 @@ export async function navigateTo(page, url, waitSeconds = 1) {
33
53
  */
34
54
  export function checkAuth(data) {
35
55
  if (COOKIE_EXPIRED_CODES.has(data.code)) {
36
- throw new Error(COOKIE_EXPIRED_MSG);
56
+ throw new AuthRequiredError(BOSS_DOMAIN, COOKIE_EXPIRED_MSG);
57
+ }
58
+ }
59
+ /**
60
+ * Map BOSS code=24 ("请切换身份后再试") to a typed AuthRequiredError.
61
+ * Recruiter-only commands (recommend, joblist, stats, resume, mark,
62
+ * exchange, invite, greet, batchgreet) have no geek-side equivalent;
63
+ * surfacing this as a generic COMMAND_EXEC hides what the user must do.
64
+ * chatlist / chatmsg avoid this path by using `allowNonZero: true` and
65
+ * branching to the geek-side fetch when they see code 24.
66
+ */
67
+ function checkRecruiterSide(data) {
68
+ if (data.code === IDENTITY_MISMATCH_CODE) {
69
+ throw new AuthRequiredError(BOSS_DOMAIN, RECRUITER_ONLY_MSG);
37
70
  }
38
71
  }
39
72
  /**
40
73
  * Throw if the API response is not code 0.
41
- * Checks for cookie expiry first, then throws with the provided message.
74
+ * Checks for cookie expiry first, then identity mismatch, then throws
75
+ * with the provided message.
42
76
  */
43
77
  export function assertOk(data, errorPrefix) {
78
+ if (!data || typeof data !== 'object') {
79
+ throw new CommandExecutionError(`${errorPrefix ? `${errorPrefix}: ` : ''}Boss API returned malformed response`);
80
+ }
44
81
  if (data.code === 0)
45
82
  return;
46
83
  checkAuth(data);
84
+ checkRecruiterSide(data);
47
85
  const prefix = errorPrefix ? `${errorPrefix}: ` : '';
48
- throw new Error(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`);
86
+ throw new CommandExecutionError(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`);
49
87
  }
50
88
  /**
51
89
  * Make a credentialed XHR request via page.evaluate().
@@ -80,7 +118,19 @@ export async function bossFetch(page, url, opts = {}) {
80
118
  });
81
119
  }
82
120
  `;
83
- const data = await page.evaluate(script);
121
+ let data;
122
+ try {
123
+ data = await page.evaluate(script);
124
+ } catch (error) {
125
+ if (error instanceof AuthRequiredError || error instanceof CommandExecutionError) {
126
+ throw error;
127
+ }
128
+ const message = error instanceof Error ? error.message : String(error);
129
+ throw new CommandExecutionError(`Boss API request failed: ${message}`);
130
+ }
131
+ if (!data || typeof data !== 'object') {
132
+ throw new CommandExecutionError('Boss API returned malformed response');
133
+ }
84
134
  // Auto-check auth unless caller opts out
85
135
  if (!opts.allowNonZero && data.code !== 0) {
86
136
  assertOk(data);
@@ -95,8 +145,13 @@ export async function fetchFriendList(page, opts = {}) {
95
145
  const pageNum = opts.pageNum ?? 1;
96
146
  const jobId = opts.jobId ?? '0';
97
147
  const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/getBossFriendListV2.json?page=${pageNum}&status=0&jobId=${jobId}`;
98
- const data = await bossFetch(page, url);
99
- return data.zpData?.friendList || [];
148
+ const data = await bossFetch(page, url, { allowNonZero: opts.allowNonZero });
149
+ if (opts.allowNonZero && data.code !== 0) return data;
150
+ const list = data.zpData?.friendList;
151
+ if (!Array.isArray(list)) {
152
+ throw new CommandExecutionError('Boss friend list response did not include zpData.friendList');
153
+ }
154
+ return list;
100
155
  }
101
156
  /**
102
157
  * Fetch the recommended candidates (greetRecSortList).
@@ -104,7 +159,11 @@ export async function fetchFriendList(page, opts = {}) {
104
159
  export async function fetchRecommendList(page) {
105
160
  const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/greetRecSortList`;
106
161
  const data = await bossFetch(page, url);
107
- return data.zpData?.friendList || [];
162
+ const list = data.zpData?.friendList;
163
+ if (!Array.isArray(list)) {
164
+ throw new CommandExecutionError('Boss recommend response did not include zpData.friendList');
165
+ }
166
+ return list;
108
167
  }
109
168
  /**
110
169
  * Find a friend by encryptUid, searching through friend list and optionally greet list.
@@ -115,10 +174,14 @@ export async function findFriendByUid(page, encryptUid, opts = {}) {
115
174
  const checkGreetList = opts.checkGreetList ?? false;
116
175
  // Search friend list pages
117
176
  for (let p = 1; p <= maxPages; p++) {
118
- const friends = await fetchFriendList(page, { pageNum: p });
177
+ const result = await fetchFriendList(page, { pageNum: p, allowNonZero: opts.allowNonZero });
178
+ if (opts.allowNonZero && !Array.isArray(result)) {
179
+ return { friend: null, code: result.code };
180
+ }
181
+ const friends = Array.isArray(result) ? result : [];
119
182
  const found = friends.find((f) => f.encryptUid === encryptUid);
120
183
  if (found)
121
- return found;
184
+ return opts.allowNonZero ? { friend: found, code: 0 } : found;
122
185
  if (friends.length === 0)
123
186
  break;
124
187
  }
@@ -127,9 +190,9 @@ export async function findFriendByUid(page, encryptUid, opts = {}) {
127
190
  const greetList = await fetchRecommendList(page);
128
191
  const found = greetList.find((f) => f.encryptUid === encryptUid);
129
192
  if (found)
130
- return found;
193
+ return opts.allowNonZero ? { friend: found, code: 0 } : found;
131
194
  }
132
- return null;
195
+ return opts.allowNonZero ? { friend: null, code: 0 } : null;
133
196
  }
134
197
  // ── UI automation helpers ───────────────────────────────────────────────────
135
198
  /**
@@ -221,3 +284,185 @@ export function verbose(msg) {
221
284
  console.error(`[opencli:boss] ${msg}`);
222
285
  }
223
286
  }
287
+ // ── Geek-side helpers ────────────────────────────────────────────────────────
288
+ export const IDENTITY_MISMATCH_CODE = 24;
289
+ const GEEK_CHAT_URL = `https://${BOSS_DOMAIN}/web/geek/chat`;
290
+ /**
291
+ * Navigate to the job-seeker chat page.
292
+ * Establishes the cookie + JS-global context needed for geek-side API calls.
293
+ */
294
+ export async function navigateToGeekChat(page, waitSeconds = 2) {
295
+ await page.goto(GEEK_CHAT_URL);
296
+ await page.wait({ time: waitSeconds });
297
+ }
298
+ /**
299
+ * Read the encryptSystemId value required by the geek-side list API.
300
+ * Strategy (in order):
301
+ * 1. Vue app state / Pinia stores / $route.query (Option 1 — runtime source)
302
+ * 2. performance.getEntriesByType('resource') — parse from geekFilterByLabel URL
303
+ * that the page itself already issued (Option 2 — most deterministic)
304
+ * 3. cookie, inline <script> SSR state, known window globals, localStorage (fallbacks)
305
+ * Returns empty string if nothing is found; the API may still succeed without it.
306
+ * Caller must have navigated to the geek chat page first.
307
+ */
308
+ export async function readEncryptSystemId(page) {
309
+ const result = await page.evaluate(`
310
+ (() => {
311
+ // 1. Vue app state / Pinia / $route.query
312
+ // The chat component reads encryptSystemId from the app runtime to build
313
+ // its own geekFilterByLabel request, so the value lives in the Vue tree.
314
+ try {
315
+ const appEl = document.querySelector('#app') || document.querySelector('[data-v-app]');
316
+ const vueApp = appEl && (appEl.__vue_app__ || appEl._vei);
317
+ if (vueApp) {
318
+ // 1a. Pinia stores (Vue 3 standard state management on BOSS直聘)
319
+ const pinia = vueApp.config && vueApp.config.globalProperties.$pinia;
320
+ if (pinia && pinia.state && pinia.state.value) {
321
+ for (const store of Object.values(pinia.state.value)) {
322
+ try {
323
+ const flat = JSON.stringify(store);
324
+ if (flat.includes('encryptSystemId')) {
325
+ const m = flat.match(/"encryptSystemId":"([^"]+)"/);
326
+ if (m) return m[1];
327
+ }
328
+ } catch (_) {}
329
+ }
330
+ }
331
+ // 1b. Vue Router current route query
332
+ const router = vueApp.config && vueApp.config.globalProperties.$router;
333
+ const query = router && router.currentRoute && router.currentRoute.value && router.currentRoute.value.query;
334
+ if (query && query.encryptSystemId) return query.encryptSystemId;
335
+ }
336
+ } catch (_) {}
337
+ // 2. Performance resource entries — the page already issued geekFilterByLabel
338
+ // with encryptSystemId in the URL; read it back from the resource timing API.
339
+ try {
340
+ const entries = performance.getEntriesByType('resource');
341
+ for (const entry of entries) {
342
+ if (!entry.name.includes('geekFilterByLabel')) continue;
343
+ const u = new URL(entry.name);
344
+ const v = u.searchParams.get('encryptSystemId');
345
+ if (v) return v;
346
+ }
347
+ } catch (_) {}
348
+ // 3. cookie
349
+ try {
350
+ const m = document.cookie.match(/encryptSystemId=([^;]+)/i);
351
+ if (m) return decodeURIComponent(m[1]);
352
+ } catch (_) {}
353
+ // 4. inline <script> SSR state (Nuxt embeds server state here)
354
+ try {
355
+ for (const s of document.querySelectorAll('script:not([src])')) {
356
+ const t = s.textContent || '';
357
+ if (!t.includes('encryptSystemId')) continue;
358
+ const m = t.match(/"encryptSystemId":"([^"]+)"/);
359
+ if (m) return m[1];
360
+ }
361
+ } catch (_) {}
362
+ // 5. known BOSS / Nuxt window globals
363
+ const KNOWN = [
364
+ '__NUXT__', '__INITIAL_STATE__', '__ZP_INFO__', '__BOSS_ZP__',
365
+ 'pageGlobalVar', 'ZP_DATA', '__ZP_DATA__', '__PAGE_DATA__',
366
+ ];
367
+ for (const k of KNOWN) {
368
+ const obj = window[k];
369
+ if (!obj || typeof obj !== 'object') continue;
370
+ try {
371
+ const flat = JSON.stringify(obj);
372
+ if (!flat.includes('encryptSystemId')) continue;
373
+ const m = flat.match(/"encryptSystemId":"([^"]+)"/);
374
+ if (m) return m[1];
375
+ } catch (_) {}
376
+ }
377
+ // 6. localStorage
378
+ try {
379
+ for (let i = 0; i < localStorage.length; i++) {
380
+ const k = localStorage.key(i);
381
+ if (!k) continue;
382
+ if (k.toLowerCase().includes('encryptsystemid')) {
383
+ const v = localStorage.getItem(k);
384
+ if (v) return v;
385
+ }
386
+ const v = localStorage.getItem(k) || '';
387
+ if (v.includes('encryptSystemId')) {
388
+ const m = v.match(/"encryptSystemId":"([^"]+)"/);
389
+ if (m) return m[1];
390
+ }
391
+ }
392
+ } catch (_) {}
393
+ return '';
394
+ })()
395
+ `);
396
+ return result || '';
397
+ }
398
+ /**
399
+ * Fetch the job-seeker chat list (brief info, no securityId).
400
+ * Use fetchGeekFriendInfoList to enrich with securityId before calling chatmsg.
401
+ */
402
+ export async function fetchGeekFriendLabelList(page, opts = {}) {
403
+ const labelId = opts.labelId ?? 0;
404
+ const encryptSystemId = opts.encryptSystemId ?? '';
405
+ const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/geekFilterByLabel?labelId=${labelId}&encryptSystemId=${encodeURIComponent(encryptSystemId)}`;
406
+ const data = await bossFetch(page, url, { allowNonZero: opts.allowNonZero });
407
+ if (opts.allowNonZero && data.code !== 0) return data;
408
+ const list = data.zpData?.friendList;
409
+ if (!Array.isArray(list)) {
410
+ throw new CommandExecutionError('Boss geek chat list response did not include zpData.friendList');
411
+ }
412
+ return list;
413
+ }
414
+ /**
415
+ * Enrich a batch of geek friends with full fields including securityId.
416
+ * Processes in batches of 50 to avoid oversized request bodies.
417
+ */
418
+ export async function fetchGeekFriendInfoList(page, friendIds = []) {
419
+ if (!friendIds.length) return [];
420
+ const BATCH_SIZE = 50;
421
+ const results = [];
422
+ for (let i = 0; i < friendIds.length; i += BATCH_SIZE) {
423
+ const batch = friendIds.slice(i, i + BATCH_SIZE).map(String);
424
+ const body = `friendIds=${batch.join(',')}`;
425
+ const data = await bossFetch(page, `https://${BOSS_DOMAIN}/wapi/zprelation/friend/getGeekFriendList.json`, {
426
+ method: 'POST',
427
+ body,
428
+ });
429
+ const batchResult = data.zpData?.result;
430
+ if (!Array.isArray(batchResult)) {
431
+ throw new CommandExecutionError('Boss geek friend enrichment response did not include zpData.result');
432
+ }
433
+ results.push(...batchResult);
434
+ }
435
+ return results;
436
+ }
437
+ /**
438
+ * Find a geek-side friend by encrypted uid.
439
+ * Merges label-list and enriched data; returns null if not found.
440
+ */
441
+ export async function findGeekFriendByUid(page, encryptUid, opts = {}) {
442
+ const labelList = await fetchGeekFriendLabelList(page, { encryptSystemId: opts.encryptSystemId });
443
+ const candidate = labelList.find((f) => f.encryptFriendId === encryptUid ||
444
+ String(f.uid) === String(encryptUid) ||
445
+ String(f.friendId) === String(encryptUid));
446
+ if (!candidate) return null;
447
+ const enriched = await fetchGeekFriendInfoList(page, [candidate.friendId]);
448
+ return { ...candidate, ...(enriched[0] || {}) };
449
+ }
450
+ /**
451
+ * Fetch message history for a geek-side chat.
452
+ * friend must have .uid (boss's numeric id) and .securityId.
453
+ */
454
+ export async function fetchGeekHistoryMsg(page, friend, opts = {}) {
455
+ const pageNum = opts.page ?? 1;
456
+ const bossId = friend.uid;
457
+ const securityId = encodeURIComponent(friend.securityId || '');
458
+ const url = `https://${BOSS_DOMAIN}/wapi/zpchat/geek/historyMsg?bossId=${bossId}&securityId=${securityId}&page=${pageNum}&c=20&src=0`;
459
+ const data = await bossFetch(page, url);
460
+ const messages = data.zpData?.messages ?? data.zpData?.historyMsgList;
461
+ if (!Array.isArray(messages)) {
462
+ throw new CommandExecutionError('Boss geek history response did not include a message list');
463
+ }
464
+ if (messages.length === 0) {
465
+ throw new EmptyResultError('boss chatmsg', 'Boss returned no messages for this chat.');
466
+ }
467
+ return messages;
468
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { assertOk } from './utils.js';
4
+
5
+ describe('assertOk', () => {
6
+ it('returns silently on code 0', () => {
7
+ expect(() => assertOk({ code: 0 })).not.toThrow();
8
+ });
9
+
10
+ it('maps expired cookie codes (7, 37) to AuthRequiredError', () => {
11
+ expect(() => assertOk({ code: 7, message: 'expired' })).toThrow(AuthRequiredError);
12
+ expect(() => assertOk({ code: 37, message: 'expired' })).toThrow(AuthRequiredError);
13
+ });
14
+
15
+ it('maps code 24 (identity mismatch) to AuthRequiredError with recruiter-only hint', () => {
16
+ try {
17
+ assertOk({ code: 24, message: '请切换身份后再试' });
18
+ throw new Error('assertOk should have thrown');
19
+ } catch (err) {
20
+ expect(err).toBeInstanceOf(AuthRequiredError);
21
+ expect(String(err.message)).toContain('招聘端');
22
+ }
23
+ });
24
+
25
+ it('falls through to CommandExecutionError for other non-zero codes', () => {
26
+ expect(() => assertOk({ code: 99, message: 'something else' }))
27
+ .toThrow(CommandExecutionError);
28
+ });
29
+
30
+ it('throws CommandExecutionError on malformed (non-object) response', () => {
31
+ expect(() => assertOk(null)).toThrow(CommandExecutionError);
32
+ expect(() => assertOk('not-an-object')).toThrow(CommandExecutionError);
33
+ });
34
+ });