@jackwener/opencli 1.7.19 → 1.7.21

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 (88) hide show
  1. package/README.md +11 -9
  2. package/README.zh-CN.md +9 -10
  3. package/cli-manifest.json +239 -249
  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 +240 -11
  10. package/clis/brave/search.js +80 -0
  11. package/clis/brave/search.test.js +76 -0
  12. package/clis/duckduckgo/search.js +131 -0
  13. package/clis/duckduckgo/search.test.js +128 -0
  14. package/clis/duckduckgo/suggest.js +45 -0
  15. package/clis/duckduckgo/suggest.test.js +66 -0
  16. package/clis/facebook/feed.js +301 -56
  17. package/clis/facebook/feed.test.js +169 -0
  18. package/clis/reddit/comment.js +0 -1
  19. package/clis/reddit/frontpage.js +0 -1
  20. package/clis/reddit/home.js +0 -1
  21. package/clis/reddit/popular.js +0 -1
  22. package/clis/reddit/read.js +0 -1
  23. package/clis/reddit/read.test.js +2 -2
  24. package/clis/reddit/save.js +0 -1
  25. package/clis/reddit/saved.js +0 -1
  26. package/clis/reddit/search.js +0 -1
  27. package/clis/reddit/subreddit-info.js +0 -1
  28. package/clis/reddit/subreddit.js +0 -1
  29. package/clis/reddit/subscribe.js +0 -1
  30. package/clis/reddit/upvote.js +0 -1
  31. package/clis/reddit/upvoted.js +0 -1
  32. package/clis/reddit/user-comments.js +0 -1
  33. package/clis/reddit/user-posts.js +0 -1
  34. package/clis/reddit/user.js +0 -1
  35. package/clis/reddit/whoami.js +0 -1
  36. package/clis/rednote/rednote.test.js +65 -0
  37. package/clis/rednote/search.js +11 -5
  38. package/clis/twitter/article.js +0 -1
  39. package/clis/twitter/bookmark-folder.js +5 -4
  40. package/clis/twitter/bookmark-folder.test.js +59 -1
  41. package/clis/twitter/bookmark-folders.js +0 -1
  42. package/clis/twitter/bookmarks.js +9 -4
  43. package/clis/twitter/bookmarks.test.js +205 -0
  44. package/clis/twitter/download.js +0 -1
  45. package/clis/twitter/followers.js +0 -1
  46. package/clis/twitter/following.js +0 -1
  47. package/clis/twitter/likes.js +0 -1
  48. package/clis/twitter/list-tweets.js +0 -1
  49. package/clis/twitter/lists.js +0 -1
  50. package/clis/twitter/notifications.js +0 -1
  51. package/clis/twitter/profile.js +0 -1
  52. package/clis/twitter/search.js +0 -1
  53. package/clis/twitter/thread.js +0 -1
  54. package/clis/twitter/timeline.js +0 -1
  55. package/clis/twitter/trending.js +0 -1
  56. package/clis/twitter/tweets.js +0 -1
  57. package/clis/xiaohongshu/search.js +34 -16
  58. package/clis/xiaohongshu/search.test.js +66 -11
  59. package/clis/yahoo/search.js +92 -0
  60. package/clis/yahoo/search.test.js +94 -0
  61. package/dist/src/browser/daemon-client.d.ts +1 -0
  62. package/dist/src/browser/daemon-client.js +3 -0
  63. package/dist/src/browser/daemon-client.test.js +20 -0
  64. package/dist/src/cli.js +8 -3
  65. package/dist/src/cli.test.js +1 -0
  66. package/dist/src/daemon-utils.d.ts +18 -0
  67. package/dist/src/daemon-utils.js +37 -0
  68. package/dist/src/daemon.d.ts +1 -1
  69. package/dist/src/daemon.js +44 -13
  70. package/dist/src/daemon.test.js +42 -1
  71. package/dist/src/electron-apps.js +0 -1
  72. package/dist/src/electron-apps.test.js +1 -0
  73. package/dist/src/external-clis.yaml +12 -3
  74. package/dist/src/external.d.ts +4 -0
  75. package/dist/src/external.js +3 -0
  76. package/dist/src/external.test.js +24 -1
  77. package/dist/src/help.d.ts +5 -1
  78. package/dist/src/help.js +4 -3
  79. package/dist/src/help.test.js +5 -1
  80. package/package.json +1 -1
  81. package/clis/notion/export.js +0 -32
  82. package/clis/notion/favorites.js +0 -85
  83. package/clis/notion/new.js +0 -35
  84. package/clis/notion/read.js +0 -31
  85. package/clis/notion/search.js +0 -47
  86. package/clis/notion/sidebar.js +0 -42
  87. package/clis/notion/status.js +0 -17
  88. package/clis/notion/write.js +0 -41
@@ -0,0 +1,70 @@
1
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
+
3
+ export function requireSearchQuery(value, label = 'keyword') {
4
+ const query = String(value ?? '').trim();
5
+ if (!query) {
6
+ throw new ArgumentError(`${label} cannot be empty`);
7
+ }
8
+ return query;
9
+ }
10
+
11
+ export function requireBoundedInteger(value, defaultValue, min, max, label) {
12
+ const raw = value ?? defaultValue;
13
+ const parsed = typeof raw === 'number' ? raw : Number(raw);
14
+ if (!Number.isInteger(parsed)) {
15
+ throw new ArgumentError(`${label} must be an integer between ${min} and ${max}, got ${JSON.stringify(value)}`);
16
+ }
17
+ if (parsed < min || parsed > max) {
18
+ throw new ArgumentError(`${label} must be between ${min} and ${max}, got ${parsed}`);
19
+ }
20
+ return parsed;
21
+ }
22
+
23
+ export function requireNonNegativeInteger(value, defaultValue, label) {
24
+ const raw = value ?? defaultValue;
25
+ const parsed = typeof raw === 'number' ? raw : Number(raw);
26
+ if (!Number.isInteger(parsed) || parsed < 0) {
27
+ throw new ArgumentError(`${label} must be a non-negative integer, got ${JSON.stringify(value)}`);
28
+ }
29
+ return parsed;
30
+ }
31
+
32
+ export function unwrapBrowserResult(value) {
33
+ if (value && typeof value === 'object' && !Array.isArray(value) && 'session' in value && 'data' in value) {
34
+ return value.data;
35
+ }
36
+ return value;
37
+ }
38
+
39
+ export function requireRows(value, label) {
40
+ const rows = unwrapBrowserResult(value);
41
+ if (!Array.isArray(rows)) {
42
+ throw new CommandExecutionError(`${label} returned an unexpected payload shape; expected an array of result rows.`);
43
+ }
44
+ return rows;
45
+ }
46
+
47
+ export function toHttpsUrl(value, baseUrl) {
48
+ const raw = String(value ?? '').trim();
49
+ if (!raw) return '';
50
+ try {
51
+ const url = new URL(raw, baseUrl);
52
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
53
+ return url.href;
54
+ } catch {
55
+ return '';
56
+ }
57
+ }
58
+
59
+ export function emptySearchResults(site, query) {
60
+ return new EmptyResultError(`${site} search`, `No ${site} results matched "${query}".`);
61
+ }
62
+
63
+ export async function runBrowserStep(label, fn) {
64
+ try {
65
+ return await fn();
66
+ } catch (error) {
67
+ if (error?.code || error?.name === 'ArgumentError') throw error;
68
+ throw new CommandExecutionError(`${label} failed: ${error?.message ?? error}`);
69
+ }
70
+ }
@@ -1,10 +1,60 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { requirePage, navigateToChat, fetchFriendList } from './utils.js';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import {
4
+ requirePage, navigateToChat, navigateToGeekChat,
5
+ fetchFriendList, fetchGeekFriendLabelList, fetchGeekFriendInfoList,
6
+ readEncryptSystemId, assertOk, IDENTITY_MISMATCH_CODE,
7
+ readPositiveInteger,
8
+ } from './utils.js';
9
+
10
+ function formatMsgTime(ms) {
11
+ if (!ms) return '';
12
+ return new Date(ms).toLocaleString('zh-CN');
13
+ }
14
+
15
+ function mapBossRow(f) {
16
+ return {
17
+ name: f.name || '',
18
+ company: '',
19
+ job: f.jobName || '',
20
+ title: '',
21
+ last_msg: f.lastMessageInfo?.text || '',
22
+ last_time: f.lastTime || '',
23
+ uid: f.encryptUid || '',
24
+ security_id: f.securityId || '',
25
+ };
26
+ }
27
+
28
+ async function buildGeekRows(page, limit) {
29
+ const encryptSystemId = await readEncryptSystemId(page);
30
+ const labelList = await fetchGeekFriendLabelList(page, { encryptSystemId });
31
+ if (labelList.length === 0) {
32
+ return [];
33
+ }
34
+ const slicedLabels = labelList.slice(0, limit);
35
+ const friendIds = slicedLabels.map((f) => f.friendId).filter(Boolean);
36
+ const enriched = await fetchGeekFriendInfoList(page, friendIds);
37
+ const enrichMap = new Map(enriched.map((f) => [String(f.friendId ?? f.uid), f]));
38
+ return slicedLabels.map((f) => {
39
+ const e = enrichMap.get(String(f.friendId)) || {};
40
+ return {
41
+ name: e.name || f.name || '',
42
+ company: e.brandName || f.brandName || '',
43
+ job: e.jobName || f.jobName || '',
44
+ title: e.bossTitle || f.bossTitle || '',
45
+ last_msg: e.lastMessageInfo?.showText || e.lastMsg || f.lastMsg || '',
46
+ last_time: e.lastTime || formatMsgTime(e.lastMessageInfo?.msgTime) || formatMsgTime(f.updateTime) || '',
47
+ uid: e.encryptUid || f.encryptFriendId || String(e.uid ?? e.friendId ?? f.friendId ?? ''),
48
+ security_id: e.securityId || '',
49
+ };
50
+ });
51
+ }
52
+
3
53
  cli({
4
54
  site: 'boss',
5
55
  name: 'chatlist',
6
56
  access: 'read',
7
- description: 'BOSS直聘查看聊天列表(招聘端)',
57
+ description: 'BOSS直聘查看聊天列表(招聘端/求职端)',
8
58
  domain: 'www.zhipin.com',
9
59
  strategy: Strategy.COOKIE,
10
60
  navigateBefore: false,
@@ -12,23 +62,55 @@ cli({
12
62
  args: [
13
63
  { name: 'page', type: 'int', default: 1, help: 'Page number' },
14
64
  { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
15
- { name: 'job-id', default: '0', help: 'Filter by job ID (0=all)' },
65
+ { name: 'job-id', default: '0', help: 'Filter by job ID (0=all, boss side only)' },
66
+ { name: 'side', default: 'auto', choices: ['auto', 'boss', 'geek'], help: 'Identity side: auto (default), boss (recruiter), or geek (job-seeker)' },
16
67
  ],
17
- columns: ['name', 'job', 'last_msg', 'last_time', 'uid', 'security_id'],
68
+ columns: ['name', 'company', 'job', 'title', 'last_msg', 'last_time', 'uid', 'security_id'],
18
69
  func: async (page, kwargs) => {
19
70
  requirePage(page);
71
+ const limit = readPositiveInteger(kwargs.limit, 'chatlist --limit', 20, 100);
72
+ const pageNum = readPositiveInteger(kwargs.page, 'chatlist --page', 1);
73
+ const side = kwargs.side || 'auto';
74
+
75
+ if (side === 'boss') {
76
+ await navigateToChat(page);
77
+ const friends = await fetchFriendList(page, {
78
+ pageNum,
79
+ jobId: kwargs['job-id'] || '0',
80
+ });
81
+ if (friends.length === 0)
82
+ throw new EmptyResultError('boss chatlist', 'No recruiter-side chat sessions were returned.');
83
+ return friends.slice(0, limit).map(mapBossRow);
84
+ }
85
+
86
+ if (side === 'geek') {
87
+ await navigateToGeekChat(page);
88
+ const rows = await buildGeekRows(page, limit);
89
+ if (rows.length === 0)
90
+ throw new EmptyResultError('boss chatlist', 'No job-seeker-side chat sessions were returned.');
91
+ return rows;
92
+ }
93
+
94
+ // auto: try recruiter first, fall back to geek on identity mismatch
20
95
  await navigateToChat(page);
21
- const friends = await fetchFriendList(page, {
22
- pageNum: kwargs.page || 1,
96
+ const bossResult = await fetchFriendList(page, {
97
+ pageNum,
23
98
  jobId: kwargs['job-id'] || '0',
99
+ allowNonZero: true,
24
100
  });
25
- return friends.slice(0, kwargs.limit || 20).map((f) => ({
26
- name: f.name || '',
27
- job: f.jobName || '',
28
- last_msg: f.lastMessageInfo?.text || '',
29
- last_time: f.lastTime || '',
30
- uid: f.encryptUid || '',
31
- security_id: f.securityId || '',
32
- }));
101
+ if (Array.isArray(bossResult)) {
102
+ if (bossResult.length === 0)
103
+ throw new EmptyResultError('boss chatlist', 'No recruiter-side chat sessions were returned.');
104
+ return bossResult.slice(0, limit).map(mapBossRow);
105
+ }
106
+ if (bossResult.code === IDENTITY_MISMATCH_CODE) {
107
+ await navigateToGeekChat(page);
108
+ const rows = await buildGeekRows(page, limit);
109
+ if (rows.length === 0)
110
+ throw new EmptyResultError('boss chatlist', 'No job-seeker-side chat sessions were returned.');
111
+ return rows;
112
+ }
113
+ assertOk(bossResult);
114
+ throw new CommandExecutionError('Boss chatlist returned an unexpected response');
33
115
  },
34
116
  });
@@ -0,0 +1,211 @@
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 './chatlist.js';
5
+
6
+ const BOSS_FRIEND = {
7
+ name: '张三',
8
+ jobName: '后端工程师',
9
+ lastMessageInfo: { text: '你好' },
10
+ lastTime: '2024-01-01 10:00',
11
+ encryptUid: 'enc-boss-uid',
12
+ securityId: 'boss-sec-id',
13
+ };
14
+
15
+ const GEEK_LABEL_FRIEND = {
16
+ friendId: 12345,
17
+ name: '李四',
18
+ brandName: '字节跳动',
19
+ jobName: '产品经理',
20
+ bossTitle: 'HR',
21
+ lastMsg: '感谢投递',
22
+ updateTime: 1704067200000,
23
+ encryptFriendId: 'enc-geek-uid',
24
+ };
25
+
26
+ const GEEK_ENRICHED = {
27
+ friendId: 12345,
28
+ uid: 99999,
29
+ name: '李四',
30
+ brandName: '字节跳动',
31
+ jobName: '产品经理',
32
+ bossTitle: 'HR总监',
33
+ encryptUid: 'enc-geek-uid',
34
+ securityId: 'geek-sec-id',
35
+ lastMessageInfo: { showText: '感谢投递', msgTime: 1704067200000 },
36
+ lastTime: '2024-01-01',
37
+ };
38
+
39
+ function createPageMock(evaluateImpl) {
40
+ return {
41
+ goto: vi.fn().mockResolvedValue(undefined),
42
+ wait: vi.fn().mockResolvedValue(undefined),
43
+ evaluate: vi.fn().mockImplementation(evaluateImpl),
44
+ };
45
+ }
46
+
47
+ describe('boss chatlist', () => {
48
+ const command = getRegistry().get('boss/chatlist');
49
+
50
+ it('--side boss preserves existing behavior with 8-column output', async () => {
51
+ const page = createPageMock(async (script) => {
52
+ if (script.includes('getBossFriendListV2')) {
53
+ return { code: 0, zpData: { friendList: [BOSS_FRIEND] } };
54
+ }
55
+ return {};
56
+ });
57
+ const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'boss' });
58
+ expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/chat/index'));
59
+ expect(rows).toHaveLength(1);
60
+ expect(rows[0]).toMatchObject({
61
+ name: '张三',
62
+ company: '',
63
+ job: '后端工程师',
64
+ title: '',
65
+ last_msg: '你好',
66
+ uid: 'enc-boss-uid',
67
+ security_id: 'boss-sec-id',
68
+ });
69
+ });
70
+
71
+ it('--side geek maps enriched getGeekFriendList data into 8 columns', async () => {
72
+ const page = createPageMock(async (script) => {
73
+ if (script.includes('document.cookie')) return 'test-enc-sys-id';
74
+ if (script.includes('geekFilterByLabel')) {
75
+ return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } };
76
+ }
77
+ if (script.includes('getGeekFriendList.json')) {
78
+ return { code: 0, zpData: { result: [GEEK_ENRICHED] } };
79
+ }
80
+ return {};
81
+ });
82
+ const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' });
83
+ expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/geek/chat'));
84
+ expect(rows).toHaveLength(1);
85
+ expect(rows[0]).toMatchObject({
86
+ name: '李四',
87
+ company: '字节跳动',
88
+ job: '产品经理',
89
+ title: 'HR总监',
90
+ uid: 'enc-geek-uid',
91
+ security_id: 'geek-sec-id',
92
+ });
93
+ });
94
+
95
+ it('--side geek falls back to label fields when enrichment has no match', async () => {
96
+ const page = createPageMock(async (script) => {
97
+ if (script.includes('document.cookie')) return 'test-enc-sys-id';
98
+ if (script.includes('geekFilterByLabel')) {
99
+ return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } };
100
+ }
101
+ if (script.includes('getGeekFriendList.json')) {
102
+ return { code: 0, zpData: { result: [] } };
103
+ }
104
+ return {};
105
+ });
106
+ const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' });
107
+ expect(rows).toHaveLength(1);
108
+ expect(rows[0].name).toBe('李四');
109
+ expect(rows[0].company).toBe('字节跳动');
110
+ expect(rows[0].security_id).toBe('');
111
+ });
112
+
113
+ it('rejects invalid --limit before navigating', async () => {
114
+ const page = createPageMock(async () => ({}));
115
+ await expect(
116
+ command.func(page, { page: 1, limit: 0, 'job-id': '0', side: 'geek' })
117
+ ).rejects.toBeInstanceOf(ArgumentError);
118
+ expect(page.goto).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('--side geek reports a true empty chat list as EmptyResultError', async () => {
122
+ const page = createPageMock(async (script) => {
123
+ if (script.includes('document.cookie')) return 'test-enc-sys-id';
124
+ if (script.includes('geekFilterByLabel')) {
125
+ return { code: 0, zpData: { friendList: [] } };
126
+ }
127
+ return {};
128
+ });
129
+ await expect(
130
+ command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' })
131
+ ).rejects.toBeInstanceOf(EmptyResultError);
132
+ });
133
+
134
+ it('treats malformed geek enrichment payload as CommandExecutionError', async () => {
135
+ const page = createPageMock(async (script) => {
136
+ if (script.includes('document.cookie')) return 'test-enc-sys-id';
137
+ if (script.includes('geekFilterByLabel')) {
138
+ return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } };
139
+ }
140
+ if (script.includes('getGeekFriendList.json')) {
141
+ return { code: 0, zpData: {} };
142
+ }
143
+ return {};
144
+ });
145
+ await expect(
146
+ command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'geek' })
147
+ ).rejects.toBeInstanceOf(CommandExecutionError);
148
+ });
149
+
150
+ it('treats null Boss API payload as CommandExecutionError', async () => {
151
+ const page = createPageMock(async (script) => {
152
+ if (script.includes('getBossFriendListV2')) return null;
153
+ return {};
154
+ });
155
+ await expect(
156
+ command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'boss' })
157
+ ).rejects.toBeInstanceOf(CommandExecutionError);
158
+ });
159
+
160
+ it('maps expired Boss cookies to AuthRequiredError', async () => {
161
+ const page = createPageMock(async (script) => {
162
+ if (script.includes('getBossFriendListV2')) {
163
+ return { code: 7, message: 'Cookie 已过期' };
164
+ }
165
+ return {};
166
+ });
167
+ await expect(
168
+ command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'boss' })
169
+ ).rejects.toBeInstanceOf(AuthRequiredError);
170
+ });
171
+
172
+ it('--side auto falls back to geek when recruiter returns code 24', async () => {
173
+ const page = createPageMock(async (script) => {
174
+ if (script.includes('getBossFriendListV2')) {
175
+ return { code: 24, message: '请切换身份后再试' };
176
+ }
177
+ if (script.includes('document.cookie')) return 'test-enc-sys-id';
178
+ if (script.includes('geekFilterByLabel')) {
179
+ return { code: 0, zpData: { friendList: [GEEK_LABEL_FRIEND] } };
180
+ }
181
+ if (script.includes('getGeekFriendList.json')) {
182
+ return { code: 0, zpData: { result: [GEEK_ENRICHED] } };
183
+ }
184
+ return {};
185
+ });
186
+ const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'auto' });
187
+ expect(rows).toHaveLength(1);
188
+ expect(rows[0].company).toBe('字节跳动');
189
+ expect(page.goto).toHaveBeenCalledWith(expect.stringContaining('/web/geek/chat'));
190
+ });
191
+
192
+ it('--side auto uses recruiter results when code 0 and does not call geek API', async () => {
193
+ const page = createPageMock(async (script) => {
194
+ if (script.includes('getBossFriendListV2')) {
195
+ return { code: 0, zpData: { friendList: [BOSS_FRIEND] } };
196
+ }
197
+ return {};
198
+ });
199
+ const rows = await command.func(page, { page: 1, limit: 20, 'job-id': '0', side: 'auto' });
200
+ expect(rows).toHaveLength(1);
201
+ expect(rows[0].name).toBe('张三');
202
+ const evaluateCalls = page.evaluate.mock.calls.map((c) => c[0]);
203
+ expect(evaluateCalls.some((s) => s.includes('geekFilterByLabel'))).toBe(false);
204
+ });
205
+
206
+ it('registers --side as a choices-constrained arg defaulting to auto', () => {
207
+ const sideArg = command.args.find((a) => a.name === 'side');
208
+ expect(sideArg?.choices).toEqual(['auto', 'boss', 'geek']);
209
+ expect(sideArg?.default).toBe('auto');
210
+ });
211
+ });
@@ -1,10 +1,72 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { requirePage, navigateToChat, bossFetch, findFriendByUid } from './utils.js';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import {
4
+ requirePage, navigateToChat, navigateToGeekChat,
5
+ bossFetch, findFriendByUid, findGeekFriendByUid,
6
+ fetchGeekHistoryMsg, readEncryptSystemId,
7
+ assertOk, IDENTITY_MISMATCH_CODE,
8
+ readPositiveInteger, readRequiredString,
9
+ } from './utils.js';
10
+
11
+ const TYPE_MAP = {
12
+ 1: '文本', 2: '图片', 3: '招呼', 4: '简历', 5: '系统',
13
+ 6: '名片', 7: '语音', 8: '视频', 9: '表情',
14
+ };
15
+
16
+ function mapBossMsg(m, friend) {
17
+ const fromObj = m.from || {};
18
+ const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false;
19
+ return {
20
+ from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name),
21
+ type: TYPE_MAP[m.type] || `其他(${m.type})`,
22
+ text: m.text || m.body?.text || '',
23
+ time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '',
24
+ };
25
+ }
26
+
27
+ function mapGeekMsg(m, friend) {
28
+ const fromUid = m.from && m.from.uid;
29
+ const isFromBoss = fromUid != null && String(fromUid) === String(friend.uid);
30
+ return {
31
+ from: isFromBoss ? '对方' : '我',
32
+ type: TYPE_MAP[m.type] || `其他(${m.type})`,
33
+ text: m.text || m.body?.text || m.body?.content || m.body?.showText ||
34
+ JSON.stringify(m.body || {}).slice(0, 120),
35
+ time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '',
36
+ };
37
+ }
38
+
39
+ async function bossChatMsg(page, kwargs, existingFriend) {
40
+ const friend = existingFriend ?? await findFriendByUid(page, kwargs.uid);
41
+ if (!friend) throw new EmptyResultError('boss chatmsg', '未找到该候选人');
42
+ if (!friend.securityId) throw new CommandExecutionError('该聊天缺少 securityId,无法获取历史消息');
43
+ const gid = friend.uid;
44
+ const securityId = encodeURIComponent(friend.securityId);
45
+ const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`;
46
+ const msgData = await bossFetch(page, msgUrl);
47
+ const messages = msgData.zpData?.messages ?? msgData.zpData?.historyMsgList;
48
+ if (!Array.isArray(messages)) {
49
+ throw new CommandExecutionError('Boss recruiter history response did not include a message list');
50
+ }
51
+ if (messages.length === 0) {
52
+ throw new EmptyResultError('boss chatmsg', 'Boss returned no messages for this chat.');
53
+ }
54
+ return messages.map((m) => mapBossMsg(m, friend));
55
+ }
56
+
57
+ async function geekChatMsg(page, kwargs, encryptSystemId) {
58
+ const friend = await findGeekFriendByUid(page, kwargs.uid, { encryptSystemId });
59
+ if (!friend) throw new EmptyResultError('boss chatmsg', '未找到该聊天(geek 侧)');
60
+ if (!friend.securityId) throw new CommandExecutionError('该聊天缺少 securityId,无法获取历史消息');
61
+ const messages = await fetchGeekHistoryMsg(page, friend, { page: kwargs.page });
62
+ return messages.map((m) => mapGeekMsg(m, friend));
63
+ }
64
+
3
65
  cli({
4
66
  site: 'boss',
5
67
  name: 'chatmsg',
6
68
  access: 'read',
7
- description: 'BOSS直聘查看与候选人的聊天消息',
69
+ description: 'BOSS直聘查看聊天消息历史(招聘端/求职端)',
8
70
  domain: 'www.zhipin.com',
9
71
  strategy: Strategy.COOKIE,
10
72
  navigateBefore: false,
@@ -12,32 +74,44 @@ cli({
12
74
  args: [
13
75
  { name: 'uid', required: true, positional: true, help: 'Encrypted UID (from chatlist)' },
14
76
  { name: 'page', type: 'int', default: 1, help: 'Page number' },
77
+ { name: 'side', default: 'auto', choices: ['auto', 'boss', 'geek'], help: 'Identity side: auto (default), boss (recruiter), or geek (job-seeker)' },
15
78
  ],
16
79
  columns: ['from', 'type', 'text', 'time'],
17
80
  func: async (page, kwargs) => {
18
81
  requirePage(page);
82
+ const uid = readRequiredString(kwargs.uid, 'chatmsg uid');
83
+ const pageNum = readPositiveInteger(kwargs.page, 'chatmsg --page', 1);
84
+ const normalizedKwargs = { ...kwargs, uid, page: pageNum };
85
+ const side = kwargs.side || 'auto';
86
+
87
+ if (side === 'boss') {
88
+ await navigateToChat(page);
89
+ return await bossChatMsg(page, normalizedKwargs);
90
+ }
91
+
92
+ if (side === 'geek') {
93
+ await navigateToGeekChat(page);
94
+ const encryptSystemId = await readEncryptSystemId(page);
95
+ return await geekChatMsg(page, normalizedKwargs, encryptSystemId);
96
+ }
97
+
98
+ // auto: try recruiter first, fall back to geek when not found or identity mismatch
19
99
  await navigateToChat(page);
20
- const friend = await findFriendByUid(page, kwargs.uid);
21
- if (!friend)
22
- throw new Error('未找到该候选人');
23
- const gid = friend.uid;
24
- const securityId = encodeURIComponent(friend.securityId);
25
- const msgUrl = `https://www.zhipin.com/wapi/zpchat/boss/historyMsg?gid=${gid}&securityId=${securityId}&page=${kwargs.page}&c=20&src=0`;
26
- const msgData = await bossFetch(page, msgUrl);
27
- const TYPE_MAP = {
28
- 1: '文本', 2: '图片', 3: '招呼', 4: '简历', 5: '系统',
29
- 6: '名片', 7: '语音', 8: '视频', 9: '表情',
30
- };
31
- const messages = msgData.zpData?.messages || msgData.zpData?.historyMsgList || [];
32
- return messages.map((m) => {
33
- const fromObj = m.from || {};
34
- const isSelf = typeof fromObj === 'object' ? fromObj.uid !== friend.uid : false;
35
- return {
36
- from: isSelf ? '我' : (typeof fromObj === 'object' ? fromObj.name : friend.name),
37
- type: TYPE_MAP[m.type] || '其他(' + m.type + ')',
38
- text: m.text || m.body?.text || '',
39
- time: m.time ? new Date(m.time).toLocaleString('zh-CN') : '',
40
- };
41
- });
100
+ const bossResult = await findFriendByUid(page, uid, { allowNonZero: true });
101
+ if (bossResult?.friend) {
102
+ return await bossChatMsg(page, normalizedKwargs, bossResult.friend);
103
+ }
104
+ // Not found or identity mismatch — check for hard errors before falling back
105
+ if (bossResult?.code && bossResult.code !== 0 && bossResult.code !== IDENTITY_MISMATCH_CODE) {
106
+ assertOk(bossResult);
107
+ }
108
+ // Fall back to geek side
109
+ await navigateToGeekChat(page);
110
+ const encryptSystemId = await readEncryptSystemId(page);
111
+ const geekFriend = await findGeekFriendByUid(page, uid, { encryptSystemId });
112
+ if (!geekFriend) throw new EmptyResultError('boss chatmsg', 'uid 在招聘端与求职端聊天列表中均未找到');
113
+ if (!geekFriend.securityId) throw new CommandExecutionError('该聊天缺少 securityId,无法获取历史消息');
114
+ const messages = await fetchGeekHistoryMsg(page, geekFriend, { page: pageNum });
115
+ return messages.map((m) => mapGeekMsg(m, geekFriend));
42
116
  },
43
117
  });