@jackwener/opencli 1.7.22 → 1.8.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 (222) hide show
  1. package/README.md +30 -148
  2. package/README.zh-CN.md +37 -211
  3. package/cli-manifest.json +6423 -4260
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/chatgpt/envelope.test.js +108 -0
  24. package/clis/chatgpt/image.js +2 -2
  25. package/clis/chatgpt/image.test.js +6 -0
  26. package/clis/chatgpt/utils.js +148 -41
  27. package/clis/chatgpt/utils.test.js +92 -2
  28. package/clis/douyin/_shared/browser-fetch.js +44 -20
  29. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  30. package/clis/douyin/_shared/evaluate-result.js +16 -0
  31. package/clis/douyin/_shared/tos-upload.js +105 -69
  32. package/clis/douyin/_shared/vod-upload.js +212 -0
  33. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  34. package/clis/douyin/delete.js +137 -4
  35. package/clis/douyin/delete.test.js +90 -1
  36. package/clis/douyin/publish-upload-id.test.js +170 -0
  37. package/clis/douyin/publish.js +88 -42
  38. package/clis/douyin/user-videos.js +9 -2
  39. package/clis/douyin/user-videos.test.js +43 -0
  40. package/clis/flomo/memos.js +228 -0
  41. package/clis/flomo/memos.test.js +144 -0
  42. package/clis/gitee/search.js +2 -2
  43. package/clis/gitee/search.test.js +65 -0
  44. package/clis/jike/post.js +27 -17
  45. package/clis/jike/read.test.js +86 -0
  46. package/clis/jike/topic.js +32 -19
  47. package/clis/jike/user.js +33 -20
  48. package/clis/lesswrong/comments.js +1 -1
  49. package/clis/lesswrong/curated.js +1 -1
  50. package/clis/lesswrong/frontpage.js +1 -1
  51. package/clis/lesswrong/frontpage.test.js +37 -0
  52. package/clis/lesswrong/new.js +1 -1
  53. package/clis/lesswrong/read.js +1 -1
  54. package/clis/lesswrong/sequences.js +1 -1
  55. package/clis/lesswrong/shortform.js +1 -1
  56. package/clis/lesswrong/tag.js +1 -1
  57. package/clis/lesswrong/top-month.js +1 -1
  58. package/clis/lesswrong/top-week.js +1 -1
  59. package/clis/lesswrong/top-year.js +1 -1
  60. package/clis/lesswrong/top.js +1 -1
  61. package/clis/linkedin/connect.js +401 -0
  62. package/clis/linkedin/connect.test.js +213 -0
  63. package/clis/linkedin/inbox.js +234 -0
  64. package/clis/linkedin/inbox.test.js +152 -0
  65. package/clis/linkedin/people-search.js +262 -0
  66. package/clis/linkedin/people-search.test.js +216 -0
  67. package/clis/linkedin/safe-send.js +357 -0
  68. package/clis/linkedin/safe-send.test.js +204 -0
  69. package/clis/linkedin/salesnav-inbox.js +210 -0
  70. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  71. package/clis/linkedin/salesnav-message.js +360 -0
  72. package/clis/linkedin/salesnav-message.test.js +172 -0
  73. package/clis/linkedin/salesnav-search.js +186 -0
  74. package/clis/linkedin/salesnav-search.test.js +76 -0
  75. package/clis/linkedin/salesnav-thread.js +212 -0
  76. package/clis/linkedin/salesnav-thread.test.js +79 -0
  77. package/clis/linkedin/sent-invitations.js +92 -0
  78. package/clis/linkedin/sent-invitations.test.js +62 -0
  79. package/clis/linkedin/thread-snapshot.js +214 -0
  80. package/clis/linkedin/thread-snapshot.test.js +89 -0
  81. package/clis/linkedin-learning/course.js +138 -0
  82. package/clis/linkedin-learning/course.test.js +114 -0
  83. package/clis/linkedin-learning/search.js +155 -0
  84. package/clis/linkedin-learning/search.test.js +144 -0
  85. package/clis/linkedin-learning/trending.js +133 -0
  86. package/clis/linkedin-learning/trending.test.js +123 -0
  87. package/clis/powerchina/search.js +3 -3
  88. package/clis/powerchina/search.test.js +27 -1
  89. package/clis/reddit/extract-media.test.js +149 -0
  90. package/clis/reddit/frontpage.js +47 -9
  91. package/clis/reddit/frontpage.test.js +34 -0
  92. package/clis/reddit/home.js +31 -1
  93. package/clis/reddit/home.test.js +46 -3
  94. package/clis/reddit/hot.js +32 -1
  95. package/clis/reddit/hot.test.js +15 -1
  96. package/clis/reddit/popular.js +39 -1
  97. package/clis/reddit/popular.test.js +26 -0
  98. package/clis/reddit/saved.js +1 -1
  99. package/clis/reddit/search.js +38 -1
  100. package/clis/reddit/search.test.js +26 -0
  101. package/clis/reddit/subreddit.js +52 -7
  102. package/clis/reddit/subreddit.test.js +31 -0
  103. package/clis/reddit/subscribed.js +165 -0
  104. package/clis/reddit/subscribed.test.js +168 -0
  105. package/clis/reddit/upvoted.js +1 -1
  106. package/clis/suno/commands.test.js +188 -0
  107. package/clis/suno/download.js +140 -0
  108. package/clis/suno/download.test.js +151 -0
  109. package/clis/suno/generate.js +226 -0
  110. package/clis/suno/generate.test.js +243 -0
  111. package/clis/suno/list.js +79 -0
  112. package/clis/suno/status.js +62 -0
  113. package/clis/suno/utils.js +540 -0
  114. package/clis/suno/utils.test.js +223 -0
  115. package/clis/twitter/device-follow.js +193 -0
  116. package/clis/twitter/device-follow.test.js +287 -0
  117. package/clis/twitter/download.js +443 -73
  118. package/clis/twitter/download.test.js +457 -0
  119. package/clis/twitter/list-create.js +155 -0
  120. package/clis/twitter/list-create.test.js +169 -0
  121. package/clis/twitter/list-remove.js +12 -5
  122. package/clis/twitter/list-remove.test.js +74 -0
  123. package/clis/twitter/list-tweets.js +6 -2
  124. package/clis/twitter/list-tweets.test.js +41 -1
  125. package/clis/twitter/lists.js +31 -4
  126. package/clis/twitter/lists.test.js +152 -16
  127. package/clis/twitter/search.js +6 -2
  128. package/clis/twitter/search.test.js +6 -0
  129. package/clis/twitter/shared.js +144 -0
  130. package/clis/twitter/shared.test.js +429 -1
  131. package/clis/twitter/thread.js +10 -2
  132. package/clis/twitter/thread.test.js +58 -0
  133. package/clis/twitter/timeline.js +6 -2
  134. package/clis/twitter/timeline.test.js +2 -0
  135. package/clis/twitter/tweets.js +3 -2
  136. package/clis/twitter/tweets.test.js +1 -1
  137. package/clis/weibo/delete.js +172 -0
  138. package/clis/weibo/delete.test.js +94 -0
  139. package/clis/weibo/publish.js +37 -14
  140. package/clis/weibo/publish.test.js +14 -5
  141. package/clis/weibo/user-posts.js +234 -0
  142. package/clis/weibo/user-posts.test.js +92 -0
  143. package/clis/weread/search-regression.test.js +18 -11
  144. package/clis/weread/search.js +15 -7
  145. package/clis/weread-official/book.js +135 -0
  146. package/clis/weread-official/commands.test.js +385 -0
  147. package/clis/weread-official/discover.js +107 -0
  148. package/clis/weread-official/list-apis.js +95 -0
  149. package/clis/weread-official/notes.js +171 -0
  150. package/clis/weread-official/readdata.js +158 -0
  151. package/clis/weread-official/review.js +93 -0
  152. package/clis/weread-official/search.js +106 -0
  153. package/clis/weread-official/shelf.js +97 -0
  154. package/clis/weread-official/utils.js +293 -0
  155. package/clis/weread-official/utils.test.js +242 -0
  156. package/clis/wikipedia/trending.js +7 -3
  157. package/clis/wikipedia/trending.test.js +57 -0
  158. package/clis/xianyu/chat.js +24 -109
  159. package/clis/xianyu/chat.test.js +5 -0
  160. package/clis/xianyu/im.js +322 -0
  161. package/clis/xianyu/im.test.js +253 -0
  162. package/clis/xianyu/inbox.js +96 -0
  163. package/clis/xianyu/messages.js +91 -0
  164. package/clis/xianyu/reply.js +82 -0
  165. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  166. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  167. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  168. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  169. package/clis/xiaohongshu/creator-notes.js +2 -1
  170. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  171. package/clis/xiaohongshu/creator-stats.js +2 -1
  172. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  173. package/clis/xiaohongshu/delete-note.js +260 -0
  174. package/clis/xiaohongshu/delete-note.test.js +172 -0
  175. package/clis/xiaohongshu/publish.js +48 -8
  176. package/clis/xiaohongshu/publish.test.js +65 -10
  177. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  178. package/clis/xiaohongshu/user.js +27 -4
  179. package/clis/xiaoyuzhou/download.js +1 -1
  180. package/clis/xiaoyuzhou/transcript.js +1 -1
  181. package/clis/youdao/note.js +258 -0
  182. package/clis/youdao/note.test.js +99 -0
  183. package/clis/youtube/transcript.js +397 -24
  184. package/clis/youtube/transcript.test.js +196 -6
  185. package/clis/zhihu/answer-comments.js +299 -0
  186. package/clis/zhihu/answer-comments.test.js +287 -0
  187. package/clis/zhihu/answer-detail.js +12 -0
  188. package/clis/zhihu/answer-detail.test.js +8 -0
  189. package/clis/zhihu/collection.js +15 -2
  190. package/clis/zhihu/collection.test.js +46 -0
  191. package/clis/zhihu/download.js +1 -1
  192. package/clis/zhihu/question.js +42 -9
  193. package/clis/zhihu/question.test.js +111 -9
  194. package/clis/zhihu/search.js +206 -43
  195. package/clis/zhihu/search.test.js +198 -0
  196. package/dist/src/browser/errors.js +4 -2
  197. package/dist/src/browser/errors.test.js +6 -0
  198. package/dist/src/browser/page.js +30 -4
  199. package/dist/src/browser/page.test.js +42 -0
  200. package/dist/src/browser/utils.d.ts +1 -1
  201. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  202. package/dist/src/cli-argv-preprocess.js +138 -0
  203. package/dist/src/cli-argv-preprocess.test.js +79 -0
  204. package/dist/src/convention-audit.js +15 -8
  205. package/dist/src/convention-audit.test.js +21 -0
  206. package/dist/src/download/media-download.js +15 -2
  207. package/dist/src/download/media-download.test.d.ts +1 -0
  208. package/dist/src/download/media-download.test.js +110 -0
  209. package/dist/src/electron-apps.js +1 -1
  210. package/dist/src/electron-apps.test.js +7 -2
  211. package/dist/src/errors.d.ts +17 -0
  212. package/dist/src/errors.js +22 -0
  213. package/dist/src/external-clis.yaml +8 -0
  214. package/dist/src/main.js +14 -2
  215. package/dist/src/utils.d.ts +43 -0
  216. package/dist/src/utils.js +97 -0
  217. package/dist/src/utils.test.d.ts +1 -0
  218. package/dist/src/utils.test.js +155 -0
  219. package/package.json +8 -2
  220. package/scripts/silent-column-drop-baseline.json +0 -52
  221. package/scripts/typed-error-lint-baseline.json +28 -380
  222. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,262 @@
1
+ /**
2
+ * LinkedIn people-search via SSR DOM text-slice. Voyager people-search
3
+ * REST returns HTTP 500 from a web context; LinkedIn renders results
4
+ * server-side now. One navigation per call consumes one CUL query.
5
+ */
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
8
+
9
+ const LINKEDIN_DOMAIN = 'www.linkedin.com';
10
+ const SEARCH_URL_BASE = 'https://www.linkedin.com/search/results/people/';
11
+ const MAX_LIMIT = 10;
12
+
13
+ function normalizeWhitespace(value) {
14
+ return String(value ?? '').replace(/[ ]/g, ' ').replace(/\s+/g, ' ').trim();
15
+ }
16
+
17
+ function requireStringArg(args, key, label = key) {
18
+ const value = normalizeWhitespace(args[key]);
19
+ if (!value) throw new ArgumentError(`${label} is required`);
20
+ return value;
21
+ }
22
+
23
+ function parseLimit(value) {
24
+ if (value === undefined || value === null || value === '') return 5;
25
+ const limit = Number(value);
26
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
27
+ throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
28
+ }
29
+ return limit;
30
+ }
31
+
32
+ function unwrapEvaluateResult(payload) {
33
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
34
+ return payload;
35
+ }
36
+
37
+ function buildSearchUrl(keywords) {
38
+ return SEARCH_URL_BASE + '?keywords=' + encodeURIComponent(keywords);
39
+ }
40
+
41
+ function looksLinkedInAuthWall(value) {
42
+ const text = normalizeWhitespace(value).toLowerCase();
43
+ if (!text) return false;
44
+ return /linkedin\.com\/(?:login|checkpoint|authwall|uas)/i.test(text)
45
+ || /\b(sign in|log in|join linkedin|captcha|verification required)\b/i.test(text)
46
+ || /(请登录|登录领英|安全验证)/.test(text);
47
+ }
48
+
49
+ function normalizeProfileUrl(value) {
50
+ const raw = normalizeWhitespace(value);
51
+ if (!raw) return '';
52
+ try {
53
+ const parsed = new URL(raw);
54
+ const host = parsed.hostname.toLowerCase();
55
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port) return '';
56
+ if (host !== 'linkedin.com' && host !== 'www.linkedin.com') return '';
57
+ const match = parsed.pathname.match(/^\/in\/([^/?#]+)\/?$/);
58
+ if (!match || !match[1]) return '';
59
+ return `https://www.linkedin.com/in/${match[1]}/`;
60
+ } catch {
61
+ return '';
62
+ }
63
+ }
64
+
65
+ function normalizePeopleRows(rows) {
66
+ if (!Array.isArray(rows)) {
67
+ throw new CommandExecutionError('LinkedIn people search returned malformed extraction payload: missing rows array');
68
+ }
69
+ return rows.map((row, index) => {
70
+ if (!row || typeof row !== 'object') {
71
+ throw new CommandExecutionError(`LinkedIn people search returned malformed row at index ${index}`);
72
+ }
73
+ const name = normalizeWhitespace(row.name);
74
+ const profileUrl = normalizeProfileUrl(row.profile_url);
75
+ if (!name || !profileUrl) {
76
+ throw new CommandExecutionError(`LinkedIn people search returned row without stable profile identity at index ${index}`);
77
+ }
78
+ return {
79
+ name,
80
+ headline: normalizeWhitespace(row.headline),
81
+ location: normalizeWhitespace(row.location),
82
+ profile_url: profileUrl,
83
+ };
84
+ });
85
+ }
86
+
87
+ function parseNonNegativeCount(value, label) {
88
+ const count = Number(value);
89
+ if (!Number.isInteger(count) || count < 0) {
90
+ throw new CommandExecutionError(`LinkedIn people search returned malformed extraction payload: invalid ${label}`);
91
+ }
92
+ return count;
93
+ }
94
+
95
+ function extractionScript() {
96
+ // Class-based selectors are dead (LinkedIn rotates hashed class
97
+ // names on every deploy) and display:contents flattens the DOM
98
+ // tree so per-card containers don't exist. Read main.innerText
99
+ // and slice between consecutive person-name lines instead.
100
+ return String.raw`(() => {
101
+ if (!/search\/results\/people/.test(window.location.href)) {
102
+ return { error: 'not on people search page', url: window.location.href };
103
+ }
104
+ const main = document.querySelector('main') || document.body;
105
+ const normalize = (s) => String(s || '').replace(/[\s\u00a0\u202f]+/g, ' ').trim();
106
+ const skip = (l) => !l
107
+ || /^Status is/.test(l)
108
+ || /^(Message|Connect|Follow|View profile|Pending|Remove)$/i.test(l)
109
+ || /^[•·]\s*(?:1st|2nd|3rd\+?|degree)/i.test(l)
110
+ || /^[•·]/.test(l)
111
+ || l.includes('mutual connection')
112
+ || l.includes('shared connection')
113
+ || /^Summary:/i.test(l)
114
+ || /^About this profile/i.test(l);
115
+
116
+ const anchors = Array.from(main.querySelectorAll('a[href*="/in/"]'));
117
+ const personEntries = [];
118
+ const seenHandles = new Set();
119
+ for (const a of anchors) {
120
+ const m = (a.getAttribute('href') || '').match(/\/in\/([^/?#]+)/);
121
+ if (!m || !m[1]) continue;
122
+ const profileHandle = m[1];
123
+ if (seenHandles.has(profileHandle)) continue;
124
+ const aria = a.querySelector('span[aria-hidden="true"]');
125
+ let name = normalize(aria ? aria.textContent : a.textContent);
126
+ name = name.replace(/^Status is (online|offline)\.?\s*/i, '')
127
+ .replace(/'?s profile$/i, '')
128
+ .replace(/\s*[•·].*$/, '').trim();
129
+ if (!name) continue;
130
+ seenHandles.add(profileHandle);
131
+ personEntries.push({ profileHandle, displayName: name });
132
+ }
133
+
134
+ const lines = (main.innerText || '').split(/\n+/).map(normalize).filter(Boolean);
135
+
136
+ // skip() rejects mutual-connection lines, so candidates that only
137
+ // appear as mutual-connection links inside another card's row
138
+ // never resolve a name index and get filtered out below.
139
+ const nameToIndex = new Map();
140
+ for (const { displayName } of personEntries) {
141
+ if (nameToIndex.has(displayName)) continue;
142
+ const match = lines.findIndex((l) =>
143
+ !skip(l) && (
144
+ l === displayName
145
+ || l.startsWith(displayName + ' ')
146
+ || l.startsWith(displayName + ',')
147
+ || l.startsWith(displayName + "'")
148
+ )
149
+ );
150
+ if (match >= 0) nameToIndex.set(displayName, match);
151
+ }
152
+
153
+ const resolved = personEntries.filter((p) => nameToIndex.has(p.displayName));
154
+ const rows = [];
155
+ for (let i = 0; i < resolved.length; i++) {
156
+ const { profileHandle, displayName } = resolved[i];
157
+ const startIdx = nameToIndex.get(displayName);
158
+ let stopIdx = lines.length;
159
+ for (let j = i + 1; j < resolved.length; j++) {
160
+ const otherStart = nameToIndex.get(resolved[j].displayName);
161
+ if (otherStart != null && otherStart > startIdx) {
162
+ stopIdx = otherStart;
163
+ break;
164
+ }
165
+ }
166
+ const slice = lines.slice(startIdx + 1, stopIdx).filter((l) => l !== displayName && !skip(l));
167
+ rows.push({
168
+ name: displayName,
169
+ headline: slice[0] || '',
170
+ location: slice[1] || '',
171
+ profile_url: 'https://www.linkedin.com/in/' + profileHandle + '/',
172
+ });
173
+ }
174
+ return {
175
+ rows,
176
+ candidate_count: personEntries.length,
177
+ person_entries_count: personEntries.length,
178
+ resolved_count: resolved.length,
179
+ };
180
+ })()`;
181
+ }
182
+
183
+ cli({
184
+ site: 'linkedin',
185
+ name: 'people-search',
186
+ access: 'read',
187
+ description: 'Search standard LinkedIn (not Sales Navigator) for people by keyword. Each invocation consumes against LinkedIn\'s monthly Commercial Use Limit on people search; throttle accordingly.',
188
+ domain: LINKEDIN_DOMAIN,
189
+ strategy: Strategy.COOKIE,
190
+ browser: true,
191
+ args: [
192
+ { name: 'keywords', type: 'string', required: true, positional: true, help: 'People search keywords, e.g. "site reliability engineer berlin"' },
193
+ { name: 'limit', type: 'int', default: 5, help: `Maximum people to return (1-${MAX_LIMIT}); each query counts toward LinkedIn's monthly CUL` },
194
+ ],
195
+ columns: ['rank', 'name', 'headline', 'location', 'profile_url'],
196
+ func: async (page, args) => {
197
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin people-search');
198
+ const keywords = requireStringArg(args, 'keywords', '--keywords');
199
+ const limit = parseLimit(args.limit);
200
+
201
+ try {
202
+ await page.goto(buildSearchUrl(keywords));
203
+ await page.wait(6);
204
+ } catch (error) {
205
+ throw new CommandExecutionError(`LinkedIn people search navigation failed: ${error?.message || error}`);
206
+ }
207
+
208
+ let cookies;
209
+ try {
210
+ cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
211
+ } catch (error) {
212
+ throw new CommandExecutionError(`LinkedIn cookie lookup failed: ${error?.message || error}`);
213
+ }
214
+ if (!Array.isArray(cookies)) {
215
+ throw new CommandExecutionError('LinkedIn cookie lookup returned malformed payload');
216
+ }
217
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
218
+ if (!jsession) {
219
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn in the browser.');
220
+ }
221
+
222
+ let result;
223
+ try {
224
+ result = unwrapEvaluateResult(await page.evaluate(extractionScript()));
225
+ } catch (error) {
226
+ throw new CommandExecutionError(`LinkedIn people search extraction failed: ${error?.message || error}`);
227
+ }
228
+ if (result?.error) {
229
+ if (looksLinkedInAuthWall(`${result.url || ''} ${result.error || ''}`)) {
230
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn people search requires an active signed-in browser session.');
231
+ }
232
+ // If LinkedIn redirected away from the search page that
233
+ // usually means CUL was reached or the account is gated.
234
+ throw new CommandExecutionError(`LinkedIn redirected away from the search page (${result.error}). Likely Commercial Use Limit reached - the limit resets on the 1st of next month.`);
235
+ }
236
+ if (!result || typeof result !== 'object') {
237
+ throw new CommandExecutionError('LinkedIn people search returned malformed extraction payload');
238
+ }
239
+ const candidateCount = parseNonNegativeCount(result.candidate_count, 'candidate_count');
240
+ parseNonNegativeCount(result.person_entries_count, 'person_entries_count');
241
+ const resolvedCount = parseNonNegativeCount(result.resolved_count, 'resolved_count');
242
+ const rows = normalizePeopleRows(result.rows);
243
+ if (rows.length === 0 && (candidateCount > 0 || resolvedCount > 0)) {
244
+ throw new CommandExecutionError('LinkedIn people search found profile candidates but could not parse stable result rows');
245
+ }
246
+ if (rows.length === 0) {
247
+ throw new EmptyResultError(`No people found on the rendered page for "${keywords}". The search may have returned zero results, or the DOM markup may have changed.`);
248
+ }
249
+ return rows.slice(0, limit).map((p, i) => ({ rank: i + 1, ...p }));
250
+ },
251
+ });
252
+
253
+ export const __test__ = {
254
+ normalizeWhitespace,
255
+ parseLimit,
256
+ buildSearchUrl,
257
+ looksLinkedInAuthWall,
258
+ normalizeProfileUrl,
259
+ normalizePeopleRows,
260
+ parseNonNegativeCount,
261
+ extractionScript,
262
+ };
@@ -0,0 +1,216 @@
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 './people-search.js';
5
+
6
+ const {
7
+ parseLimit,
8
+ buildSearchUrl,
9
+ looksLinkedInAuthWall,
10
+ normalizeProfileUrl,
11
+ normalizePeopleRows,
12
+ parseNonNegativeCount,
13
+ extractionScript,
14
+ } = await import('./people-search.js').then((m) => m.__test__);
15
+
16
+ function extractionResult(rows, counts = {}) {
17
+ return {
18
+ rows,
19
+ candidate_count: counts.candidate_count ?? rows.length,
20
+ person_entries_count: counts.person_entries_count ?? counts.candidate_count ?? rows.length,
21
+ resolved_count: counts.resolved_count ?? rows.length,
22
+ };
23
+ }
24
+
25
+ function makePage({
26
+ evaluateResult,
27
+ evaluateReject,
28
+ gotoReject,
29
+ cookies = [{ name: 'JSESSIONID', value: '"ajax:1234567890"' }],
30
+ } = {}) {
31
+ return {
32
+ goto: vi.fn().mockImplementation(() => gotoReject ? Promise.reject(gotoReject) : Promise.resolve(undefined)),
33
+ wait: vi.fn().mockResolvedValue(undefined),
34
+ getCookies: vi.fn().mockResolvedValue(cookies),
35
+ evaluate: vi.fn().mockImplementation(() => evaluateReject ? Promise.reject(evaluateReject) : Promise.resolve(evaluateResult)),
36
+ };
37
+ }
38
+
39
+ describe('linkedin people-search command', () => {
40
+ it('builds the canonical SSR search URL with encoded keywords', () => {
41
+ expect(buildSearchUrl('site reliability engineer'))
42
+ .toBe('https://www.linkedin.com/search/results/people/?keywords=site%20reliability%20engineer');
43
+ expect(buildSearchUrl('hello/world & stuff'))
44
+ .toBe('https://www.linkedin.com/search/results/people/?keywords=hello%2Fworld%20%26%20stuff');
45
+ });
46
+
47
+ it('validates --limit without silent clamping', () => {
48
+ expect(parseLimit(undefined)).toBe(5);
49
+ expect(parseLimit(1)).toBe(1);
50
+ expect(parseLimit(10)).toBe(10);
51
+ expect(() => parseLimit(0)).toThrow(ArgumentError);
52
+ expect(() => parseLimit(11)).toThrow(ArgumentError);
53
+ expect(() => parseLimit(-1)).toThrow(ArgumentError);
54
+ expect(() => parseLimit('abc')).toThrow(ArgumentError);
55
+ expect(() => parseLimit(1.5)).toThrow(ArgumentError);
56
+ });
57
+
58
+ it('extraction script slices main.innerText by person-name boundaries', () => {
59
+ const s = extractionScript();
60
+ // Anchor enumeration finds /in/<handle>.
61
+ expect(s).toContain('a[href*="/in/"]');
62
+ expect(s).toContain('\\/in\\/([^/?#]+)');
63
+ // Text-slice approach: split main.innerText and locate names.
64
+ expect(s).toContain('main.innerText');
65
+ expect(s).toContain('lines.findIndex');
66
+ // Mutual-connection anchors are filtered out via the skip()
67
+ // predicate on the name-line match.
68
+ expect(s).toContain('mutual connection');
69
+ // Names dedup'd by handle.
70
+ expect(s).toContain('seenHandles');
71
+ // Aria-hidden span as canonical name source.
72
+ expect(s).toContain('span[aria-hidden="true"]');
73
+ // Only operates on the people-search page.
74
+ expect(s).toContain('search\\/results\\/people');
75
+ expect(s).toContain('candidate_count');
76
+ expect(s).toContain('resolved_count');
77
+ });
78
+
79
+ it('normalizes only stable LinkedIn profile identities', () => {
80
+ expect(normalizeProfileUrl('https://www.linkedin.com/in/alice-engineer/?mini=true'))
81
+ .toBe('https://www.linkedin.com/in/alice-engineer/');
82
+ expect(normalizeProfileUrl('https://linkedin.com/in/bob-builder')).toBe('https://www.linkedin.com/in/bob-builder/');
83
+ expect(normalizeProfileUrl('https://evil-linkedin.com/in/bob-builder')).toBe('');
84
+ expect(normalizeProfileUrl('http://www.linkedin.com/in/bob-builder')).toBe('');
85
+ expect(normalizeProfileUrl('https://www.linkedin.com/company/opencli')).toBe('');
86
+ });
87
+
88
+ it('detects LinkedIn auth-wall URLs separately from CUL redirects', () => {
89
+ expect(looksLinkedInAuthWall('https://www.linkedin.com/authwall Sign in to continue')).toBe(true);
90
+ expect(looksLinkedInAuthWall('https://www.linkedin.com/checkpoint/challenge security verification required')).toBe(true);
91
+ expect(looksLinkedInAuthWall('https://www.linkedin.com/feed/')).toBe(false);
92
+ });
93
+
94
+ it('rejects malformed extraction rows instead of fabricating success rows', () => {
95
+ expect(() => normalizePeopleRows({})).toThrow(CommandExecutionError);
96
+ expect(() => normalizePeopleRows([null])).toThrow(CommandExecutionError);
97
+ expect(() => normalizePeopleRows([{ name: 'No URL', headline: 'h', location: 'l', profile_url: '' }]))
98
+ .toThrow(CommandExecutionError);
99
+ expect(() => normalizePeopleRows([{ name: '', headline: 'h', location: 'l', profile_url: 'https://www.linkedin.com/in/no-name/' }]))
100
+ .toThrow(CommandExecutionError);
101
+ });
102
+
103
+ it('validates extraction evidence counters', () => {
104
+ expect(parseNonNegativeCount(0, 'candidate_count')).toBe(0);
105
+ expect(parseNonNegativeCount(2, 'candidate_count')).toBe(2);
106
+ expect(() => parseNonNegativeCount(undefined, 'candidate_count')).toThrow(CommandExecutionError);
107
+ expect(() => parseNonNegativeCount(-1, 'candidate_count')).toThrow(CommandExecutionError);
108
+ expect(() => parseNonNegativeCount(1.2, 'candidate_count')).toThrow(CommandExecutionError);
109
+ });
110
+
111
+ it('returns ranked rows when the page yields people', async () => {
112
+ const cmd = getRegistry().get('linkedin/people-search');
113
+ expect(cmd?.func).toBeTypeOf('function');
114
+ const page = makePage({
115
+ evaluateResult: extractionResult([
116
+ { name: 'Alice Engineer', headline: 'Staff SWE at Acme', location: 'Berlin', profile_url: 'https://www.linkedin.com/in/alice-engineer/' },
117
+ { name: 'Bob Builder', headline: 'CTO at Globex', location: 'Remote', profile_url: 'https://www.linkedin.com/in/bob-builder/' },
118
+ ]),
119
+ });
120
+ const result = await cmd.func(page, { keywords: 'reinforcement learning', limit: 5 });
121
+ expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/search/results/people/?keywords=reinforcement%20learning');
122
+ expect(result).toEqual([
123
+ { rank: 1, name: 'Alice Engineer', headline: 'Staff SWE at Acme', location: 'Berlin', profile_url: 'https://www.linkedin.com/in/alice-engineer/' },
124
+ { rank: 2, name: 'Bob Builder', headline: 'CTO at Globex', location: 'Remote', profile_url: 'https://www.linkedin.com/in/bob-builder/' },
125
+ ]);
126
+ });
127
+
128
+ it('slices to --limit when more rows are extracted than requested', async () => {
129
+ const cmd = getRegistry().get('linkedin/people-search');
130
+ const page = makePage({
131
+ evaluateResult: extractionResult(Array.from({ length: 8 }, (_, i) => ({
132
+ name: `Person ${i}`, headline: 'h', location: 'l', profile_url: `https://www.linkedin.com/in/p${i}/`,
133
+ }))),
134
+ });
135
+ const result = await cmd.func(page, { keywords: 'x', limit: 3 });
136
+ expect(result).toHaveLength(3);
137
+ expect(result.map((r) => r.rank)).toEqual([1, 2, 3]);
138
+ });
139
+
140
+ it('throws AuthRequiredError when JSESSIONID cookie is missing', async () => {
141
+ const cmd = getRegistry().get('linkedin/people-search');
142
+ const page = makePage({ cookies: [], evaluateResult: extractionResult([]) });
143
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
144
+ });
145
+
146
+ it('treats malformed cookie lookup results as CommandExecutionError', async () => {
147
+ const cmd = getRegistry().get('linkedin/people-search');
148
+ const page = makePage({ cookies: null, evaluateResult: extractionResult([]) });
149
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
150
+ });
151
+
152
+ it('treats LinkedIn redirect away from search page as a CUL-flavoured CommandExecutionError', async () => {
153
+ const cmd = getRegistry().get('linkedin/people-search');
154
+ const page = makePage({ evaluateResult: { error: 'not on people search page', url: 'https://www.linkedin.com/' } });
155
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
156
+ });
157
+
158
+ it('treats LinkedIn auth-wall redirects as AuthRequiredError', async () => {
159
+ const cmd = getRegistry().get('linkedin/people-search');
160
+ const page = makePage({ evaluateResult: { error: 'not on people search page', url: 'https://www.linkedin.com/authwall?trk=people_search' } });
161
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
162
+ });
163
+
164
+ it('wraps browser extraction exceptions as CommandExecutionError', async () => {
165
+ const cmd = getRegistry().get('linkedin/people-search');
166
+ const page = makePage({ evaluateReject: new SyntaxError('Unexpected token <') });
167
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
168
+ });
169
+
170
+ it('wraps browser navigation exceptions as CommandExecutionError', async () => {
171
+ const cmd = getRegistry().get('linkedin/people-search');
172
+ const page = makePage({ gotoReject: new Error('navigation failed') });
173
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
174
+ });
175
+
176
+ it('throws EmptyResultError when the page rendered zero rows', async () => {
177
+ const cmd = getRegistry().get('linkedin/people-search');
178
+ const page = makePage({ evaluateResult: extractionResult([]) });
179
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
180
+ });
181
+
182
+ it('treats profile candidates without stable parsed rows as parser drift', async () => {
183
+ const cmd = getRegistry().get('linkedin/people-search');
184
+ const page = makePage({
185
+ evaluateResult: extractionResult([], {
186
+ candidate_count: 1,
187
+ person_entries_count: 1,
188
+ resolved_count: 0,
189
+ }),
190
+ });
191
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
192
+ });
193
+
194
+ it('treats missing rows array as parser drift, not empty results', async () => {
195
+ const cmd = getRegistry().get('linkedin/people-search');
196
+ const page = makePage({ evaluateResult: {} });
197
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
198
+ });
199
+
200
+ it('rejects empty keywords with ArgumentError before navigation', async () => {
201
+ const cmd = getRegistry().get('linkedin/people-search');
202
+ const page = makePage({ evaluateResult: extractionResult([]) });
203
+ await expect(cmd.func(page, { keywords: ' ', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
204
+ expect(page.goto).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it('registers with the expected columns and arg shape', () => {
208
+ const cmd = getRegistry().get('linkedin/people-search');
209
+ expect(cmd?.columns).toEqual(['rank', 'name', 'headline', 'location', 'profile_url']);
210
+ expect(cmd?.access).toBe('read');
211
+ expect(cmd?.browser).toBe(true);
212
+ const keywordsArg = cmd?.args?.find((a) => a.name === 'keywords');
213
+ expect(keywordsArg?.positional).toBe(true);
214
+ expect(keywordsArg?.required).toBe(true);
215
+ });
216
+ });