@jackwener/opencli 1.7.20 → 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 (57) hide show
  1. package/cli-manifest.json +233 -72
  2. package/clis/_shared/search-adapter.js +70 -0
  3. package/clis/boss/chatlist.js +96 -14
  4. package/clis/boss/chatlist.test.js +211 -0
  5. package/clis/boss/chatmsg.js +98 -24
  6. package/clis/boss/chatmsg.test.js +230 -0
  7. package/clis/boss/utils.js +240 -11
  8. package/clis/brave/search.js +80 -0
  9. package/clis/brave/search.test.js +76 -0
  10. package/clis/duckduckgo/search.js +131 -0
  11. package/clis/duckduckgo/search.test.js +128 -0
  12. package/clis/duckduckgo/suggest.js +45 -0
  13. package/clis/duckduckgo/suggest.test.js +66 -0
  14. package/clis/facebook/feed.js +301 -56
  15. package/clis/facebook/feed.test.js +169 -0
  16. package/clis/reddit/comment.js +0 -1
  17. package/clis/reddit/frontpage.js +0 -1
  18. package/clis/reddit/home.js +0 -1
  19. package/clis/reddit/popular.js +0 -1
  20. package/clis/reddit/read.js +0 -1
  21. package/clis/reddit/read.test.js +2 -2
  22. package/clis/reddit/save.js +0 -1
  23. package/clis/reddit/saved.js +0 -1
  24. package/clis/reddit/search.js +0 -1
  25. package/clis/reddit/subreddit-info.js +0 -1
  26. package/clis/reddit/subreddit.js +0 -1
  27. package/clis/reddit/subscribe.js +0 -1
  28. package/clis/reddit/upvote.js +0 -1
  29. package/clis/reddit/upvoted.js +0 -1
  30. package/clis/reddit/user-comments.js +0 -1
  31. package/clis/reddit/user-posts.js +0 -1
  32. package/clis/reddit/user.js +0 -1
  33. package/clis/reddit/whoami.js +0 -1
  34. package/clis/rednote/rednote.test.js +65 -0
  35. package/clis/rednote/search.js +11 -5
  36. package/clis/twitter/article.js +0 -1
  37. package/clis/twitter/bookmark-folder.js +0 -1
  38. package/clis/twitter/bookmark-folders.js +0 -1
  39. package/clis/twitter/bookmarks.js +0 -1
  40. package/clis/twitter/download.js +0 -1
  41. package/clis/twitter/followers.js +0 -1
  42. package/clis/twitter/following.js +0 -1
  43. package/clis/twitter/likes.js +0 -1
  44. package/clis/twitter/list-tweets.js +0 -1
  45. package/clis/twitter/lists.js +0 -1
  46. package/clis/twitter/notifications.js +0 -1
  47. package/clis/twitter/profile.js +0 -1
  48. package/clis/twitter/search.js +0 -1
  49. package/clis/twitter/thread.js +0 -1
  50. package/clis/twitter/timeline.js +0 -1
  51. package/clis/twitter/trending.js +0 -1
  52. package/clis/twitter/tweets.js +0 -1
  53. package/clis/xiaohongshu/search.js +34 -16
  54. package/clis/xiaohongshu/search.test.js +66 -11
  55. package/clis/yahoo/search.js +92 -0
  56. package/clis/yahoo/search.test.js +94 -0
  57. 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,3 +1,5 @@
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`;
@@ -10,7 +12,24 @@ const DEFAULT_TIMEOUT = 15_000;
10
12
  */
11
13
  export function requirePage(page) {
12
14
  if (!page)
13
- throw new Error('Browser page required');
15
+ throw new CommandExecutionError('Browser page required');
16
+ }
17
+ export function readPositiveInteger(raw, name, fallback, max) {
18
+ const value = raw === undefined || raw === null || raw === '' ? fallback : Number(raw);
19
+ if (!Number.isInteger(value) || value < 1) {
20
+ throw new ArgumentError(`boss ${name} must be a positive integer`);
21
+ }
22
+ if (max !== undefined && value > max) {
23
+ throw new ArgumentError(`boss ${name} must be <= ${max}`);
24
+ }
25
+ return value;
26
+ }
27
+ export function readRequiredString(raw, name) {
28
+ const value = String(raw ?? '').trim();
29
+ if (!value) {
30
+ throw new ArgumentError(`boss ${name} cannot be empty`);
31
+ }
32
+ return value;
14
33
  }
15
34
  /**
16
35
  * Navigate to BOSS chat page and wait for it to settle.
@@ -33,7 +52,7 @@ export async function navigateTo(page, url, waitSeconds = 1) {
33
52
  */
34
53
  export function checkAuth(data) {
35
54
  if (COOKIE_EXPIRED_CODES.has(data.code)) {
36
- throw new Error(COOKIE_EXPIRED_MSG);
55
+ throw new AuthRequiredError(BOSS_DOMAIN, COOKIE_EXPIRED_MSG);
37
56
  }
38
57
  }
39
58
  /**
@@ -41,11 +60,14 @@ export function checkAuth(data) {
41
60
  * Checks for cookie expiry first, then throws with the provided message.
42
61
  */
43
62
  export function assertOk(data, errorPrefix) {
63
+ if (!data || typeof data !== 'object') {
64
+ throw new CommandExecutionError(`${errorPrefix ? `${errorPrefix}: ` : ''}Boss API returned malformed response`);
65
+ }
44
66
  if (data.code === 0)
45
67
  return;
46
68
  checkAuth(data);
47
69
  const prefix = errorPrefix ? `${errorPrefix}: ` : '';
48
- throw new Error(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`);
70
+ throw new CommandExecutionError(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`);
49
71
  }
50
72
  /**
51
73
  * Make a credentialed XHR request via page.evaluate().
@@ -80,7 +102,19 @@ export async function bossFetch(page, url, opts = {}) {
80
102
  });
81
103
  }
82
104
  `;
83
- const data = await page.evaluate(script);
105
+ let data;
106
+ try {
107
+ data = await page.evaluate(script);
108
+ } catch (error) {
109
+ if (error instanceof AuthRequiredError || error instanceof CommandExecutionError) {
110
+ throw error;
111
+ }
112
+ const message = error instanceof Error ? error.message : String(error);
113
+ throw new CommandExecutionError(`Boss API request failed: ${message}`);
114
+ }
115
+ if (!data || typeof data !== 'object') {
116
+ throw new CommandExecutionError('Boss API returned malformed response');
117
+ }
84
118
  // Auto-check auth unless caller opts out
85
119
  if (!opts.allowNonZero && data.code !== 0) {
86
120
  assertOk(data);
@@ -95,8 +129,13 @@ export async function fetchFriendList(page, opts = {}) {
95
129
  const pageNum = opts.pageNum ?? 1;
96
130
  const jobId = opts.jobId ?? '0';
97
131
  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 || [];
132
+ const data = await bossFetch(page, url, { allowNonZero: opts.allowNonZero });
133
+ if (opts.allowNonZero && data.code !== 0) return data;
134
+ const list = data.zpData?.friendList;
135
+ if (!Array.isArray(list)) {
136
+ throw new CommandExecutionError('Boss friend list response did not include zpData.friendList');
137
+ }
138
+ return list;
100
139
  }
101
140
  /**
102
141
  * Fetch the recommended candidates (greetRecSortList).
@@ -104,7 +143,11 @@ export async function fetchFriendList(page, opts = {}) {
104
143
  export async function fetchRecommendList(page) {
105
144
  const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/greetRecSortList`;
106
145
  const data = await bossFetch(page, url);
107
- return data.zpData?.friendList || [];
146
+ const list = data.zpData?.friendList;
147
+ if (!Array.isArray(list)) {
148
+ throw new CommandExecutionError('Boss recommend response did not include zpData.friendList');
149
+ }
150
+ return list;
108
151
  }
109
152
  /**
110
153
  * Find a friend by encryptUid, searching through friend list and optionally greet list.
@@ -115,10 +158,14 @@ export async function findFriendByUid(page, encryptUid, opts = {}) {
115
158
  const checkGreetList = opts.checkGreetList ?? false;
116
159
  // Search friend list pages
117
160
  for (let p = 1; p <= maxPages; p++) {
118
- const friends = await fetchFriendList(page, { pageNum: p });
161
+ const result = await fetchFriendList(page, { pageNum: p, allowNonZero: opts.allowNonZero });
162
+ if (opts.allowNonZero && !Array.isArray(result)) {
163
+ return { friend: null, code: result.code };
164
+ }
165
+ const friends = Array.isArray(result) ? result : [];
119
166
  const found = friends.find((f) => f.encryptUid === encryptUid);
120
167
  if (found)
121
- return found;
168
+ return opts.allowNonZero ? { friend: found, code: 0 } : found;
122
169
  if (friends.length === 0)
123
170
  break;
124
171
  }
@@ -127,9 +174,9 @@ export async function findFriendByUid(page, encryptUid, opts = {}) {
127
174
  const greetList = await fetchRecommendList(page);
128
175
  const found = greetList.find((f) => f.encryptUid === encryptUid);
129
176
  if (found)
130
- return found;
177
+ return opts.allowNonZero ? { friend: found, code: 0 } : found;
131
178
  }
132
- return null;
179
+ return opts.allowNonZero ? { friend: null, code: 0 } : null;
133
180
  }
134
181
  // ── UI automation helpers ───────────────────────────────────────────────────
135
182
  /**
@@ -221,3 +268,185 @@ export function verbose(msg) {
221
268
  console.error(`[opencli:boss] ${msg}`);
222
269
  }
223
270
  }
271
+ // ── Geek-side helpers ────────────────────────────────────────────────────────
272
+ export const IDENTITY_MISMATCH_CODE = 24;
273
+ const GEEK_CHAT_URL = `https://${BOSS_DOMAIN}/web/geek/chat`;
274
+ /**
275
+ * Navigate to the job-seeker chat page.
276
+ * Establishes the cookie + JS-global context needed for geek-side API calls.
277
+ */
278
+ export async function navigateToGeekChat(page, waitSeconds = 2) {
279
+ await page.goto(GEEK_CHAT_URL);
280
+ await page.wait({ time: waitSeconds });
281
+ }
282
+ /**
283
+ * Read the encryptSystemId value required by the geek-side list API.
284
+ * Strategy (in order):
285
+ * 1. Vue app state / Pinia stores / $route.query (Option 1 — runtime source)
286
+ * 2. performance.getEntriesByType('resource') — parse from geekFilterByLabel URL
287
+ * that the page itself already issued (Option 2 — most deterministic)
288
+ * 3. cookie, inline <script> SSR state, known window globals, localStorage (fallbacks)
289
+ * Returns empty string if nothing is found; the API may still succeed without it.
290
+ * Caller must have navigated to the geek chat page first.
291
+ */
292
+ export async function readEncryptSystemId(page) {
293
+ const result = await page.evaluate(`
294
+ (() => {
295
+ // 1. Vue app state / Pinia / $route.query
296
+ // The chat component reads encryptSystemId from the app runtime to build
297
+ // its own geekFilterByLabel request, so the value lives in the Vue tree.
298
+ try {
299
+ const appEl = document.querySelector('#app') || document.querySelector('[data-v-app]');
300
+ const vueApp = appEl && (appEl.__vue_app__ || appEl._vei);
301
+ if (vueApp) {
302
+ // 1a. Pinia stores (Vue 3 standard state management on BOSS直聘)
303
+ const pinia = vueApp.config && vueApp.config.globalProperties.$pinia;
304
+ if (pinia && pinia.state && pinia.state.value) {
305
+ for (const store of Object.values(pinia.state.value)) {
306
+ try {
307
+ const flat = JSON.stringify(store);
308
+ if (flat.includes('encryptSystemId')) {
309
+ const m = flat.match(/"encryptSystemId":"([^"]+)"/);
310
+ if (m) return m[1];
311
+ }
312
+ } catch (_) {}
313
+ }
314
+ }
315
+ // 1b. Vue Router current route query
316
+ const router = vueApp.config && vueApp.config.globalProperties.$router;
317
+ const query = router && router.currentRoute && router.currentRoute.value && router.currentRoute.value.query;
318
+ if (query && query.encryptSystemId) return query.encryptSystemId;
319
+ }
320
+ } catch (_) {}
321
+ // 2. Performance resource entries — the page already issued geekFilterByLabel
322
+ // with encryptSystemId in the URL; read it back from the resource timing API.
323
+ try {
324
+ const entries = performance.getEntriesByType('resource');
325
+ for (const entry of entries) {
326
+ if (!entry.name.includes('geekFilterByLabel')) continue;
327
+ const u = new URL(entry.name);
328
+ const v = u.searchParams.get('encryptSystemId');
329
+ if (v) return v;
330
+ }
331
+ } catch (_) {}
332
+ // 3. cookie
333
+ try {
334
+ const m = document.cookie.match(/encryptSystemId=([^;]+)/i);
335
+ if (m) return decodeURIComponent(m[1]);
336
+ } catch (_) {}
337
+ // 4. inline <script> SSR state (Nuxt embeds server state here)
338
+ try {
339
+ for (const s of document.querySelectorAll('script:not([src])')) {
340
+ const t = s.textContent || '';
341
+ if (!t.includes('encryptSystemId')) continue;
342
+ const m = t.match(/"encryptSystemId":"([^"]+)"/);
343
+ if (m) return m[1];
344
+ }
345
+ } catch (_) {}
346
+ // 5. known BOSS / Nuxt window globals
347
+ const KNOWN = [
348
+ '__NUXT__', '__INITIAL_STATE__', '__ZP_INFO__', '__BOSS_ZP__',
349
+ 'pageGlobalVar', 'ZP_DATA', '__ZP_DATA__', '__PAGE_DATA__',
350
+ ];
351
+ for (const k of KNOWN) {
352
+ const obj = window[k];
353
+ if (!obj || typeof obj !== 'object') continue;
354
+ try {
355
+ const flat = JSON.stringify(obj);
356
+ if (!flat.includes('encryptSystemId')) continue;
357
+ const m = flat.match(/"encryptSystemId":"([^"]+)"/);
358
+ if (m) return m[1];
359
+ } catch (_) {}
360
+ }
361
+ // 6. localStorage
362
+ try {
363
+ for (let i = 0; i < localStorage.length; i++) {
364
+ const k = localStorage.key(i);
365
+ if (!k) continue;
366
+ if (k.toLowerCase().includes('encryptsystemid')) {
367
+ const v = localStorage.getItem(k);
368
+ if (v) return v;
369
+ }
370
+ const v = localStorage.getItem(k) || '';
371
+ if (v.includes('encryptSystemId')) {
372
+ const m = v.match(/"encryptSystemId":"([^"]+)"/);
373
+ if (m) return m[1];
374
+ }
375
+ }
376
+ } catch (_) {}
377
+ return '';
378
+ })()
379
+ `);
380
+ return result || '';
381
+ }
382
+ /**
383
+ * Fetch the job-seeker chat list (brief info, no securityId).
384
+ * Use fetchGeekFriendInfoList to enrich with securityId before calling chatmsg.
385
+ */
386
+ export async function fetchGeekFriendLabelList(page, opts = {}) {
387
+ const labelId = opts.labelId ?? 0;
388
+ const encryptSystemId = opts.encryptSystemId ?? '';
389
+ const url = `https://${BOSS_DOMAIN}/wapi/zprelation/friend/geekFilterByLabel?labelId=${labelId}&encryptSystemId=${encodeURIComponent(encryptSystemId)}`;
390
+ const data = await bossFetch(page, url, { allowNonZero: opts.allowNonZero });
391
+ if (opts.allowNonZero && data.code !== 0) return data;
392
+ const list = data.zpData?.friendList;
393
+ if (!Array.isArray(list)) {
394
+ throw new CommandExecutionError('Boss geek chat list response did not include zpData.friendList');
395
+ }
396
+ return list;
397
+ }
398
+ /**
399
+ * Enrich a batch of geek friends with full fields including securityId.
400
+ * Processes in batches of 50 to avoid oversized request bodies.
401
+ */
402
+ export async function fetchGeekFriendInfoList(page, friendIds = []) {
403
+ if (!friendIds.length) return [];
404
+ const BATCH_SIZE = 50;
405
+ const results = [];
406
+ for (let i = 0; i < friendIds.length; i += BATCH_SIZE) {
407
+ const batch = friendIds.slice(i, i + BATCH_SIZE).map(String);
408
+ const body = `friendIds=${batch.join(',')}`;
409
+ const data = await bossFetch(page, `https://${BOSS_DOMAIN}/wapi/zprelation/friend/getGeekFriendList.json`, {
410
+ method: 'POST',
411
+ body,
412
+ });
413
+ const batchResult = data.zpData?.result;
414
+ if (!Array.isArray(batchResult)) {
415
+ throw new CommandExecutionError('Boss geek friend enrichment response did not include zpData.result');
416
+ }
417
+ results.push(...batchResult);
418
+ }
419
+ return results;
420
+ }
421
+ /**
422
+ * Find a geek-side friend by encrypted uid.
423
+ * Merges label-list and enriched data; returns null if not found.
424
+ */
425
+ export async function findGeekFriendByUid(page, encryptUid, opts = {}) {
426
+ const labelList = await fetchGeekFriendLabelList(page, { encryptSystemId: opts.encryptSystemId });
427
+ const candidate = labelList.find((f) => f.encryptFriendId === encryptUid ||
428
+ String(f.uid) === String(encryptUid) ||
429
+ String(f.friendId) === String(encryptUid));
430
+ if (!candidate) return null;
431
+ const enriched = await fetchGeekFriendInfoList(page, [candidate.friendId]);
432
+ return { ...candidate, ...(enriched[0] || {}) };
433
+ }
434
+ /**
435
+ * Fetch message history for a geek-side chat.
436
+ * friend must have .uid (boss's numeric id) and .securityId.
437
+ */
438
+ export async function fetchGeekHistoryMsg(page, friend, opts = {}) {
439
+ const pageNum = opts.page ?? 1;
440
+ const bossId = friend.uid;
441
+ const securityId = encodeURIComponent(friend.securityId || '');
442
+ const url = `https://${BOSS_DOMAIN}/wapi/zpchat/geek/historyMsg?bossId=${bossId}&securityId=${securityId}&page=${pageNum}&c=20&src=0`;
443
+ const data = await bossFetch(page, url);
444
+ const messages = data.zpData?.messages ?? data.zpData?.historyMsgList;
445
+ if (!Array.isArray(messages)) {
446
+ throw new CommandExecutionError('Boss geek history response did not include a message list');
447
+ }
448
+ if (messages.length === 0) {
449
+ throw new EmptyResultError('boss chatmsg', 'Boss returned no messages for this chat.');
450
+ }
451
+ return messages;
452
+ }
@@ -0,0 +1,80 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import {
3
+ emptySearchResults,
4
+ requireBoundedInteger,
5
+ requireNonNegativeInteger,
6
+ requireRows,
7
+ requireSearchQuery,
8
+ runBrowserStep,
9
+ toHttpsUrl,
10
+ } from '../_shared/search-adapter.js';
11
+
12
+ function buildExtractorJs(limit) {
13
+ return `
14
+ (function() {
15
+ var results = [];
16
+ var seen = {};
17
+ var items = document.querySelectorAll('.snippet');
18
+ for (var i = 0; i < items.length; i++) {
19
+ if (results.length >= ${limit}) break;
20
+ var el = items[i];
21
+ if (el.classList.contains('standalone') || el.classList.contains('ad')) continue;
22
+ var titleEl = el.querySelector('.search-snippet-title');
23
+ var snippetEl = el.querySelector('.generic-snippet .content');
24
+ var linkEl = el.querySelector('.result-content a');
25
+ if (!titleEl) continue;
26
+ var title = titleEl.textContent.trim();
27
+ var href = linkEl ? linkEl.getAttribute('href') || '' : '';
28
+ var snippet = snippetEl ? snippetEl.textContent.trim() : '';
29
+ if (!title || !href || seen[href]) continue;
30
+ if (href.indexOf('/') === 0) continue;
31
+ seen[href] = true;
32
+ results.push([title, href, snippet]);
33
+ }
34
+ return results;
35
+ })()`;
36
+ }
37
+
38
+ const command = cli({
39
+ site: 'brave',
40
+ name: 'search',
41
+ access: 'read',
42
+ description: 'Search Brave Search',
43
+ domain: 'search.brave.com',
44
+ strategy: Strategy.PUBLIC,
45
+ browser: true,
46
+ args: [
47
+ { name: 'keyword', positional: true, required: true, help: 'Search query' },
48
+ { name: 'limit', type: 'int', default: 10, help: 'Number of results per page (max 18)' },
49
+ { name: 'offset', type: 'int', default: 0, help: 'Page offset (0, 1, 2...). Brave returns ~18 results per page' },
50
+ ],
51
+ columns: ['rank', 'title', 'url', 'snippet'],
52
+ func: async (page, kwargs) => {
53
+ const limit = requireBoundedInteger(kwargs.limit, 10, 1, 18, '--limit');
54
+ const query = requireSearchQuery(kwargs.keyword);
55
+ const keyword = encodeURIComponent(query);
56
+ const offset = requireNonNegativeInteger(kwargs.offset, 0, '--offset');
57
+ let url = `https://search.brave.com/search?q=${keyword}`;
58
+ if (offset > 0) url += `&offset=${offset}`;
59
+ await runBrowserStep('brave search navigation', () => page.goto(url));
60
+ try {
61
+ await page.wait({ selector: '.snippet', timeout: 10 });
62
+ } catch {
63
+ await page.wait(3).catch(function() {});
64
+ }
65
+ const raw = await runBrowserStep('brave search extraction', () => page.evaluate(buildExtractorJs(limit)));
66
+ const results = requireRows(raw, 'brave search');
67
+ if (results.length === 0) {
68
+ throw emptySearchResults('Brave', query);
69
+ }
70
+ const rows = results
71
+ .map(function(r, index) {
72
+ return { rank: index + 1 + offset * 18, title: r[0], url: toHttpsUrl(r[1], 'https://search.brave.com'), snippet: r[2] };
73
+ })
74
+ .filter((row) => row.url);
75
+ if (rows.length === 0) throw emptySearchResults('Brave', query);
76
+ return rows;
77
+ },
78
+ });
79
+
80
+ export const __test__ = { command };