@jackwener/opencli 1.7.22 → 1.8.1

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 (346) hide show
  1. package/README.md +35 -194
  2. package/README.zh-CN.md +42 -260
  3. package/cli-manifest.json +8160 -4392
  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/_atlassian/shared.js +577 -0
  16. package/clis/_atlassian/shared.test.js +170 -0
  17. package/clis/apple-podcasts/commands.test.js +20 -0
  18. package/clis/apple-podcasts/search.js +2 -2
  19. package/clis/barchart/greeks.js +144 -56
  20. package/clis/barchart/greeks.test.js +138 -0
  21. package/clis/bilibili/comment.js +125 -0
  22. package/clis/bilibili/comment.test.js +153 -0
  23. package/clis/bilibili/comments.js +116 -21
  24. package/clis/bilibili/comments.test.js +77 -18
  25. package/clis/bilibili/subtitle.js +76 -31
  26. package/clis/bilibili/subtitle.test.js +156 -9
  27. package/clis/bilibili/summary.js +167 -0
  28. package/clis/bilibili/summary.test.js +210 -0
  29. package/clis/bilibili/utils.js +63 -5
  30. package/clis/bilibili/utils.test.js +45 -1
  31. package/clis/booking/booking.test.js +356 -0
  32. package/clis/booking/search.js +351 -0
  33. package/clis/chatgpt/envelope.test.js +108 -0
  34. package/clis/chatgpt/image.js +2 -2
  35. package/clis/chatgpt/image.test.js +6 -0
  36. package/clis/chatgpt/utils.js +148 -41
  37. package/clis/chatgpt/utils.test.js +92 -2
  38. package/clis/chess/analyze.js +35 -0
  39. package/clis/chess/analyze.test.js +79 -0
  40. package/clis/chess/game.js +114 -0
  41. package/clis/chess/game.test.js +178 -0
  42. package/clis/chess/games.js +67 -0
  43. package/clis/chess/games.test.js +164 -0
  44. package/clis/chess/stats.js +32 -0
  45. package/clis/chess/stats.test.js +79 -0
  46. package/clis/chess/utils.js +170 -0
  47. package/clis/chess/utils.test.js +230 -0
  48. package/clis/confluence/commands.test.js +195 -0
  49. package/clis/confluence/create.js +39 -0
  50. package/clis/confluence/page.js +23 -0
  51. package/clis/confluence/search.js +34 -0
  52. package/clis/confluence/shared.js +173 -0
  53. package/clis/confluence/update.js +38 -0
  54. package/clis/douyin/_shared/browser-fetch.js +44 -20
  55. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  56. package/clis/douyin/_shared/evaluate-result.js +16 -0
  57. package/clis/douyin/_shared/tos-upload.js +105 -69
  58. package/clis/douyin/_shared/vod-upload.js +212 -0
  59. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  60. package/clis/douyin/delete.js +137 -4
  61. package/clis/douyin/delete.test.js +90 -1
  62. package/clis/douyin/hashtag.js +84 -23
  63. package/clis/douyin/hashtag.test.js +113 -0
  64. package/clis/douyin/publish-upload-id.test.js +170 -0
  65. package/clis/douyin/publish.js +88 -42
  66. package/clis/douyin/user-videos.js +9 -2
  67. package/clis/douyin/user-videos.test.js +43 -0
  68. package/clis/flomo/memos.js +228 -0
  69. package/clis/flomo/memos.test.js +144 -0
  70. package/clis/geogebra/add-circle.js +46 -0
  71. package/clis/geogebra/add-line.js +35 -0
  72. package/clis/geogebra/add-point.js +27 -0
  73. package/clis/geogebra/add-polygon.js +25 -0
  74. package/clis/geogebra/eval.js +35 -0
  75. package/clis/geogebra/geogebra.test.js +175 -0
  76. package/clis/geogebra/hexagon.js +62 -0
  77. package/clis/geogebra/info.js +72 -0
  78. package/clis/geogebra/list.js +35 -0
  79. package/clis/geogebra/triangle.js +60 -0
  80. package/clis/geogebra/utils.js +271 -0
  81. package/clis/gitee/search.js +2 -2
  82. package/clis/gitee/search.test.js +65 -0
  83. package/clis/jike/post.js +27 -17
  84. package/clis/jike/read.test.js +86 -0
  85. package/clis/jike/topic.js +32 -19
  86. package/clis/jike/user.js +33 -20
  87. package/clis/jira/attachments.js +28 -0
  88. package/clis/jira/commands.test.js +287 -0
  89. package/clis/jira/comments.js +28 -0
  90. package/clis/jira/issue.js +28 -0
  91. package/clis/jira/links.js +28 -0
  92. package/clis/jira/search.js +47 -0
  93. package/clis/jira/shared.js +256 -0
  94. package/clis/lesswrong/comments.js +1 -1
  95. package/clis/lesswrong/curated.js +1 -1
  96. package/clis/lesswrong/frontpage.js +1 -1
  97. package/clis/lesswrong/frontpage.test.js +37 -0
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/top-month.js +1 -1
  104. package/clis/lesswrong/top-week.js +1 -1
  105. package/clis/lesswrong/top-year.js +1 -1
  106. package/clis/lesswrong/top.js +1 -1
  107. package/clis/linkedin/connect.js +401 -0
  108. package/clis/linkedin/connect.test.js +213 -0
  109. package/clis/linkedin/inbox.js +234 -0
  110. package/clis/linkedin/inbox.test.js +152 -0
  111. package/clis/linkedin/job-detail.js +167 -0
  112. package/clis/linkedin/job-detail.test.js +38 -0
  113. package/clis/linkedin/jobs-preferences.js +113 -0
  114. package/clis/linkedin/jobs-preferences.test.js +43 -0
  115. package/clis/linkedin/people-search.js +262 -0
  116. package/clis/linkedin/people-search.test.js +216 -0
  117. package/clis/linkedin/post-analytics.js +74 -0
  118. package/clis/linkedin/post-analytics.test.js +40 -0
  119. package/clis/linkedin/posts-core.js +241 -0
  120. package/clis/linkedin/posts.js +22 -0
  121. package/clis/linkedin/posts.test.js +40 -0
  122. package/clis/linkedin/profile-analytics.js +104 -0
  123. package/clis/linkedin/profile-analytics.test.js +67 -0
  124. package/clis/linkedin/profile-experience.js +671 -0
  125. package/clis/linkedin/profile-experience.test.js +152 -0
  126. package/clis/linkedin/profile-projects.js +311 -0
  127. package/clis/linkedin/profile-projects.test.js +111 -0
  128. package/clis/linkedin/profile-read.js +148 -0
  129. package/clis/linkedin/profile-read.test.js +77 -0
  130. package/clis/linkedin/safe-send.js +357 -0
  131. package/clis/linkedin/safe-send.test.js +204 -0
  132. package/clis/linkedin/salesnav-inbox.js +210 -0
  133. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  134. package/clis/linkedin/salesnav-message.js +360 -0
  135. package/clis/linkedin/salesnav-message.test.js +172 -0
  136. package/clis/linkedin/salesnav-search.js +186 -0
  137. package/clis/linkedin/salesnav-search.test.js +76 -0
  138. package/clis/linkedin/salesnav-thread.js +212 -0
  139. package/clis/linkedin/salesnav-thread.test.js +79 -0
  140. package/clis/linkedin/sent-invitations.js +92 -0
  141. package/clis/linkedin/sent-invitations.test.js +62 -0
  142. package/clis/linkedin/services-read.js +213 -0
  143. package/clis/linkedin/services-read.test.js +105 -0
  144. package/clis/linkedin/shared.js +124 -0
  145. package/clis/linkedin/thread-snapshot.js +214 -0
  146. package/clis/linkedin/thread-snapshot.test.js +89 -0
  147. package/clis/linkedin/timeline.js +14 -7
  148. package/clis/linkedin-learning/course.js +138 -0
  149. package/clis/linkedin-learning/course.test.js +114 -0
  150. package/clis/linkedin-learning/search.js +155 -0
  151. package/clis/linkedin-learning/search.test.js +144 -0
  152. package/clis/linkedin-learning/trending.js +133 -0
  153. package/clis/linkedin-learning/trending.test.js +123 -0
  154. package/clis/notebooklm/add-source.js +269 -0
  155. package/clis/notebooklm/add-source.test.js +97 -0
  156. package/clis/notebooklm/create.js +76 -0
  157. package/clis/notebooklm/create.test.js +58 -0
  158. package/clis/notebooklm/generate-audio.js +91 -0
  159. package/clis/notebooklm/generate-audio.test.js +63 -0
  160. package/clis/notebooklm/generate-slides.js +106 -0
  161. package/clis/notebooklm/generate-slides.test.js +75 -0
  162. package/clis/notebooklm/open.test.js +10 -10
  163. package/clis/notebooklm/rpc.js +20 -6
  164. package/clis/notebooklm/rpc.test.js +27 -1
  165. package/clis/notebooklm/utils.js +100 -24
  166. package/clis/notebooklm/utils.test.js +60 -1
  167. package/clis/notebooklm/write-note.js +103 -0
  168. package/clis/notebooklm/write-note.test.js +70 -0
  169. package/clis/pixiv/detail.js +41 -34
  170. package/clis/pixiv/detail.test.js +93 -0
  171. package/clis/pixiv/user.js +36 -31
  172. package/clis/pixiv/user.test.js +100 -0
  173. package/clis/pixiv/utils.js +56 -7
  174. package/clis/powerchina/search.js +3 -3
  175. package/clis/powerchina/search.test.js +27 -1
  176. package/clis/reddit/extract-media.test.js +149 -0
  177. package/clis/reddit/frontpage.js +47 -9
  178. package/clis/reddit/frontpage.test.js +34 -0
  179. package/clis/reddit/home.js +31 -1
  180. package/clis/reddit/home.test.js +46 -3
  181. package/clis/reddit/hot.js +32 -1
  182. package/clis/reddit/hot.test.js +15 -1
  183. package/clis/reddit/popular.js +39 -1
  184. package/clis/reddit/popular.test.js +26 -0
  185. package/clis/reddit/saved.js +1 -1
  186. package/clis/reddit/search.js +38 -1
  187. package/clis/reddit/search.test.js +26 -0
  188. package/clis/reddit/subreddit.js +52 -7
  189. package/clis/reddit/subreddit.test.js +31 -0
  190. package/clis/reddit/subscribed.js +165 -0
  191. package/clis/reddit/subscribed.test.js +168 -0
  192. package/clis/reddit/upvoted.js +1 -1
  193. package/clis/suno/commands.test.js +188 -0
  194. package/clis/suno/download.js +140 -0
  195. package/clis/suno/download.test.js +151 -0
  196. package/clis/suno/generate.js +231 -0
  197. package/clis/suno/generate.test.js +252 -0
  198. package/clis/suno/list.js +79 -0
  199. package/clis/suno/status.js +63 -0
  200. package/clis/suno/utils.js +549 -0
  201. package/clis/suno/utils.test.js +329 -0
  202. package/clis/twitter/device-follow.js +193 -0
  203. package/clis/twitter/device-follow.test.js +287 -0
  204. package/clis/twitter/download.js +443 -73
  205. package/clis/twitter/download.test.js +457 -0
  206. package/clis/twitter/followers.js +6 -2
  207. package/clis/twitter/followers.test.js +19 -1
  208. package/clis/twitter/following.js +14 -5
  209. package/clis/twitter/following.test.js +29 -0
  210. package/clis/twitter/likes.js +12 -4
  211. package/clis/twitter/likes.test.js +26 -1
  212. package/clis/twitter/list-add.js +1 -1
  213. package/clis/twitter/list-create.js +155 -0
  214. package/clis/twitter/list-create.test.js +169 -0
  215. package/clis/twitter/list-remove.js +13 -6
  216. package/clis/twitter/list-remove.test.js +74 -0
  217. package/clis/twitter/list-tweets.js +6 -2
  218. package/clis/twitter/list-tweets.test.js +41 -1
  219. package/clis/twitter/lists.js +31 -4
  220. package/clis/twitter/lists.test.js +152 -16
  221. package/clis/twitter/notifications.js +4 -4
  222. package/clis/twitter/post.js +62 -4
  223. package/clis/twitter/post.test.js +35 -3
  224. package/clis/twitter/profile.js +81 -28
  225. package/clis/twitter/profile.test.js +113 -2
  226. package/clis/twitter/quote.js +9 -4
  227. package/clis/twitter/reply.js +13 -10
  228. package/clis/twitter/reply.test.js +41 -0
  229. package/clis/twitter/search.js +7 -3
  230. package/clis/twitter/search.test.js +41 -0
  231. package/clis/twitter/shared.js +155 -0
  232. package/clis/twitter/shared.test.js +465 -1
  233. package/clis/twitter/thread.js +10 -2
  234. package/clis/twitter/thread.test.js +58 -0
  235. package/clis/twitter/timeline.js +6 -2
  236. package/clis/twitter/timeline.test.js +2 -0
  237. package/clis/twitter/tweets.js +3 -2
  238. package/clis/twitter/tweets.test.js +1 -1
  239. package/clis/twitter/utils.js +53 -16
  240. package/clis/upwork/detail.js +132 -0
  241. package/clis/upwork/feed.js +109 -0
  242. package/clis/upwork/search.js +115 -0
  243. package/clis/upwork/upwork.test.js +566 -0
  244. package/clis/upwork/utils.js +323 -0
  245. package/clis/weibo/delete.js +172 -0
  246. package/clis/weibo/delete.test.js +94 -0
  247. package/clis/weibo/publish.js +37 -14
  248. package/clis/weibo/publish.test.js +14 -5
  249. package/clis/weibo/user-posts.js +234 -0
  250. package/clis/weibo/user-posts.test.js +92 -0
  251. package/clis/weread/book-search.js +438 -0
  252. package/clis/weread/book-search.test.js +242 -0
  253. package/clis/weread/search-regression.test.js +98 -11
  254. package/clis/weread/search.js +32 -9
  255. package/clis/weread-official/book.js +135 -0
  256. package/clis/weread-official/commands.test.js +385 -0
  257. package/clis/weread-official/discover.js +107 -0
  258. package/clis/weread-official/list-apis.js +95 -0
  259. package/clis/weread-official/notes.js +171 -0
  260. package/clis/weread-official/readdata.js +158 -0
  261. package/clis/weread-official/review.js +93 -0
  262. package/clis/weread-official/search.js +106 -0
  263. package/clis/weread-official/shelf.js +97 -0
  264. package/clis/weread-official/utils.js +293 -0
  265. package/clis/weread-official/utils.test.js +242 -0
  266. package/clis/wikipedia/trending.js +7 -3
  267. package/clis/wikipedia/trending.test.js +57 -0
  268. package/clis/xianyu/chat.js +24 -109
  269. package/clis/xianyu/chat.test.js +5 -0
  270. package/clis/xianyu/im.js +322 -0
  271. package/clis/xianyu/im.test.js +253 -0
  272. package/clis/xianyu/inbox.js +96 -0
  273. package/clis/xianyu/messages.js +91 -0
  274. package/clis/xianyu/reply.js +82 -0
  275. package/clis/xiaohongshu/creator-note-detail.js +166 -28
  276. package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
  277. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  278. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  279. package/clis/xiaohongshu/creator-notes.js +252 -2
  280. package/clis/xiaohongshu/creator-notes.test.js +90 -1
  281. package/clis/xiaohongshu/creator-stats.js +2 -1
  282. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  283. package/clis/xiaohongshu/delete-note.js +260 -0
  284. package/clis/xiaohongshu/delete-note.test.js +172 -0
  285. package/clis/xiaohongshu/download.js +97 -39
  286. package/clis/xiaohongshu/download.test.js +201 -0
  287. package/clis/xiaohongshu/publish.js +48 -8
  288. package/clis/xiaohongshu/publish.test.js +65 -10
  289. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  290. package/clis/xiaohongshu/user.js +27 -4
  291. package/clis/xiaoyuzhou/download.js +1 -1
  292. package/clis/xiaoyuzhou/transcript.js +1 -1
  293. package/clis/youdao/note.js +258 -0
  294. package/clis/youdao/note.test.js +99 -0
  295. package/clis/youtube/transcript.js +397 -24
  296. package/clis/youtube/transcript.test.js +196 -6
  297. package/clis/zhihu/answer-comments.js +280 -0
  298. package/clis/zhihu/answer-comments.test.js +287 -0
  299. package/clis/zhihu/answer-detail.js +2 -19
  300. package/clis/zhihu/answer-detail.test.js +8 -0
  301. package/clis/zhihu/collection.js +17 -16
  302. package/clis/zhihu/collection.test.js +50 -3
  303. package/clis/zhihu/download.js +1 -1
  304. package/clis/zhihu/question.js +42 -17
  305. package/clis/zhihu/question.test.js +113 -11
  306. package/clis/zhihu/search.js +195 -43
  307. package/clis/zhihu/search.test.js +198 -0
  308. package/clis/zhihu/text.js +29 -0
  309. package/clis/zhihu/text.test.js +24 -0
  310. package/dist/src/browser/errors.js +4 -2
  311. package/dist/src/browser/errors.test.js +6 -0
  312. package/dist/src/browser/network-cache.js +13 -1
  313. package/dist/src/browser/network-cache.test.js +17 -0
  314. package/dist/src/browser/page.js +30 -4
  315. package/dist/src/browser/page.test.js +42 -0
  316. package/dist/src/browser/utils.d.ts +1 -1
  317. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  318. package/dist/src/cli-argv-preprocess.js +138 -0
  319. package/dist/src/cli-argv-preprocess.test.js +79 -0
  320. package/dist/src/convention-audit.js +15 -8
  321. package/dist/src/convention-audit.test.js +21 -0
  322. package/dist/src/download/index.js +13 -1
  323. package/dist/src/download/index.test.js +23 -1
  324. package/dist/src/download/media-download.js +15 -2
  325. package/dist/src/download/media-download.test.d.ts +1 -0
  326. package/dist/src/download/media-download.test.js +112 -0
  327. package/dist/src/download/progress.js +2 -2
  328. package/dist/src/download/progress.test.js +12 -1
  329. package/dist/src/electron-apps.js +1 -1
  330. package/dist/src/electron-apps.test.js +7 -2
  331. package/dist/src/errors.d.ts +17 -0
  332. package/dist/src/errors.js +22 -0
  333. package/dist/src/external-clis.yaml +8 -0
  334. package/dist/src/main.js +14 -2
  335. package/dist/src/output.js +11 -1
  336. package/dist/src/output.test.js +6 -0
  337. package/dist/src/registry.js +1 -0
  338. package/dist/src/registry.test.js +11 -0
  339. package/dist/src/utils.d.ts +43 -0
  340. package/dist/src/utils.js +97 -0
  341. package/dist/src/utils.test.d.ts +1 -0
  342. package/dist/src/utils.test.js +155 -0
  343. package/package.json +8 -2
  344. package/scripts/silent-column-drop-baseline.json +0 -52
  345. package/scripts/typed-error-lint-baseline.json +28 -380
  346. package/clis/slock/_utils.js +0 -12
@@ -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
+ };
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import './salesnav-thread.js';
4
+
5
+ const {
6
+ parseThreadInput,
7
+ threadApiUrl,
8
+ parseSalesnavThreadMessages,
9
+ threadMatchesInput,
10
+ salesnavThreadUrl,
11
+ } = await import('./salesnav-thread.js').then((m) => m.__test__);
12
+
13
+ describe('linkedin salesnav-thread command', () => {
14
+ it('accepts Sales Navigator inbox URLs, raw thread ids, lead URLs, urns, and names', () => {
15
+ expect(parseThreadInput('https://www.linkedin.com/sales/inbox/2-abc%3D%3D')).toEqual(['thread_id', '2-abc==']);
16
+ expect(parseThreadInput('2-abc==')).toEqual(['thread_id', '2-abc==']);
17
+ expect(parseThreadInput('https://www.linkedin.com/sales/lead/P1,NAME_SEARCH,T1')).toEqual(['recipient_urn', 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)']);
18
+ expect(parseThreadInput('urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)')).toEqual(['recipient_urn', 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)']);
19
+ expect(parseThreadInput('Rachael Stolberg')).toEqual(['name', 'rachael stolberg']);
20
+ expect(() => parseThreadInput('urn:li:fs_salesProfile:(P1,undefined,T1)')).toThrow(ArgumentError);
21
+ expect(() => parseThreadInput('https://www.linkedin.com/sales/lead/P1,undefined,T1')).toThrow(ArgumentError);
22
+ });
23
+
24
+ it('builds the decorated thread API URL with encoded parentheses and message pagination size', () => {
25
+ const url = threadApiUrl('2-thread==', 40);
26
+ expect(url).toContain('/sales-api/salesApiMessagingThreads/2-thread%3D%3D?');
27
+ expect(url).toContain('messageCount=40');
28
+ expect(url).toContain('count=1');
29
+ expect(url).toContain('decoration=%28');
30
+ expect(url).not.toContain('decoration=(');
31
+ });
32
+
33
+ it('parses thread messages in chronological order with sender names and timestamps', () => {
34
+ const rows = parseSalesnavThreadMessages({
35
+ id: '2-thread',
36
+ participants: [
37
+ 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)',
38
+ 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)',
39
+ ],
40
+ participantsResolutionResults: {
41
+ 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)': { fullName: 'Rachael Stolberg', entityUrn: 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)' },
42
+ 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)': { fullName: 'Hanzi Li', entityUrn: 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)' },
43
+ },
44
+ messages: [
45
+ { id: 'm2', author: 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)', body: 'Happy to chat', deliveredAt: 1778206803669, type: 'MEMBER_TO_MEMBER' },
46
+ { id: 'm1', author: 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)', subject: 'Quick question', body: 'Hi Rachael', deliveredAt: 1777188013523, type: 'INMAIL' },
47
+ ],
48
+ });
49
+ expect(rows).toHaveLength(2);
50
+ expect(rows[0]).toMatchObject({
51
+ index: 0,
52
+ message_id: 'm1',
53
+ thread_id: '2-thread',
54
+ sender: 'Hanzi Li',
55
+ subject: 'Quick question',
56
+ text: 'Hi Rachael',
57
+ timestamp: '2026-04-26T07:20:13.523Z',
58
+ });
59
+ expect(rows[1]).toMatchObject({ index: 1, message_id: 'm2', sender: 'Rachael Stolberg', text: 'Happy to chat' });
60
+ });
61
+
62
+ it('fails typed on malformed thread message payloads', () => {
63
+ expect(() => parseSalesnavThreadMessages({ messages: [] })).toThrow(CommandExecutionError);
64
+ expect(() => parseSalesnavThreadMessages({ id: '2-thread' })).toThrow(CommandExecutionError);
65
+ expect(() => parseSalesnavThreadMessages({ id: '2-thread', messages: [null] })).toThrow(CommandExecutionError);
66
+ });
67
+
68
+ it('matches recipient identifiers against inbox rows', () => {
69
+ const row = {
70
+ thread_id: '2-thread',
71
+ person_name: 'Rachael Stolberg',
72
+ participants: [{ name: 'Rachael Stolberg', entity_urn: 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)' }],
73
+ };
74
+ expect(threadMatchesInput(row, ['thread_id', '2-thread'])).toBe(true);
75
+ expect(threadMatchesInput(row, ['recipient_urn', 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)'])).toBe(true);
76
+ expect(threadMatchesInput(row, ['name', 'rachael stolberg'])).toBe(true);
77
+ expect(salesnavThreadUrl('2-thread')).toBe('https://www.linkedin.com/sales/inbox/2-thread');
78
+ });
79
+ });
@@ -0,0 +1,92 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+
4
+ const LINKEDIN_DOMAIN = 'www.linkedin.com';
5
+ const SENT_URL = 'https://www.linkedin.com/mynetwork/invitation-manager/sent/';
6
+
7
+ function unwrapEvaluateResult(payload) {
8
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
9
+ return payload;
10
+ }
11
+
12
+ function buildSentInvitationsScript() {
13
+ return String.raw`(() => {
14
+ const clean = (s) => String(s || '').replace(/[  ]/g, ' ').replace(/\s+/g, ' ').trim();
15
+ const text = document.body ? (document.body.innerText || '') : '';
16
+ const href = location.href;
17
+ const authRequired = /\b(sign in|log in|join linkedin)\b/i.test(text)
18
+ || /linkedin\.com\/(login|checkpoint|authwall|uas)/i.test(href);
19
+ const warning = /captcha|verification required|unusual activity|account restricted|temporarily restricted|security check|checkpoint/i.test(text);
20
+ const cleanName = (value) => {
21
+ const first = String(value || '').split(/\n+/).map(clean).filter(Boolean)[0] || '';
22
+ return clean(first
23
+ .replace(/^(view\s+)?profile\s+of\s+/i, '')
24
+ .replace(/\s*(?:View profile|LinkedIn|Pending|Sent|Withdraw).*$/i, ''));
25
+ };
26
+ const cards = Array.from(document.querySelectorAll('li, div, section, article')).filter((el) => {
27
+ if (!el || el.offsetParent === null) return false;
28
+ const t = clean(el.innerText || el.textContent || '');
29
+ return t && /withdraw/i.test(t) && t.length < 1200;
30
+ });
31
+ const byName = new Map();
32
+ for (const card of cards) {
33
+ const raw = card.innerText || card.textContent || '';
34
+ if (!/withdraw/i.test(raw)) continue;
35
+ const lines = raw.split(/\n+/).map(clean).filter(Boolean);
36
+ const link = card.querySelector('a[href*="/in/"]');
37
+ const linkName = cleanName(link ? (link.innerText || link.textContent || link.getAttribute('aria-label') || '') : '');
38
+ const name = linkName
39
+ || cleanName(lines.find((line) => !/^(pending|sent|withdraw|message|view profile|invitation|invited|ago|manage|received)\b/i.test(line)) || '');
40
+ if (!name) continue;
41
+ const hrefAttr = link ? (link.getAttribute('href') || '') : '';
42
+ const profile_url = hrefAttr ? new URL(hrefAttr, location.origin).toString().replace(/[?#].*$/, '') : '';
43
+ const invited_date_text = clean((raw.match(/(?:Sent|Invited)\s+(?:\d+\s+\w+\s+ago|yesterday|today)/i) || [''])[0]);
44
+ const key = name.toLowerCase();
45
+ const existing = byName.get(key);
46
+ if (!existing) {
47
+ byName.set(key, { name, profile_url, invited_date_text });
48
+ } else {
49
+ if (!existing.profile_url && profile_url) existing.profile_url = profile_url;
50
+ if (!existing.invited_date_text && invited_date_text) existing.invited_date_text = invited_date_text;
51
+ }
52
+ }
53
+ const rows = Array.from(byName.values());
54
+ return { url: href, title: document.title || '', authRequired, warning, count: rows.length, rows, bodyText: text.slice(0, 1000) };
55
+ })()`;
56
+ }
57
+
58
+ cli({
59
+ site: 'linkedin',
60
+ name: 'sent-invitations',
61
+ access: 'read',
62
+ description: 'List pending LinkedIn sent invitations for CRM reconciliation',
63
+ domain: LINKEDIN_DOMAIN,
64
+ strategy: Strategy.UI,
65
+ browser: true,
66
+ args: [],
67
+ columns: ['rank', 'name', 'profile_url', 'invited_date_text'],
68
+ func: async (page) => {
69
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin sent-invitations');
70
+ await page.goto(SENT_URL);
71
+ await page.wait(12);
72
+ let result = unwrapEvaluateResult(await page.evaluate(buildSentInvitationsScript()));
73
+ if (result?.authRequired) {
74
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn sent invitations requires an active signed-in browser session.');
75
+ }
76
+ if (result?.warning) {
77
+ throw new CommandExecutionError('LinkedIn warning/restriction state visible on sent invitations page.');
78
+ }
79
+ const rows = Array.isArray(result?.rows) ? result.rows : [];
80
+ return rows.map((row, index) => ({
81
+ rank: index + 1,
82
+ name: row.name || '',
83
+ profile_url: row.profile_url || '',
84
+ invited_date_text: row.invited_date_text || '',
85
+ }));
86
+ },
87
+ });
88
+
89
+ export const __test__ = {
90
+ buildSentInvitationsScript,
91
+ unwrapEvaluateResult,
92
+ };