@jackwener/opencli 1.7.21 → 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 (238) hide show
  1. package/README.md +31 -148
  2. package/README.zh-CN.md +38 -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/boss/utils.js +17 -1
  24. package/clis/boss/utils.test.js +34 -0
  25. package/clis/chatgpt/envelope.test.js +108 -0
  26. package/clis/chatgpt/image.js +2 -2
  27. package/clis/chatgpt/image.test.js +6 -0
  28. package/clis/chatgpt/utils.js +148 -41
  29. package/clis/chatgpt/utils.test.js +92 -2
  30. package/clis/douyin/_shared/browser-fetch.js +44 -20
  31. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  32. package/clis/douyin/_shared/evaluate-result.js +16 -0
  33. package/clis/douyin/_shared/tos-upload.js +105 -69
  34. package/clis/douyin/_shared/vod-upload.js +212 -0
  35. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  36. package/clis/douyin/delete.js +137 -4
  37. package/clis/douyin/delete.test.js +90 -1
  38. package/clis/douyin/publish-upload-id.test.js +170 -0
  39. package/clis/douyin/publish.js +88 -42
  40. package/clis/douyin/user-videos.js +9 -2
  41. package/clis/douyin/user-videos.test.js +43 -0
  42. package/clis/flomo/memos.js +228 -0
  43. package/clis/flomo/memos.test.js +144 -0
  44. package/clis/gitee/search.js +2 -2
  45. package/clis/gitee/search.test.js +65 -0
  46. package/clis/jike/post.js +27 -17
  47. package/clis/jike/read.test.js +86 -0
  48. package/clis/jike/topic.js +32 -19
  49. package/clis/jike/user.js +33 -20
  50. package/clis/lesswrong/comments.js +1 -1
  51. package/clis/lesswrong/curated.js +1 -1
  52. package/clis/lesswrong/frontpage.js +1 -1
  53. package/clis/lesswrong/frontpage.test.js +37 -0
  54. package/clis/lesswrong/new.js +1 -1
  55. package/clis/lesswrong/read.js +1 -1
  56. package/clis/lesswrong/sequences.js +1 -1
  57. package/clis/lesswrong/shortform.js +1 -1
  58. package/clis/lesswrong/tag.js +1 -1
  59. package/clis/lesswrong/top-month.js +1 -1
  60. package/clis/lesswrong/top-week.js +1 -1
  61. package/clis/lesswrong/top-year.js +1 -1
  62. package/clis/lesswrong/top.js +1 -1
  63. package/clis/linkedin/connect.js +401 -0
  64. package/clis/linkedin/connect.test.js +213 -0
  65. package/clis/linkedin/inbox.js +234 -0
  66. package/clis/linkedin/inbox.test.js +152 -0
  67. package/clis/linkedin/people-search.js +262 -0
  68. package/clis/linkedin/people-search.test.js +216 -0
  69. package/clis/linkedin/safe-send.js +357 -0
  70. package/clis/linkedin/safe-send.test.js +204 -0
  71. package/clis/linkedin/salesnav-inbox.js +210 -0
  72. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  73. package/clis/linkedin/salesnav-message.js +360 -0
  74. package/clis/linkedin/salesnav-message.test.js +172 -0
  75. package/clis/linkedin/salesnav-search.js +186 -0
  76. package/clis/linkedin/salesnav-search.test.js +76 -0
  77. package/clis/linkedin/salesnav-thread.js +212 -0
  78. package/clis/linkedin/salesnav-thread.test.js +79 -0
  79. package/clis/linkedin/sent-invitations.js +92 -0
  80. package/clis/linkedin/sent-invitations.test.js +62 -0
  81. package/clis/linkedin/thread-snapshot.js +214 -0
  82. package/clis/linkedin/thread-snapshot.test.js +89 -0
  83. package/clis/linkedin-learning/course.js +138 -0
  84. package/clis/linkedin-learning/course.test.js +114 -0
  85. package/clis/linkedin-learning/search.js +155 -0
  86. package/clis/linkedin-learning/search.test.js +144 -0
  87. package/clis/linkedin-learning/trending.js +133 -0
  88. package/clis/linkedin-learning/trending.test.js +123 -0
  89. package/clis/powerchina/search.js +3 -3
  90. package/clis/powerchina/search.test.js +27 -1
  91. package/clis/reddit/extract-media.test.js +149 -0
  92. package/clis/reddit/frontpage.js +47 -9
  93. package/clis/reddit/frontpage.test.js +34 -0
  94. package/clis/reddit/home.js +31 -1
  95. package/clis/reddit/home.test.js +46 -3
  96. package/clis/reddit/hot.js +32 -1
  97. package/clis/reddit/hot.test.js +15 -1
  98. package/clis/reddit/popular.js +39 -1
  99. package/clis/reddit/popular.test.js +26 -0
  100. package/clis/reddit/saved.js +1 -1
  101. package/clis/reddit/search.js +38 -1
  102. package/clis/reddit/search.test.js +26 -0
  103. package/clis/reddit/subreddit.js +52 -7
  104. package/clis/reddit/subreddit.test.js +31 -0
  105. package/clis/reddit/subscribed.js +165 -0
  106. package/clis/reddit/subscribed.test.js +168 -0
  107. package/clis/reddit/upvoted.js +1 -1
  108. package/clis/suno/commands.test.js +188 -0
  109. package/clis/suno/download.js +140 -0
  110. package/clis/suno/download.test.js +151 -0
  111. package/clis/suno/generate.js +226 -0
  112. package/clis/suno/generate.test.js +243 -0
  113. package/clis/suno/list.js +79 -0
  114. package/clis/suno/status.js +62 -0
  115. package/clis/suno/utils.js +540 -0
  116. package/clis/suno/utils.test.js +223 -0
  117. package/clis/twitter/device-follow.js +193 -0
  118. package/clis/twitter/device-follow.test.js +287 -0
  119. package/clis/twitter/download.js +443 -73
  120. package/clis/twitter/download.test.js +457 -0
  121. package/clis/twitter/list-create.js +155 -0
  122. package/clis/twitter/list-create.test.js +169 -0
  123. package/clis/twitter/list-remove.js +12 -5
  124. package/clis/twitter/list-remove.test.js +74 -0
  125. package/clis/twitter/list-tweets.js +6 -2
  126. package/clis/twitter/list-tweets.test.js +41 -1
  127. package/clis/twitter/lists.js +31 -4
  128. package/clis/twitter/lists.test.js +152 -16
  129. package/clis/twitter/search.js +6 -2
  130. package/clis/twitter/search.test.js +6 -0
  131. package/clis/twitter/shared.js +144 -0
  132. package/clis/twitter/shared.test.js +429 -1
  133. package/clis/twitter/thread.js +10 -2
  134. package/clis/twitter/thread.test.js +58 -0
  135. package/clis/twitter/timeline.js +6 -2
  136. package/clis/twitter/timeline.test.js +2 -0
  137. package/clis/twitter/tweets.js +3 -2
  138. package/clis/twitter/tweets.test.js +1 -1
  139. package/clis/weibo/comments.js +3 -4
  140. package/clis/weibo/delete.js +172 -0
  141. package/clis/weibo/delete.test.js +94 -0
  142. package/clis/weibo/envelope.test.js +85 -0
  143. package/clis/weibo/favorites.js +4 -4
  144. package/clis/weibo/feed.js +3 -5
  145. package/clis/weibo/hot.js +3 -4
  146. package/clis/weibo/me.js +3 -5
  147. package/clis/weibo/post.js +3 -4
  148. package/clis/weibo/publish.js +37 -14
  149. package/clis/weibo/publish.test.js +14 -5
  150. package/clis/weibo/search.js +4 -3
  151. package/clis/weibo/user-posts.js +234 -0
  152. package/clis/weibo/user-posts.test.js +92 -0
  153. package/clis/weibo/user.js +3 -4
  154. package/clis/weibo/utils.js +34 -5
  155. package/clis/weibo/utils.test.js +36 -0
  156. package/clis/weread/search-regression.test.js +18 -11
  157. package/clis/weread/search.js +15 -7
  158. package/clis/weread-official/book.js +135 -0
  159. package/clis/weread-official/commands.test.js +385 -0
  160. package/clis/weread-official/discover.js +107 -0
  161. package/clis/weread-official/list-apis.js +95 -0
  162. package/clis/weread-official/notes.js +171 -0
  163. package/clis/weread-official/readdata.js +158 -0
  164. package/clis/weread-official/review.js +93 -0
  165. package/clis/weread-official/search.js +106 -0
  166. package/clis/weread-official/shelf.js +97 -0
  167. package/clis/weread-official/utils.js +293 -0
  168. package/clis/weread-official/utils.test.js +242 -0
  169. package/clis/wikipedia/trending.js +7 -3
  170. package/clis/wikipedia/trending.test.js +57 -0
  171. package/clis/xianyu/chat.js +24 -109
  172. package/clis/xianyu/chat.test.js +5 -0
  173. package/clis/xianyu/im.js +322 -0
  174. package/clis/xianyu/im.test.js +253 -0
  175. package/clis/xianyu/inbox.js +96 -0
  176. package/clis/xianyu/messages.js +91 -0
  177. package/clis/xianyu/reply.js +82 -0
  178. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  179. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  180. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  181. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  182. package/clis/xiaohongshu/creator-notes.js +2 -1
  183. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  184. package/clis/xiaohongshu/creator-stats.js +2 -1
  185. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  186. package/clis/xiaohongshu/delete-note.js +260 -0
  187. package/clis/xiaohongshu/delete-note.test.js +172 -0
  188. package/clis/xiaohongshu/publish.js +48 -8
  189. package/clis/xiaohongshu/publish.test.js +65 -10
  190. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  191. package/clis/xiaohongshu/user.js +27 -4
  192. package/clis/xiaoyuzhou/download.js +1 -1
  193. package/clis/xiaoyuzhou/transcript.js +1 -1
  194. package/clis/youdao/note.js +258 -0
  195. package/clis/youdao/note.test.js +99 -0
  196. package/clis/youtube/transcript.js +397 -24
  197. package/clis/youtube/transcript.test.js +196 -6
  198. package/clis/zhihu/answer-comments.js +299 -0
  199. package/clis/zhihu/answer-comments.test.js +287 -0
  200. package/clis/zhihu/answer-detail.js +12 -0
  201. package/clis/zhihu/answer-detail.test.js +8 -0
  202. package/clis/zhihu/collection.js +15 -2
  203. package/clis/zhihu/collection.test.js +46 -0
  204. package/clis/zhihu/download.js +1 -1
  205. package/clis/zhihu/question.js +42 -9
  206. package/clis/zhihu/question.test.js +111 -9
  207. package/clis/zhihu/search.js +206 -43
  208. package/clis/zhihu/search.test.js +198 -0
  209. package/dist/src/browser/errors.js +4 -2
  210. package/dist/src/browser/errors.test.js +6 -0
  211. package/dist/src/browser/page.js +30 -4
  212. package/dist/src/browser/page.test.js +42 -0
  213. package/dist/src/browser/utils.d.ts +1 -1
  214. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  215. package/dist/src/cli-argv-preprocess.js +138 -0
  216. package/dist/src/cli-argv-preprocess.test.js +79 -0
  217. package/dist/src/cli.js +1 -1
  218. package/dist/src/convention-audit.js +15 -8
  219. package/dist/src/convention-audit.test.js +21 -0
  220. package/dist/src/download/media-download.js +15 -2
  221. package/dist/src/download/media-download.test.d.ts +1 -0
  222. package/dist/src/download/media-download.test.js +110 -0
  223. package/dist/src/electron-apps.js +1 -1
  224. package/dist/src/electron-apps.test.js +7 -2
  225. package/dist/src/errors.d.ts +17 -0
  226. package/dist/src/errors.js +22 -0
  227. package/dist/src/external-clis.yaml +20 -0
  228. package/dist/src/external.d.ts +6 -1
  229. package/dist/src/external.test.js +19 -0
  230. package/dist/src/main.js +14 -2
  231. package/dist/src/utils.d.ts +43 -0
  232. package/dist/src/utils.js +97 -0
  233. package/dist/src/utils.test.d.ts +1 -0
  234. package/dist/src/utils.test.js +155 -0
  235. package/package.json +8 -2
  236. package/scripts/silent-column-drop-baseline.json +0 -52
  237. package/scripts/typed-error-lint-baseline.json +28 -380
  238. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './salesnav-message.js';
5
+
6
+ const {
7
+ parseSalesProfileUrn,
8
+ parseRecipient,
9
+ salesLeadUrlFromParts,
10
+ profileApiUrl,
11
+ buildCreateMessagePayload,
12
+ extractRemainingCredits,
13
+ profileSummary,
14
+ requireProfileSummary,
15
+ salesPageShowsSentMessage,
16
+ } = await import('./salesnav-message.js').then((m) => m.__test__);
17
+
18
+ function createPageMock(evaluateResults = []) {
19
+ const evaluate = vi.fn();
20
+ for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result);
21
+ evaluate.mockResolvedValue(undefined);
22
+ return {
23
+ goto: vi.fn().mockResolvedValue(undefined),
24
+ wait: vi.fn().mockResolvedValue(undefined),
25
+ evaluate,
26
+ getCookies: vi.fn().mockResolvedValue([{ name: 'JSESSIONID', value: '"csrf"', domain: '.linkedin.com' }]),
27
+ };
28
+ }
29
+
30
+ describe('linkedin salesnav-message command', () => {
31
+ it('parses Sales Navigator profile urns and lead URLs', () => {
32
+ const urn = 'urn:li:fs_salesProfile:(ACwAAAJS8TABxyz,NAME_SEARCH,Enlo)';
33
+ expect(parseSalesProfileUrn(urn)).toMatchObject({
34
+ profileId: 'ACwAAAJS8TABxyz',
35
+ authType: 'NAME_SEARCH',
36
+ authToken: 'Enlo',
37
+ entityUrn: urn,
38
+ });
39
+ expect(parseSalesProfileUrn('urn:li:fs_salesProfile:(ACwAAAJS8TABxyz,undefined,undefined)')).toBeNull();
40
+
41
+ const parsed = parseRecipient('https://www.linkedin.com/sales/lead/ACwAAAJS8TABxyz,NAME_SEARCH,Enlo');
42
+ expect(parsed).toMatchObject({ profileId: 'ACwAAAJS8TABxyz', authType: 'NAME_SEARCH', authToken: 'Enlo' });
43
+ expect(parsed.entityUrn).toBe(urn);
44
+ expect(salesLeadUrlFromParts(parsed)).toBe('https://www.linkedin.com/sales/lead/ACwAAAJS8TABxyz,NAME_SEARCH,Enlo');
45
+ });
46
+
47
+ it('accepts LinkedIn /in tokens as unresolved recipients', () => {
48
+ expect(parseRecipient('https://www.linkedin.com/in/ACwAAAJS8TABxyz/')).toMatchObject({
49
+ profileId: 'ACwAAAJS8TABxyz',
50
+ authType: '',
51
+ authToken: '',
52
+ entityUrn: '',
53
+ });
54
+ });
55
+
56
+ it('builds profile API URLs with the Sales Navigator auth key', () => {
57
+ const url = profileApiUrl({ profileId: 'P1', authType: 'NAME_SEARCH', authToken: 'T1' });
58
+ expect(url).toContain('/sales-api/salesApiProfiles/(profileId:P1,authType:NAME_SEARCH,authToken:T1)');
59
+ expect(url).toContain('decoration=');
60
+ });
61
+
62
+ it('constructs the createMessage action payload used by Sales Navigator', () => {
63
+ const payload = buildCreateMessagePayload({
64
+ recipientUrn: 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)',
65
+ subject: 'Quick QA doc question',
66
+ body: 'Hi Jane, can I ask a quick question?',
67
+ trackingId: '0123456789abcdef',
68
+ copyToCrm: false,
69
+ });
70
+ expect(payload).toEqual({
71
+ createMessageRequest: {
72
+ recipients: ['urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)'],
73
+ subject: 'Quick QA doc question',
74
+ body: 'Hi Jane, can I ask a quick question?',
75
+ copyToCrm: false,
76
+ trackingId: '0123456789abcdef',
77
+ },
78
+ });
79
+ });
80
+
81
+ it('validates payload fields before any send attempt', () => {
82
+ expect(() => buildCreateMessagePayload({ recipientUrn: '', subject: 's', body: 'b' })).toThrow();
83
+ expect(() => buildCreateMessagePayload({ recipientUrn: 'urn:li:fs_salesProfile:(P,A,T)', subject: '', body: 'b' })).toThrow();
84
+ expect(() => buildCreateMessagePayload({ recipientUrn: 'urn:li:fs_salesProfile:(P,A,T)', subject: 's', body: '' })).toThrow();
85
+ expect(() => buildCreateMessagePayload({ recipientUrn: 'urn:li:fs_salesProfile:(P,A,T)', subject: 'x'.repeat(201), body: 'b' })).toThrow();
86
+ expect(() => buildCreateMessagePayload({ recipientUrn: 'urn:li:fs_salesProfile:(P,undefined,undefined)', subject: 's', body: 'b' })).toThrow();
87
+ });
88
+
89
+ it('detects the Sales Navigator sent activity on a verified lead page', () => {
90
+ expect(salesPageShowsSentMessage('5/18/2026 You sent a Sales Navigator message to Jane', 'Jane Q')).toBe(true);
91
+ expect(salesPageShowsSentMessage('No recent activity', 'Jane Q')).toBe(false);
92
+ });
93
+
94
+ it('extracts a plausible remaining InMail credit count', () => {
95
+ expect(extractRemainingCredits({ elements: [{ type: 'LSS_INMAIL', value: 149, id: 1 }], paging: { count: 10 } })).toBe(149);
96
+ expect(extractRemainingCredits({ data: { remaining: 149, used: 1 } })).toBe(149);
97
+ expect(extractRemainingCredits({ elements: [{ availableCount: 12 }] })).toBe(12);
98
+ expect(extractRemainingCredits({})).toBe(null);
99
+ });
100
+
101
+ it('summarizes decorated Sales Navigator profile data', () => {
102
+ expect(profileSummary({ data: {
103
+ fullName: 'Rayki Goh',
104
+ headline: 'Food Safety',
105
+ degree: 3,
106
+ defaultPosition: { title: 'FSQA Manager', companyName: 'Acme Foods' },
107
+ memberBadges: { openLink: false },
108
+ } })).toMatchObject({
109
+ recipient: 'Rayki Goh',
110
+ title: 'FSQA Manager',
111
+ company: 'Acme Foods',
112
+ degree: '3',
113
+ open_link: false,
114
+ });
115
+ });
116
+
117
+ it('fails typed when decorated profile data has no recipient identity', () => {
118
+ expect(() => requireProfileSummary({ data: { defaultPosition: { title: 'FSQA' } } }))
119
+ .toThrow(CommandExecutionError);
120
+ });
121
+
122
+ it('keeps manifest columns aligned with dry-run rows and fails typed on malformed profile API', async () => {
123
+ const cmd = getRegistry().get('linkedin/salesnav-message');
124
+ expect(cmd?.columns).toEqual([
125
+ 'status',
126
+ 'recipient',
127
+ 'title',
128
+ 'company',
129
+ 'credits_remaining',
130
+ 'credits_before',
131
+ 'credits_after',
132
+ 'sent_in_salesnav',
133
+ 'message_chars',
134
+ 'subject_chars',
135
+ 'recipient_urn',
136
+ 'degree',
137
+ 'inmail_restriction',
138
+ 'open_link',
139
+ ]);
140
+
141
+ const goodPage = createPageMock([
142
+ { status: 200, json: { data: { fullName: 'Jane Doe', defaultPosition: { title: 'QA', companyName: 'Acme' }, degree: 2, inmailRestriction: 'NO_RESTRICTION', memberBadges: { openLink: true } } } },
143
+ { status: 200, json: { elements: [{ type: 'LSS_INMAIL', value: 12 }] } },
144
+ ]);
145
+ const rows = await cmd.func(goodPage, {
146
+ recipient: 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)',
147
+ subject: 'Hello',
148
+ body: 'Quick question',
149
+ });
150
+ expect(Object.keys(rows[0]).sort()).toEqual([...cmd.columns].sort());
151
+ expect(rows[0]).toMatchObject({
152
+ status: 'validated_dry_run',
153
+ recipient: 'Jane Doe',
154
+ credits_remaining: 12,
155
+ credits_before: 12,
156
+ credits_after: '',
157
+ sent_in_salesnav: false,
158
+ degree: '2',
159
+ inmail_restriction: 'NO_RESTRICTION',
160
+ open_link: true,
161
+ });
162
+
163
+ const malformedPage = createPageMock([
164
+ { status: 200, json: { data: { defaultPosition: { title: 'QA' } } } },
165
+ ]);
166
+ await expect(cmd.func(malformedPage, {
167
+ recipient: 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)',
168
+ subject: 'Hello',
169
+ body: 'Quick question',
170
+ })).rejects.toThrow(CommandExecutionError);
171
+ });
172
+ });
@@ -0,0 +1,186 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+
4
+ const LINKEDIN_DOMAIN = 'www.linkedin.com';
5
+ const SALES_HOME = 'https://www.linkedin.com/sales/';
6
+ const LEAD_SEARCH_BASE = 'https://www.linkedin.com/sales-api/salesApiLeadSearch';
7
+ // Versioned response decoration. LinkedIn bumps this on Sales Navigator
8
+ // redeploys; if the response shape ever changes, refresh it from a live
9
+ // /sales/search/people request.
10
+ const LEAD_SEARCH_DECORATION = 'com.linkedin.sales.deco.desktop.searchv2.LeadSearchResult-14';
11
+ const PAGE_SIZE = 25;
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 25;
25
+ const limit = Number(value);
26
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
27
+ throw new ArgumentError('--limit must be an integer between 1 and 500');
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
+ // Sales Navigator keeps the structural ( ) , : of the query literal and only
38
+ // percent-encodes the keyword value.
39
+ function leadSearchUrl(keywords, start) {
40
+ const query = '(spellCorrectionEnabled:true,recentSearchParam:(doLogHistory:true),keywords:'
41
+ + encodeURIComponent(keywords) + ')';
42
+ return LEAD_SEARCH_BASE
43
+ + '?q=searchQuery&query=' + query
44
+ + '&start=' + start + '&count=' + PAGE_SIZE
45
+ + '&decorationId=' + LEAD_SEARCH_DECORATION;
46
+ }
47
+
48
+ function fetchLeadSearchScript(url, csrf) {
49
+ return String.raw`(async () => {
50
+ const headers = {
51
+ 'csrf-token': ${JSON.stringify(csrf)},
52
+ 'x-restli-protocol-version': '2.0.0',
53
+ accept: 'application/json',
54
+ };
55
+ try {
56
+ const res = await fetch(${JSON.stringify(url)}, { credentials: 'include', headers });
57
+ if (res.status === 401 || res.status === 403) return { authRequired: true, status: res.status };
58
+ if (!res.ok) return { error: 'HTTP ' + res.status };
59
+ return { json: await res.json() };
60
+ } catch (e) {
61
+ return { error: 'fetch failed: ' + ((e && e.message) || String(e)) };
62
+ }
63
+ })()`;
64
+ }
65
+
66
+ // Sales Navigator search returns no /in/ vanity URL, but the entityUrn carries
67
+ // the obfuscated member token, and linkedin.com/in/<token> is a valid profile
68
+ // URL that the connect command accepts.
69
+ function profileUrlFromEntityUrn(entityUrn) {
70
+ const match = String(entityUrn || '').match(/fs_salesProfile:\(([^,)]+)/);
71
+ return match && match[1] ? 'https://www.linkedin.com/in/' + match[1] : '';
72
+ }
73
+
74
+ function leadUrlFromEntityUrn(entityUrn) {
75
+ const match = String(entityUrn || '').match(/^urn:li:fs_salesProfile:\(([^,()]+),([^,()]+),([^,()]+)\)$/);
76
+ if (!match) return '';
77
+ return `https://www.linkedin.com/sales/lead/${encodeURIComponent(match[1])},${encodeURIComponent(match[2])},${encodeURIComponent(match[3])}`;
78
+ }
79
+
80
+ function parseLeads(json) {
81
+ if (!json || typeof json !== 'object' || !Array.isArray(json.elements)) {
82
+ throw new CommandExecutionError('Sales Navigator lead search API returned malformed payload');
83
+ }
84
+ const leads = [];
85
+ for (const el of json.elements) {
86
+ if (!el || typeof el !== 'object') {
87
+ throw new CommandExecutionError('Sales Navigator lead search API returned malformed lead row');
88
+ }
89
+ const current = Array.isArray(el.currentPositions) ? el.currentPositions : [];
90
+ const past = Array.isArray(el.pastPositions) ? el.pastPositions : [];
91
+ const pos = current[0] || past[0] || {};
92
+ const name = normalizeWhitespace(el.fullName || [el.firstName, el.lastName].filter(Boolean).join(' '));
93
+ if (!name) {
94
+ throw new CommandExecutionError('Sales Navigator lead row missing name');
95
+ }
96
+ const entityUrn = normalizeWhitespace(el.entityUrn || '');
97
+ if (!profileUrlFromEntityUrn(entityUrn)) {
98
+ throw new CommandExecutionError('Sales Navigator lead row missing profile identity');
99
+ }
100
+ leads.push({
101
+ name,
102
+ title: normalizeWhitespace(pos.title || ''),
103
+ company: normalizeWhitespace(pos.companyName || ''),
104
+ location: normalizeWhitespace(el.geoRegion || ''),
105
+ degree: normalizeWhitespace(el.degree || ''),
106
+ profile_url: profileUrlFromEntityUrn(entityUrn),
107
+ lead_url: leadUrlFromEntityUrn(entityUrn),
108
+ recipient_urn: entityUrn,
109
+ });
110
+ }
111
+ return leads;
112
+ }
113
+
114
+ function requireLeadSearchResult(result) {
115
+ if (result?.authRequired) {
116
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn Sales Navigator API auth failed (HTTP ' + (result.status || '') + '). Confirm the account has Sales Navigator access.');
117
+ }
118
+ if (result?.error) {
119
+ throw new CommandExecutionError('Sales Navigator lead search API returned an unexpected response', result.error);
120
+ }
121
+ if (!result || !result.json) {
122
+ throw new CommandExecutionError('Sales Navigator lead search API returned an unexpected response', 'no_json');
123
+ }
124
+ return result.json;
125
+ }
126
+
127
+ cli({
128
+ site: 'linkedin',
129
+ name: 'salesnav-search',
130
+ access: 'read',
131
+ description: 'Search LinkedIn Sales Navigator for people leads by keyword',
132
+ domain: LINKEDIN_DOMAIN,
133
+ strategy: Strategy.UI,
134
+ browser: true,
135
+ args: [
136
+ { name: 'keywords', type: 'string', required: true, positional: true, help: 'People search keywords, e.g. "quality manager food manufacturing"' },
137
+ { name: 'limit', type: 'number', default: 25, help: 'Maximum leads to return (1-500, fetched 25 per request)' },
138
+ ],
139
+ columns: ['rank', 'name', 'title', 'company', 'location', 'degree', 'profile_url', 'lead_url', 'recipient_urn'],
140
+ func: async (page, args) => {
141
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin salesnav-search');
142
+ const keywords = requireStringArg(args, 'keywords', '--keywords');
143
+ const limit = parseLimit(args.limit);
144
+
145
+ await page.goto(SALES_HOME);
146
+ await page.wait(6);
147
+
148
+ const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
149
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
150
+ if (!jsession) {
151
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn.');
152
+ }
153
+ const csrf = jsession.replace(/^\"|\"$/g, '');
154
+
155
+ const leads = [];
156
+ const seen = new Set();
157
+ for (let start = 0; leads.length < limit && start < 2000; start += PAGE_SIZE) {
158
+ const result = unwrapEvaluateResult(await page.evaluate(fetchLeadSearchScript(leadSearchUrl(keywords, start), csrf)));
159
+ const json = requireLeadSearchResult(result);
160
+ const pageLeads = parseLeads(json);
161
+ if (pageLeads.length === 0) break;
162
+ for (const lead of pageLeads) {
163
+ const key = lead.profile_url || lead.name.toLowerCase();
164
+ if (seen.has(key)) continue;
165
+ seen.add(key);
166
+ leads.push(lead);
167
+ }
168
+ await page.wait(1);
169
+ }
170
+
171
+ if (leads.length === 0) {
172
+ throw new EmptyResultError('linkedin salesnav-search', 'No Sales Navigator leads were found.');
173
+ }
174
+ return leads.slice(0, limit).map((lead, index) => ({ rank: index + 1, ...lead }));
175
+ },
176
+ });
177
+
178
+ export const __test__ = {
179
+ normalizeWhitespace,
180
+ parseLimit,
181
+ leadSearchUrl,
182
+ profileUrlFromEntityUrn,
183
+ leadUrlFromEntityUrn,
184
+ parseLeads,
185
+ requireLeadSearchResult,
186
+ };
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import './salesnav-search.js';
4
+
5
+ const {
6
+ parseLimit,
7
+ leadSearchUrl,
8
+ profileUrlFromEntityUrn,
9
+ leadUrlFromEntityUrn,
10
+ parseLeads,
11
+ requireLeadSearchResult,
12
+ } = await import('./salesnav-search.js').then((m) => m.__test__);
13
+
14
+ describe('linkedin salesnav-search command', () => {
15
+ it('builds a salesApiLeadSearch URL with encoded keywords and pagination', () => {
16
+ const url = leadSearchUrl('quality manager food', 50);
17
+ expect(url).toContain('/sales-api/salesApiLeadSearch');
18
+ expect(url).toContain('keywords:quality%20manager%20food');
19
+ expect(url).toContain('start=50');
20
+ expect(url).toContain('count=25');
21
+ });
22
+
23
+ it('derives a profile URL from the sales-profile entityUrn token', () => {
24
+ expect(profileUrlFromEntityUrn('urn:li:fs_salesProfile:(ACwAAAJS8TABxyz,NAME_SEARCH,Enlo)'))
25
+ .toBe('https://www.linkedin.com/in/ACwAAAJS8TABxyz');
26
+ expect(profileUrlFromEntityUrn('')).toBe('');
27
+ expect(profileUrlFromEntityUrn('not-a-urn')).toBe('');
28
+ });
29
+
30
+ it('derives a Sales Navigator lead URL from the full sales-profile entityUrn', () => {
31
+ expect(leadUrlFromEntityUrn('urn:li:fs_salesProfile:(ACwAAAJS8TABxyz,NAME_SEARCH,Enlo)'))
32
+ .toBe('https://www.linkedin.com/sales/lead/ACwAAAJS8TABxyz,NAME_SEARCH,Enlo');
33
+ expect(leadUrlFromEntityUrn('not-a-urn')).toBe('');
34
+ });
35
+
36
+ it('validates --limit without silent clamping', () => {
37
+ expect(parseLimit(undefined)).toBe(25);
38
+ expect(parseLimit(120)).toBe(120);
39
+ expect(() => parseLimit(0)).toThrow();
40
+ expect(() => parseLimit(999)).toThrow();
41
+ expect(() => parseLimit('abc')).toThrow();
42
+ });
43
+
44
+ it('parses lead rows and falls back to past positions', () => {
45
+ const json = { elements: [
46
+ { fullName: 'Jane Q', geoRegion: 'Vancouver, BC', degree: 2,
47
+ entityUrn: 'urn:li:fs_salesProfile:(TOKEN1,NAME_SEARCH,abc)',
48
+ currentPositions: [{ title: 'QA Manager', companyName: 'Acme Foods' }] },
49
+ { fullName: 'No Current', geoRegion: 'Toronto',
50
+ entityUrn: 'urn:li:fs_salesProfile:(TOKEN2,NAME_SEARCH,def)',
51
+ currentPositions: [], pastPositions: [{ title: 'Past QA Lead', companyName: 'Old Co' }] },
52
+ ] };
53
+ const leads = parseLeads(json);
54
+ expect(leads).toHaveLength(2);
55
+ expect(leads[0]).toMatchObject({
56
+ name: 'Jane Q',
57
+ title: 'QA Manager',
58
+ company: 'Acme Foods',
59
+ location: 'Vancouver, BC',
60
+ profile_url: 'https://www.linkedin.com/in/TOKEN1',
61
+ lead_url: 'https://www.linkedin.com/sales/lead/TOKEN1,NAME_SEARCH,abc',
62
+ recipient_urn: 'urn:li:fs_salesProfile:(TOKEN1,NAME_SEARCH,abc)',
63
+ });
64
+ expect(leads[1]).toMatchObject({ name: 'No Current', title: 'Past QA Lead', company: 'Old Co' });
65
+ });
66
+
67
+ it('fails typed on malformed lead payloads instead of silently dropping rows', () => {
68
+ expect(() => parseLeads({})).toThrow(CommandExecutionError);
69
+ expect(() => parseLeads({ elements: [{ firstName: '', lastName: '', entityUrn: 'urn:li:fs_salesProfile:(TOKEN3,x,y)' }] }))
70
+ .toThrow(CommandExecutionError);
71
+ expect(() => parseLeads({ elements: [{ fullName: 'No Identity' }] }))
72
+ .toThrow(CommandExecutionError);
73
+ expect(() => requireLeadSearchResult({ error: 'HTTP 500' })).toThrow(CommandExecutionError);
74
+ expect(() => requireLeadSearchResult({})).toThrow(CommandExecutionError);
75
+ });
76
+ });
@@ -0,0 +1,212 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import {
4
+ THREAD_DECORATION,
5
+ THREADS_BASE,
6
+ encodeRestliDecoration,
7
+ fetchInboxRows,
8
+ fetchSalesnavJson,
9
+ getCsrf,
10
+ normalizeWhitespace,
11
+ parseLimit,
12
+ } from './salesnav-inbox.js';
13
+
14
+ const LINKEDIN_DOMAIN = 'www.linkedin.com';
15
+ const SALES_INBOX_URL = 'https://www.linkedin.com/sales/inbox/';
16
+ const DEFAULT_MESSAGE_LIMIT = 200;
17
+ const THREAD_PAGE_SIZE = 20;
18
+
19
+ function isLinkedInHost(hostname) {
20
+ const host = String(hostname || '').toLowerCase();
21
+ return host === 'linkedin.com' || host.endsWith('.linkedin.com');
22
+ }
23
+
24
+ function parseSalesProfileUrn(value) {
25
+ const raw = normalizeWhitespace(value);
26
+ const match = raw.match(/^urn:li:fs_salesProfile:\(([^,()]+),([^,()]+),([^,()]+)\)$/);
27
+ if (!match) return '';
28
+ const parts = [match[1], match[2], match[3]].map((part) => normalizeWhitespace(part).toLowerCase());
29
+ if (parts.some((part) => !part || part === 'undefined' || part === 'null' || part === 'not_available')) return '';
30
+ return raw;
31
+ }
32
+
33
+ function parseThreadInput(value) {
34
+ const raw = normalizeWhitespace(value);
35
+ if (!raw) return ['empty', ''];
36
+ if (/^2-[A-Za-z0-9+/=_-]+$/.test(raw)) return ['thread_id', raw];
37
+ if (/^urn:li:fs_salesProfile:\(/.test(raw)) {
38
+ const urn = parseSalesProfileUrn(raw);
39
+ if (!urn) throw new ArgumentError('Sales Navigator recipient urn must be urn:li:fs_salesProfile:(profileId,authType,authToken)');
40
+ return ['recipient_urn', urn];
41
+ }
42
+ try {
43
+ const url = new URL(raw);
44
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return ['name', raw.toLowerCase()];
45
+ const inboxMatch = url.pathname.match(/^\/sales\/inbox\/([^/]+)\/?$/i);
46
+ if (inboxMatch) return ['thread_id', decodeURIComponent(inboxMatch[1])];
47
+ const leadMatch = url.pathname.match(/^\/sales\/lead\/([^,/]+),([^,/]+),([^/]+)\/?$/i);
48
+ if (leadMatch) {
49
+ const urn = `urn:li:fs_salesProfile:(${decodeURIComponent(leadMatch[1])},${decodeURIComponent(leadMatch[2])},${decodeURIComponent(leadMatch[3])})`;
50
+ if (!parseSalesProfileUrn(urn)) {
51
+ throw new ArgumentError('Sales Navigator lead URL must contain resolved profileId, authType, and authToken');
52
+ }
53
+ return ['recipient_urn', urn];
54
+ }
55
+ } catch (err) {
56
+ if (err instanceof ArgumentError) throw err;
57
+ // Fall through to name matching for non-URL text.
58
+ }
59
+ return ['name', raw.toLowerCase()];
60
+ }
61
+
62
+ function salesnavThreadUrl(threadId) {
63
+ return threadId ? `https://www.linkedin.com/sales/inbox/${encodeURIComponent(threadId)}` : '';
64
+ }
65
+
66
+ function threadApiUrl(threadId, messageCount) {
67
+ return `${THREADS_BASE}/${encodeURIComponent(threadId)}?decoration=${encodeRestliDecoration(THREAD_DECORATION)}&count=1&messageCount=${messageCount}`;
68
+ }
69
+
70
+ function participantName(profile) {
71
+ return normalizeWhitespace(profile?.fullName || [profile?.firstName, profile?.lastName].filter(Boolean).join(' '));
72
+ }
73
+
74
+ function participantIndex(thread) {
75
+ const resolution = thread?.participantsResolutionResults || {};
76
+ const participants = Array.isArray(thread?.participants) ? thread.participants : Object.keys(resolution);
77
+ const byUrn = new Map();
78
+ for (const urn of participants) {
79
+ const profile = resolution[urn] || { entityUrn: urn };
80
+ byUrn.set(urn, profile);
81
+ }
82
+ return byUrn;
83
+ }
84
+
85
+ function parseSalesnavThreadMessages(thread) {
86
+ if (!thread || typeof thread !== 'object') {
87
+ throw new CommandExecutionError('Sales Navigator messaging thread API returned malformed payload');
88
+ }
89
+ const threadId = normalizeWhitespace(thread?.id || '');
90
+ if (!threadId) {
91
+ throw new CommandExecutionError('Sales Navigator messaging thread API returned a thread without id');
92
+ }
93
+ if (!Array.isArray(thread?.messages)) {
94
+ throw new CommandExecutionError('Sales Navigator messaging thread API returned malformed messages');
95
+ }
96
+ const byUrn = participantIndex(thread);
97
+ const messages = thread.messages;
98
+ const rows = messages.map((message) => {
99
+ if (!message || typeof message !== 'object') {
100
+ throw new CommandExecutionError('Sales Navigator messaging thread API returned malformed message row');
101
+ }
102
+ const deliveredAt = Number(message?.deliveredAt || 0);
103
+ const senderProfile = byUrn.get(message?.author);
104
+ return {
105
+ message_id: normalizeWhitespace(message?.id || ''),
106
+ thread_id: threadId,
107
+ sender: participantName(senderProfile) || normalizeWhitespace(message?.author || ''),
108
+ sender_urn: normalizeWhitespace(message?.author || ''),
109
+ text: normalizeWhitespace(message?.body || message?.systemMessageContent || ''),
110
+ subject: normalizeWhitespace(message?.subject || ''),
111
+ timestamp: deliveredAt ? new Date(deliveredAt).toISOString() : '',
112
+ delivered_at: deliveredAt || '',
113
+ type: normalizeWhitespace(message?.type || ''),
114
+ };
115
+ }).filter((row) => row.text || row.subject || row.message_id);
116
+ rows.sort((a, b) => Number(a.delivered_at || 0) - Number(b.delivered_at || 0));
117
+ return rows.map((row, index) => ({ index, ...row }));
118
+ }
119
+
120
+ function threadMatchesInput(row, parsed) {
121
+ if (!row || !parsed) return false;
122
+ const [kind, criterion] = parsed;
123
+ if (kind === 'thread_id') return row.thread_id === criterion;
124
+ if (kind === 'recipient_urn') {
125
+ return (row.participants || []).some((p) => normalizeWhitespace(p.entity_urn) === criterion);
126
+ }
127
+ if (kind === 'name') {
128
+ const needle = normalizeWhitespace(criterion).toLowerCase();
129
+ if (!needle) return false;
130
+ if (normalizeWhitespace(row.person_name).toLowerCase() === needle) return true;
131
+ return (row.participants || []).some((p) => normalizeWhitespace(p.name).toLowerCase() === needle);
132
+ }
133
+ return false;
134
+ }
135
+
136
+ async function resolveThreadId(page, input, { maxPages = 30 } = {}) {
137
+ const parsed = parseThreadInput(input);
138
+ if (parsed[0] === 'empty') throw new ArgumentError('thread or recipient is required');
139
+ if (parsed[0] === 'thread_id') return parsed[1];
140
+ const inboxRows = await fetchInboxRows(page, { limit: 500, maxPages });
141
+ const match = inboxRows.find((row) => threadMatchesInput(row, parsed));
142
+ if (!match) {
143
+ throw new EmptyResultError('linkedin salesnav-thread', `No Sales Navigator thread matched ${input}`);
144
+ }
145
+ return match.thread_id;
146
+ }
147
+
148
+ async function fetchThreadWithPagination(page, csrf, threadId, limit = DEFAULT_MESSAGE_LIMIT) {
149
+ let requested = THREAD_PAGE_SIZE;
150
+ if (limit < requested) requested = limit;
151
+ let thread = null;
152
+ for (let attempts = 0; attempts < 30; attempts += 1) {
153
+ thread = await fetchSalesnavJson(page, csrf, threadApiUrl(threadId, requested), 'Sales Navigator messaging thread API');
154
+ const total = Number(thread?.totalMessageCount || 0);
155
+ const have = Array.isArray(thread?.messages) ? thread.messages.length : 0;
156
+ if (have >= limit || (total && have >= total) || requested >= limit) break;
157
+ let nextRequested = requested + THREAD_PAGE_SIZE;
158
+ if (total && total > nextRequested) nextRequested = total;
159
+ if (nextRequested > limit) nextRequested = limit;
160
+ requested = nextRequested;
161
+ }
162
+ const total = Number(thread?.totalMessageCount || 0);
163
+ const have = Array.isArray(thread?.messages) ? thread.messages.length : 0;
164
+ if (total && have < total && have < limit) {
165
+ throw new CommandExecutionError(`Sales Navigator messaging thread API returned partial history (${have}/${total})`);
166
+ }
167
+ return thread;
168
+ }
169
+
170
+ cli({
171
+ site: 'linkedin',
172
+ name: 'salesnav-thread',
173
+ access: 'read',
174
+ description: 'Return full Sales Navigator message history for a thread id, Sales Navigator inbox URL, lead URL, recipient urn, or exact recipient name',
175
+ domain: LINKEDIN_DOMAIN,
176
+ strategy: Strategy.UI,
177
+ browser: true,
178
+ args: [
179
+ { name: 'thread-or-recipient', type: 'string', required: true, positional: true, help: 'Sales Navigator inbox URL/thread id, Sales Navigator lead URL, recipient urn, or exact participant name' },
180
+ { name: 'limit', type: 'number', default: DEFAULT_MESSAGE_LIMIT, help: 'Maximum messages to return (1-500)' },
181
+ { name: 'max-pages', type: 'number', default: 30, help: 'Maximum inbox pages to scan when resolving a recipient' },
182
+ ],
183
+ columns: ['index', 'thread_id', 'thread_url', 'sender', 'text', 'timestamp', 'subject', 'message_id', 'sender_urn', 'delivered_at', 'type', 'total_message_count'],
184
+ func: async (page, args) => {
185
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin salesnav-thread');
186
+ const input = normalizeWhitespace(args['thread-or-recipient']);
187
+ if (!input) throw new ArgumentError('thread-or-recipient is required');
188
+ const limit = parseLimit(args.limit, DEFAULT_MESSAGE_LIMIT);
189
+ const maxPages = parseLimit(args['max-pages'], 30);
190
+ await page.goto(SALES_INBOX_URL);
191
+ await page.wait(4);
192
+ const threadId = await resolveThreadId(page, input, { maxPages });
193
+ const csrf = await getCsrf(page);
194
+ const thread = await fetchThreadWithPagination(page, csrf, threadId, limit);
195
+ const messages = parseSalesnavThreadMessages(thread).slice(0, limit);
196
+ if (messages.length === 0) throw new EmptyResultError('linkedin salesnav-thread', `No messages found for ${threadId}`);
197
+ return messages.map((message) => ({
198
+ ...message,
199
+ thread_url: salesnavThreadUrl(threadId),
200
+ total_message_count: Number(thread?.totalMessageCount || messages.length),
201
+ }));
202
+ },
203
+ });
204
+
205
+ export const __test__ = {
206
+ parseThreadInput,
207
+ threadApiUrl,
208
+ participantIndex,
209
+ parseSalesnavThreadMessages,
210
+ threadMatchesInput,
211
+ salesnavThreadUrl,
212
+ };