@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,210 @@
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_INBOX_URL = 'https://www.linkedin.com/sales/inbox/';
6
+ const THREADS_BASE = 'https://www.linkedin.com/sales-api/salesApiMessagingThreads';
7
+ const PAGE_SIZE = 20;
8
+ const DEFAULT_LIMIT = 40;
9
+ const MAX_LIMIT = 500;
10
+ const THREAD_DECORATION = '(id,restrictions,archived,unreadMessageCount,nextPageStartsAt,totalMessageCount,messages*(id,type,contentFlag,deliveredAt,lastEditedAt,subject,body,footerText,blockCopy,attachments,author,systemMessageContent),participants*~fs_salesProfile(entityUrn,firstName,lastName,fullName,degree,profilePictureDisplayImage,objectUrn,inmailRestriction))';
11
+
12
+ export function normalizeWhitespace(value) {
13
+ return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
14
+ }
15
+
16
+ export function parseLimit(value, defaultValue = DEFAULT_LIMIT) {
17
+ if (value === undefined || value === null || value === '') return defaultValue;
18
+ const limit = Number(value);
19
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
20
+ throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
21
+ }
22
+ return limit;
23
+ }
24
+
25
+ function unwrapEvaluateResult(payload) {
26
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
27
+ return payload;
28
+ }
29
+
30
+ export function encodeRestliDecoration(value) {
31
+ // LinkedIn's Sales Navigator Rest.li endpoint returns HTTP 400 when this
32
+ // decoration is sent with literal parentheses. Keep parentheses percent-encoded.
33
+ return encodeURIComponent(value).replace(/\(/g, '%28').replace(/\)/g, '%29');
34
+ }
35
+
36
+ function salesnavThreadUrl(threadId) {
37
+ return threadId ? `https://www.linkedin.com/sales/inbox/${encodeURIComponent(threadId)}` : '';
38
+ }
39
+
40
+ function threadListUrl({ count = PAGE_SIZE, pageStartsAt = '' } = {}) {
41
+ let url = `${THREADS_BASE}?decoration=${encodeRestliDecoration(THREAD_DECORATION)}&count=${count}&filter=INBOX&q=filter`;
42
+ if (pageStartsAt) url += `&pageStartsAt=${encodeURIComponent(pageStartsAt)}`;
43
+ return url;
44
+ }
45
+
46
+ function getThreadParticipants(thread) {
47
+ const resolution = thread?.participantsResolutionResults || {};
48
+ const participants = Array.isArray(thread?.participants) ? thread.participants : Object.keys(resolution);
49
+ return participants.map((urn) => resolution[urn] || { entityUrn: urn }).filter(Boolean);
50
+ }
51
+
52
+ function isSelfParticipant(profile) {
53
+ const degree = String(profile?.degree ?? '').trim();
54
+ return degree === '0';
55
+ }
56
+
57
+ function otherParticipantName(thread) {
58
+ const participants = getThreadParticipants(thread);
59
+ const other = participants.find((p) => !isSelfParticipant(p)) || participants[0];
60
+ return normalizeWhitespace(other?.fullName || [other?.firstName, other?.lastName].filter(Boolean).join(' '));
61
+ }
62
+
63
+ function parseSalesnavThreads(json) {
64
+ if (!json || typeof json !== 'object' || !Array.isArray(json.elements)) {
65
+ throw new CommandExecutionError('Sales Navigator messaging threads API returned malformed payload');
66
+ }
67
+ return json.elements.map((thread) => {
68
+ if (!thread || typeof thread !== 'object') {
69
+ throw new CommandExecutionError('Sales Navigator messaging threads API returned malformed thread row');
70
+ }
71
+ const messages = Array.isArray(thread?.messages) ? thread.messages : [];
72
+ const lastMessage = messages[0] || {};
73
+ const deliveredAt = Number(lastMessage.deliveredAt || thread?.nextPageStartsAt || 0);
74
+ const threadId = normalizeWhitespace(thread?.id || '');
75
+ if (!threadId) {
76
+ throw new CommandExecutionError('Sales Navigator messaging thread row missing id');
77
+ }
78
+ return {
79
+ thread_id: threadId,
80
+ thread_url: salesnavThreadUrl(threadId),
81
+ person_name: otherParticipantName(thread),
82
+ last_message_snippet: normalizeWhitespace(lastMessage.body || lastMessage.subject || '').slice(0, 300),
83
+ last_activity_time: deliveredAt ? new Date(deliveredAt).toISOString() : '',
84
+ unread: Number(thread?.unreadMessageCount || 0) > 0,
85
+ unread_count: Number(thread?.unreadMessageCount || 0),
86
+ total_message_count: Number(thread?.totalMessageCount || messages.length || 0),
87
+ archived: Boolean(thread?.archived),
88
+ next_page_starts_at: normalizeWhitespace(thread?.nextPageStartsAt || ''),
89
+ participants: getThreadParticipants(thread).map((p) => ({
90
+ name: normalizeWhitespace(p.fullName || [p.firstName, p.lastName].filter(Boolean).join(' ')),
91
+ entity_urn: normalizeWhitespace(p.entityUrn || ''),
92
+ object_urn: normalizeWhitespace(p.objectUrn || ''),
93
+ degree: normalizeWhitespace(p.degree ?? ''),
94
+ })),
95
+ };
96
+ });
97
+ }
98
+
99
+ function fetchJsonScript(url, csrf) {
100
+ return String.raw`(async () => {
101
+ try {
102
+ const res = await fetch(${JSON.stringify(url)}, {
103
+ credentials: 'include',
104
+ headers: {
105
+ 'csrf-token': ${JSON.stringify(csrf)},
106
+ 'x-restli-protocol-version': '2.0.0',
107
+ accept: 'application/json',
108
+ },
109
+ });
110
+ const text = await res.text();
111
+ let json = null;
112
+ try { json = text ? JSON.parse(text) : null; } catch (_) { json = null; }
113
+ if (res.status === 401 || res.status === 403) return { authRequired: true, status: res.status, text };
114
+ if (!res.ok) return { error: 'HTTP ' + res.status, status: res.status, text, json };
115
+ return { status: res.status, json };
116
+ } catch (e) {
117
+ return { error: 'fetch failed: ' + ((e && e.message) || String(e)) };
118
+ }
119
+ })()`;
120
+ }
121
+
122
+ export async function getCsrf(page) {
123
+ const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
124
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
125
+ if (!jsession) throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn.');
126
+ return jsession.replace(/^\"|\"$/g, '');
127
+ }
128
+
129
+ export async function fetchSalesnavJson(page, csrf, url, label) {
130
+ const result = unwrapEvaluateResult(await page.evaluate(fetchJsonScript(url, csrf)));
131
+ if (result?.authRequired) throw new AuthRequiredError(LINKEDIN_DOMAIN, `${label} authentication failed (HTTP ${result.status || 'auth_required'}).`);
132
+ if (result?.error || !result?.json) throw new CommandExecutionError(`${label} returned an unexpected response`, `${result?.error || 'no_json'}\n${normalizeWhitespace(result?.text || '').slice(0, 500)}`);
133
+ return result.json;
134
+ }
135
+
136
+ export async function fetchInboxRows(page, { limit = DEFAULT_LIMIT, maxPages = 30 } = {}) {
137
+ const csrf = await getCsrf(page);
138
+ const rows = [];
139
+ const seen = new Set();
140
+ let pageStartsAt = '';
141
+ let pagesFetched = 0;
142
+ let hasMorePages = false;
143
+ while (rows.length < limit && pagesFetched < maxPages) {
144
+ const json = await fetchSalesnavJson(page, csrf, threadListUrl({ count: PAGE_SIZE, pageStartsAt }), 'Sales Navigator messaging threads API');
145
+ pagesFetched += 1;
146
+ const pageRows = parseSalesnavThreads(json);
147
+ if (pageRows.length === 0) break;
148
+ for (const row of pageRows) {
149
+ if (seen.has(row.thread_id)) continue;
150
+ seen.add(row.thread_id);
151
+ rows.push(row);
152
+ if (rows.length >= limit) break;
153
+ }
154
+ const last = pageRows[pageRows.length - 1];
155
+ const next = last?.next_page_starts_at;
156
+ hasMorePages = Boolean(next);
157
+ if (!next) break;
158
+ if (next === pageStartsAt) {
159
+ throw new CommandExecutionError('Sales Navigator messaging threads API returned the same cursor twice');
160
+ }
161
+ pageStartsAt = next;
162
+ }
163
+ if (rows.length < limit && hasMorePages && pagesFetched >= maxPages) {
164
+ throw new CommandExecutionError(`Sales Navigator messaging threads API reached the ${maxPages}-page safety cap before collecting ${limit} conversations`);
165
+ }
166
+ return rows.slice(0, limit).map((row, index) => ({ ...row, rank: index + 1 }));
167
+ }
168
+
169
+ export { THREAD_DECORATION, THREADS_BASE };
170
+
171
+ cli({
172
+ site: 'linkedin',
173
+ name: 'salesnav-inbox',
174
+ access: 'read',
175
+ description: 'List LinkedIn Sales Navigator message conversations with API pagination',
176
+ domain: LINKEDIN_DOMAIN,
177
+ strategy: Strategy.UI,
178
+ browser: true,
179
+ args: [
180
+ { name: 'limit', type: 'number', default: DEFAULT_LIMIT, help: 'Maximum conversations to return (1-500)' },
181
+ { name: 'max-pages', type: 'number', default: 30, help: 'Maximum Sales Navigator API pages to fetch' },
182
+ { name: 'unread-only', type: 'bool', default: false, help: 'Return only unread conversations' },
183
+ ],
184
+ columns: ['rank', 'thread_id', 'thread_url', 'person_name', 'last_message_snippet', 'last_activity_time', 'unread', 'unread_count', 'total_message_count', 'archived', 'participants', 'next_page_starts_at'],
185
+ func: async (page, args) => {
186
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin salesnav-inbox');
187
+ const limit = parseLimit(args.limit);
188
+ const maxPages = parseLimit(args['max-pages'], 30);
189
+ await page.goto(SALES_INBOX_URL);
190
+ await page.wait(4);
191
+ let rows = await fetchInboxRows(page, { limit, maxPages });
192
+ if (args['unread-only']) rows = rows.filter((row) => row.unread);
193
+ if (rows.length === 0) {
194
+ if (args['unread-only']) return [];
195
+ throw new EmptyResultError('linkedin salesnav-inbox', 'No Sales Navigator conversations were found.');
196
+ }
197
+ return rows.slice(0, limit).map((row, index) => ({ ...row, rank: index + 1 }));
198
+ },
199
+ });
200
+
201
+ export const __test__ = {
202
+ THREAD_DECORATION,
203
+ normalizeWhitespace,
204
+ parseLimit,
205
+ encodeRestliDecoration,
206
+ salesnavThreadUrl,
207
+ threadListUrl,
208
+ parseSalesnavThreads,
209
+ fetchInboxRows,
210
+ };
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import './salesnav-inbox.js';
4
+
5
+ const {
6
+ THREAD_DECORATION,
7
+ encodeRestliDecoration,
8
+ parseLimit,
9
+ parseSalesnavThreads,
10
+ salesnavThreadUrl,
11
+ threadListUrl,
12
+ } = await import('./salesnav-inbox.js').then((m) => m.__test__);
13
+
14
+ describe('linkedin salesnav-inbox command', () => {
15
+ it('percent-encodes Rest.li decoration parentheses for Sales Navigator messaging', () => {
16
+ const encoded = encodeRestliDecoration('(id,messages*(body))');
17
+ expect(encoded).toBe('%28id%2Cmessages*%28body%29%29');
18
+ expect(encoded).not.toContain('(');
19
+ expect(encoded).not.toContain(')');
20
+ });
21
+
22
+ it('builds the paginated salesApiMessagingThreads inbox URL', () => {
23
+ const url = threadListUrl({ count: 20, pageStartsAt: '1779070755626' });
24
+ expect(url).toContain('/sales-api/salesApiMessagingThreads?');
25
+ expect(url).toContain('q=filter');
26
+ expect(url).toContain('filter=INBOX');
27
+ expect(url).toContain('count=20');
28
+ expect(url).toContain('pageStartsAt=1779070755626');
29
+ expect(url).toContain(encodeRestliDecoration(THREAD_DECORATION));
30
+ });
31
+
32
+ it('validates limits without silent clamping', () => {
33
+ expect(parseLimit(undefined)).toBe(40);
34
+ expect(parseLimit(12)).toBe(12);
35
+ expect(() => parseLimit(0)).toThrow();
36
+ expect(() => parseLimit(501)).toThrow();
37
+ expect(() => parseLimit('abc')).toThrow();
38
+ });
39
+
40
+ it('parses Sales Navigator thread rows with other participant and unread state', () => {
41
+ const rows = parseSalesnavThreads({ elements: [{
42
+ id: '2-thread',
43
+ unreadMessageCount: 1,
44
+ archived: false,
45
+ totalMessageCount: 2,
46
+ nextPageStartsAt: 1778206803669,
47
+ participants: [
48
+ 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)',
49
+ 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)',
50
+ ],
51
+ participantsResolutionResults: {
52
+ 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)': {
53
+ entityUrn: 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)',
54
+ firstName: 'Rachael',
55
+ lastName: 'Stolberg',
56
+ fullName: 'Rachael Stolberg',
57
+ degree: 2,
58
+ },
59
+ 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)': {
60
+ entityUrn: 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)',
61
+ firstName: 'Hanzi',
62
+ lastName: 'Li',
63
+ fullName: 'Hanzi Li',
64
+ degree: 0,
65
+ },
66
+ },
67
+ messages: [{
68
+ id: 'msg-1',
69
+ author: 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)',
70
+ body: 'Hi hanzi, happy to chat',
71
+ deliveredAt: 1778206803669,
72
+ }],
73
+ }] });
74
+ expect(rows).toHaveLength(1);
75
+ expect(rows[0]).toMatchObject({
76
+ thread_id: '2-thread',
77
+ thread_url: salesnavThreadUrl('2-thread'),
78
+ person_name: 'Rachael Stolberg',
79
+ last_message_snippet: 'Hi hanzi, happy to chat',
80
+ last_activity_time: '2026-05-08T02:20:03.669Z',
81
+ unread: true,
82
+ unread_count: 1,
83
+ total_message_count: 2,
84
+ });
85
+ });
86
+
87
+ it('does not hard-code a specific account name as the inbox owner', () => {
88
+ const rows = parseSalesnavThreads({ elements: [{
89
+ id: '2-thread',
90
+ participants: [
91
+ 'urn:li:fs_salesProfile:(HANZI,NAME_SEARCH,T1)',
92
+ 'urn:li:fs_salesProfile:(ME,NAME_SEARCH,T2)',
93
+ ],
94
+ participantsResolutionResults: {
95
+ 'urn:li:fs_salesProfile:(HANZI,NAME_SEARCH,T1)': {
96
+ fullName: 'Hanzi Li',
97
+ degree: 2,
98
+ },
99
+ 'urn:li:fs_salesProfile:(ME,NAME_SEARCH,T2)': {
100
+ fullName: 'Current User',
101
+ degree: 0,
102
+ },
103
+ },
104
+ messages: [],
105
+ }] });
106
+ expect(rows[0].person_name).toBe('Hanzi Li');
107
+ });
108
+
109
+ it('fails typed on malformed thread payloads and missing thread identity', () => {
110
+ expect(() => parseSalesnavThreads({})).toThrow(CommandExecutionError);
111
+ expect(() => parseSalesnavThreads({ elements: [{}] })).toThrow(CommandExecutionError);
112
+ });
113
+ });
@@ -0,0 +1,360 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+
4
+ const LINKEDIN_DOMAIN = 'www.linkedin.com';
5
+ const SALES_HOME = 'https://www.linkedin.com/sales/';
6
+ const PROFILE_DECO = '(entityUrn,objectUrn,firstName,lastName,fullName,headline,degree,inmailRestriction,memberBadges,defaultPosition)';
7
+ const CREDITS_URL = 'https://www.linkedin.com/sales-api/salesApiCredits?q=findCreditGrant&creditGrantType=LSS_INMAIL';
8
+ const MESSAGE_ACTION_URL = 'https://www.linkedin.com/sales-api/salesApiMessageActions?action=createMessage';
9
+
10
+ function normalizeWhitespace(value) {
11
+ return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
12
+ }
13
+
14
+ function requireStringArg(args, key, label = key) {
15
+ const value = normalizeWhitespace(args[key]);
16
+ if (!value) throw new ArgumentError(`${label} is required`);
17
+ return value;
18
+ }
19
+
20
+ function unwrapEvaluateResult(payload) {
21
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
22
+ return payload;
23
+ }
24
+
25
+ function isLinkedInHost(hostname) {
26
+ const host = String(hostname || '').toLowerCase();
27
+ return host === 'linkedin.com' || host.endsWith('.linkedin.com');
28
+ }
29
+
30
+ function parseSalesProfileUrn(value) {
31
+ const raw = normalizeWhitespace(value);
32
+ const match = raw.match(/^urn:li:fs_salesProfile:\(([^,()]+),([^,()]+),([^,()]+)\)$/);
33
+ if (!match) return null;
34
+ if (!isResolvedSalesProfileParts(match[1], match[2], match[3])) return null;
35
+ return { profileId: match[1], authType: match[2], authToken: match[3], entityUrn: raw };
36
+ }
37
+
38
+ function isResolvedSalesProfileParts(profileId, authType, authToken) {
39
+ return [profileId, authType, authToken].every((part) => {
40
+ const clean = normalizeWhitespace(part).toLowerCase();
41
+ return clean && clean !== 'undefined' && clean !== 'null' && clean !== 'not_available';
42
+ });
43
+ }
44
+
45
+ function salesLeadUrlFromParts({ profileId, authType, authToken }) {
46
+ return `https://www.linkedin.com/sales/lead/${encodeURIComponent(profileId)},${encodeURIComponent(authType)},${encodeURIComponent(authToken)}`;
47
+ }
48
+
49
+ function parseRecipient(value) {
50
+ const raw = normalizeWhitespace(value);
51
+ const urn = parseSalesProfileUrn(raw);
52
+ if (urn) return urn;
53
+ try {
54
+ const url = new URL(raw);
55
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return null;
56
+ const salesMatch = url.pathname.match(/^\/sales\/lead\/([^,/]+),([^,/]+),([^/]+)\/?$/i);
57
+ if (salesMatch) {
58
+ const profileId = decodeURIComponent(salesMatch[1]);
59
+ const authType = decodeURIComponent(salesMatch[2]);
60
+ const authToken = decodeURIComponent(salesMatch[3]);
61
+ if (!isResolvedSalesProfileParts(profileId, authType, authToken)) return null;
62
+ return { profileId, authType, authToken, entityUrn: `urn:li:fs_salesProfile:(${profileId},${authType},${authToken})` };
63
+ }
64
+ const profileMatch = url.pathname.match(/^\/in\/([^/]+)\/?$/i);
65
+ if (profileMatch) {
66
+ return { profileId: decodeURIComponent(profileMatch[1]), authType: '', authToken: '', entityUrn: '' };
67
+ }
68
+ } catch {
69
+ return null;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function encodeRestliDecoration(value) {
75
+ return encodeURIComponent(value).replace(/\(/g, '%28').replace(/\)/g, '%29');
76
+ }
77
+
78
+ function profileApiUrl(recipient) {
79
+ if (!recipient?.profileId || !recipient?.authType || !recipient?.authToken) return '';
80
+ const key = `(profileId:${recipient.profileId},authType:${recipient.authType},authToken:${recipient.authToken})`;
81
+ return `https://www.linkedin.com/sales-api/salesApiProfiles/${key}?decoration=${encodeRestliDecoration(PROFILE_DECO)}`;
82
+ }
83
+
84
+ function randomTrackingId() {
85
+ const bytes = new Uint8Array(8);
86
+ if (globalThis.crypto?.getRandomValues) globalThis.crypto.getRandomValues(bytes);
87
+ else for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
88
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
89
+ }
90
+
91
+ function buildCreateMessagePayload({ recipientUrn, subject, body, trackingId = randomTrackingId(), copyToCrm = false }) {
92
+ const cleanRecipient = normalizeWhitespace(recipientUrn);
93
+ if (!parseSalesProfileUrn(cleanRecipient)) throw new ArgumentError('--recipient must resolve to a Sales Navigator lead urn');
94
+ const cleanSubject = normalizeWhitespace(subject);
95
+ const cleanBody = String(body ?? '').trim();
96
+ if (!cleanSubject) throw new ArgumentError('--subject is required');
97
+ if (!cleanBody) throw new ArgumentError('--body is required');
98
+ if (cleanSubject.length > 200) throw new ArgumentError('--subject must be 200 characters or fewer');
99
+ if (cleanBody.length > 1900) throw new ArgumentError('--body must be 1900 characters or fewer');
100
+ return {
101
+ createMessageRequest: {
102
+ recipients: [cleanRecipient],
103
+ subject: cleanSubject,
104
+ body: cleanBody,
105
+ copyToCrm: Boolean(copyToCrm),
106
+ trackingId,
107
+ },
108
+ };
109
+ }
110
+
111
+ function extractRemainingCredits(json) {
112
+ const elements = Array.isArray(json?.elements) ? json.elements : [];
113
+ const inmailGrant = elements.find((el) => el?.type === 'LSS_INMAIL' && Number.isInteger(el.value));
114
+ if (inmailGrant) return inmailGrant.value;
115
+ const candidates = [];
116
+ const visit = (value) => {
117
+ if (value === null || value === undefined) return;
118
+ if (typeof value === 'number' && Number.isFinite(value)) candidates.push(value);
119
+ if (Array.isArray(value)) value.forEach(visit);
120
+ else if (typeof value === 'object') {
121
+ for (const [key, child] of Object.entries(value)) {
122
+ if (/remaining|available|balance|value/i.test(key) && typeof child === 'number') candidates.unshift(child);
123
+ else if (!/^count$|^start$|^id$/i.test(key)) visit(child);
124
+ }
125
+ }
126
+ };
127
+ visit(json);
128
+ return candidates.find((n) => Number.isInteger(n) && n >= 0) ?? null;
129
+ }
130
+
131
+ function fetchJsonScript(url, csrf, options = {}) {
132
+ return String.raw`(async () => {
133
+ const headers = {
134
+ 'csrf-token': ${JSON.stringify(csrf)},
135
+ 'x-restli-protocol-version': '2.0.0',
136
+ accept: ${JSON.stringify(options.accept || 'application/json')},
137
+ ...((${JSON.stringify(Boolean(options.body))}) ? { 'content-type': 'application/json' } : {}),
138
+ };
139
+ try {
140
+ const res = await fetch(${JSON.stringify(url)}, {
141
+ credentials: 'include',
142
+ method: ${JSON.stringify(options.method || 'GET')},
143
+ headers,
144
+ body: ${options.body ? JSON.stringify(JSON.stringify(options.body)) : 'undefined'},
145
+ });
146
+ const text = await res.text();
147
+ let json = null;
148
+ try { json = text ? JSON.parse(text) : null; } catch (_) { json = null; }
149
+ if (res.status === 401 || res.status === 403) return ['auth', res.status, json, text];
150
+ if (!res.ok) return ['error', res.status, json, text, 'HTTP ' + res.status];
151
+ return ['ok', res.status, json, text];
152
+ } catch (e) {
153
+ return ['error', 0, null, '', 'fetch failed: ' + ((e && e.message) || String(e))];
154
+ }
155
+ })()`;
156
+ }
157
+
158
+ function requireFetchResult(result, label, { requireJson = true } = {}) {
159
+ if (Array.isArray(result)) {
160
+ const [kind, status, json, text, error] = result;
161
+ result = {
162
+ authRequired: kind === 'auth',
163
+ error: kind === 'error' ? error || `HTTP ${status}` : '',
164
+ status,
165
+ json,
166
+ text,
167
+ };
168
+ }
169
+ if (result?.authRequired) throw new AuthRequiredError(LINKEDIN_DOMAIN, `${label} auth failed.`);
170
+ if (result?.error) throw new CommandExecutionError(`${label} failed`, result.error);
171
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
172
+ throw new CommandExecutionError(`${label} returned malformed response`);
173
+ }
174
+ if (requireJson && (!result.json || typeof result.json !== 'object' || Array.isArray(result.json))) {
175
+ throw new CommandExecutionError(`${label} returned malformed response`, 'missing_json');
176
+ }
177
+ return result;
178
+ }
179
+
180
+ function salesPageShowsSentMessage(text, recipientName) {
181
+ const normalizedText = normalizeWhitespace(text);
182
+ const firstName = normalizeWhitespace(recipientName).split(' ')[0];
183
+ return normalizedText.includes('You sent a Sales Navigator message')
184
+ && (!firstName || normalizedText.includes(firstName));
185
+ }
186
+
187
+ async function getCsrf(page) {
188
+ const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
189
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
190
+ if (!jsession) throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn.');
191
+ return jsession.replace(/^\"|\"$/g, '');
192
+ }
193
+
194
+ async function resolveRecipient(page, parsed, csrf) {
195
+ if (!parsed) throw new ArgumentError('--recipient must be a Sales Navigator lead URL, Sales Navigator profile URL, LinkedIn /in/ URL, or urn:li:fs_salesProfile:(...)');
196
+ if (parsed.entityUrn && parsed.authType && parsed.authToken) return parsed;
197
+
198
+ await page.goto(`https://www.linkedin.com/sales/lead/${encodeURIComponent(parsed.profileId)}`);
199
+ await page.wait(6);
200
+ const probe = unwrapEvaluateResult(await page.evaluate(String.raw`(() => {
201
+ const href = location.href;
202
+ const text = document.body ? document.body.innerText : '';
203
+ const resourceUrns = Array.from(performance.getEntriesByType('resource'))
204
+ .map((entry) => entry.name)
205
+ .filter((name) => name.includes('/sales-api/salesApiProfiles/'))
206
+ .slice(-20);
207
+ return { href, text: text.slice(0, 1000), resourceUrns };
208
+ })()`));
209
+ const urlMatch = String(probe?.href || '').match(/\/sales\/lead\/([^,/]+),([^,/]+),([^/?#]+)/i);
210
+ if (urlMatch && isResolvedSalesProfileParts(urlMatch[1], urlMatch[2], urlMatch[3])) {
211
+ return {
212
+ profileId: decodeURIComponent(urlMatch[1]),
213
+ authType: decodeURIComponent(urlMatch[2]),
214
+ authToken: decodeURIComponent(urlMatch[3]),
215
+ entityUrn: `urn:li:fs_salesProfile:(${decodeURIComponent(urlMatch[1])},${decodeURIComponent(urlMatch[2])},${decodeURIComponent(urlMatch[3])})`,
216
+ };
217
+ }
218
+ for (const resource of probe?.resourceUrns || []) {
219
+ const resourceMatch = String(resource).match(/profileId:([^,)]+),authType:([^,)]+),authToken:([^,)]+)\)/);
220
+ if (resourceMatch && resourceMatch[1] === parsed.profileId) {
221
+ return {
222
+ profileId: resourceMatch[1],
223
+ authType: resourceMatch[2],
224
+ authToken: resourceMatch[3],
225
+ entityUrn: `urn:li:fs_salesProfile:(${resourceMatch[1]},${resourceMatch[2]},${resourceMatch[3]})`,
226
+ };
227
+ }
228
+ }
229
+ void csrf;
230
+ throw new CommandExecutionError('Could not resolve Sales Navigator auth token for recipient', `Observed URL: ${probe?.href || 'url_not_available'}\nBody: ${normalizeWhitespace(probe?.text || '').slice(0, 500)}`);
231
+ }
232
+
233
+ function profileSummary(json) {
234
+ const data = json?.data || json || {};
235
+ const pos = data.defaultPosition || (Array.isArray(data.positions) ? data.positions.find((p) => p.current) || data.positions[0] : {}) || {};
236
+ return {
237
+ recipient: normalizeWhitespace(data.fullName || [data.firstName, data.lastName].filter(Boolean).join(' ')),
238
+ title: normalizeWhitespace(pos.title || data.headline || ''),
239
+ company: normalizeWhitespace(pos.companyName || pos.company?.name || ''),
240
+ degree: normalizeWhitespace(data.degree || ''),
241
+ inmail_restriction: normalizeWhitespace(data.inmailRestriction || ''),
242
+ open_link: Boolean(data.memberBadges?.openLink),
243
+ };
244
+ }
245
+
246
+ function requireProfileSummary(json) {
247
+ const summary = profileSummary(json);
248
+ if (!summary.recipient) {
249
+ throw new CommandExecutionError('Sales Navigator profile lookup returned malformed profile data', 'missing_recipient_name');
250
+ }
251
+ return summary;
252
+ }
253
+
254
+ cli({
255
+ site: 'linkedin',
256
+ name: 'salesnav-message',
257
+ access: 'write',
258
+ description: 'Send or dry-run a LinkedIn Sales Navigator InMail to a lead using the Sales Navigator messaging API',
259
+ domain: LINKEDIN_DOMAIN,
260
+ strategy: Strategy.UI,
261
+ browser: true,
262
+ args: [
263
+ { name: 'recipient', type: 'string', required: true, positional: true, help: 'Sales Navigator lead URL, LinkedIn /in/ URL from salesnav-search, or urn:li:fs_salesProfile:(...)' },
264
+ { name: 'subject', type: 'string', required: true, help: 'InMail subject' },
265
+ { name: 'body', type: 'string', required: true, help: 'InMail body' },
266
+ { name: 'send', type: 'bool', default: false, help: 'Actually send the InMail. Default is dry-run validation only.' },
267
+ { name: 'copy-to-crm', type: 'bool', default: false, help: 'Set Sales Navigator copyToCrm on the message request' },
268
+ ],
269
+ columns: ['status', 'recipient', 'title', 'company', 'credits_remaining', 'credits_before', 'credits_after', 'sent_in_salesnav', 'message_chars', 'subject_chars', 'recipient_urn', 'degree', 'inmail_restriction', 'open_link'],
270
+ func: async (page, args) => {
271
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin salesnav-message');
272
+ const recipientArg = requireStringArg(args, 'recipient', '--recipient');
273
+ const subject = requireStringArg(args, 'subject', '--subject');
274
+ const body = String(args.body ?? '').trim();
275
+ if (!body) throw new ArgumentError('--body is required');
276
+
277
+ await page.goto(SALES_HOME);
278
+ await page.wait(4);
279
+ const csrf = await getCsrf(page);
280
+ const recipient = await resolveRecipient(page, parseRecipient(recipientArg), csrf);
281
+
282
+ let summary = { recipient: '', title: '', company: '', degree: '', inmail_restriction: '', open_link: false };
283
+ const profileUrl = profileApiUrl(recipient);
284
+ if (profileUrl) {
285
+ const profileResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(profileUrl, csrf))), 'LinkedIn Sales Navigator profile API');
286
+ summary = requireProfileSummary(profileResult.json);
287
+ }
288
+ if (summary.inmail_restriction && summary.inmail_restriction !== 'NO_RESTRICTION') {
289
+ throw new CommandExecutionError('Sales Navigator InMail blocked by recipient restriction', summary.inmail_restriction);
290
+ }
291
+
292
+ const creditsResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(CREDITS_URL, csrf))), 'LinkedIn Sales Navigator credits API');
293
+ const creditsRemaining = extractRemainingCredits(creditsResult?.json);
294
+
295
+ const payload = buildCreateMessagePayload({ recipientUrn: recipient.entityUrn, subject, body, copyToCrm: args['copy-to-crm'] });
296
+ if (!args.send) {
297
+ return [{
298
+ status: 'validated_dry_run',
299
+ recipient: summary.recipient,
300
+ title: summary.title,
301
+ company: summary.company,
302
+ credits_remaining: creditsRemaining,
303
+ credits_before: creditsRemaining,
304
+ credits_after: '',
305
+ sent_in_salesnav: false,
306
+ message_chars: body.length,
307
+ subject_chars: subject.length,
308
+ recipient_urn: recipient.entityUrn,
309
+ degree: summary.degree,
310
+ inmail_restriction: summary.inmail_restriction,
311
+ open_link: summary.open_link,
312
+ }];
313
+ }
314
+
315
+ const sendResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(MESSAGE_ACTION_URL, csrf, {
316
+ method: 'POST',
317
+ accept: 'application/vnd.linkedin.normalized+json+2.1',
318
+ body: payload,
319
+ }))), 'LinkedIn Sales Navigator message API', { requireJson: false });
320
+ void sendResult;
321
+ await page.wait(3);
322
+ const creditsAfterResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(CREDITS_URL, csrf))), 'LinkedIn Sales Navigator credits API after send');
323
+ const creditsAfter = extractRemainingCredits(creditsAfterResult?.json);
324
+ await page.goto(salesLeadUrlFromParts(recipient));
325
+ await page.wait(6);
326
+ const salesPageText = unwrapEvaluateResult(await page.evaluate('document.body ? document.body.innerText : ""'));
327
+ const sentInSalesNav = salesPageShowsSentMessage(salesPageText, summary.recipient);
328
+ if (!sentInSalesNav) throw new CommandExecutionError('Sales Navigator post-send verification failed', 'Sent activity was not found on the Sales Navigator lead page.');
329
+ return [{
330
+ status: 'sent',
331
+ recipient: summary.recipient,
332
+ title: summary.title,
333
+ company: summary.company,
334
+ credits_remaining: creditsAfter,
335
+ credits_before: creditsRemaining,
336
+ credits_after: creditsAfter,
337
+ sent_in_salesnav: sentInSalesNav,
338
+ message_chars: body.length,
339
+ subject_chars: subject.length,
340
+ recipient_urn: recipient.entityUrn,
341
+ degree: summary.degree,
342
+ inmail_restriction: summary.inmail_restriction,
343
+ open_link: summary.open_link,
344
+ }];
345
+ },
346
+ });
347
+
348
+ export const __test__ = {
349
+ normalizeWhitespace,
350
+ parseSalesProfileUrn,
351
+ isResolvedSalesProfileParts,
352
+ parseRecipient,
353
+ salesLeadUrlFromParts,
354
+ profileApiUrl,
355
+ buildCreateMessagePayload,
356
+ extractRemainingCredits,
357
+ profileSummary,
358
+ requireProfileSummary,
359
+ salesPageShowsSentMessage,
360
+ };