@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,148 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import {
4
+ assertLinkedInAuthenticated,
5
+ assertSafeLinkedinUrl,
6
+ compactRepeatedText,
7
+ normalizeWhitespace,
8
+ unwrapEvaluateResult,
9
+ } from './shared.js';
10
+
11
+ function normalizeProfileReadUrl(value) {
12
+ const url = assertSafeLinkedinUrl(value || 'https://www.linkedin.com/in/me/', 'profile-url', '/in/me/');
13
+ const parsed = new URL(url);
14
+ if (!/^\/in\/[^/?#]+\/?$/.test(parsed.pathname)) {
15
+ throw new CommandExecutionError('LinkedIn profile-read requires a /in/<handle>/ profile URL');
16
+ }
17
+ return parsed.toString();
18
+ }
19
+
20
+ function buildProfileExtractionScript() {
21
+ return String.raw`(() => {
22
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
23
+ const compact = (s) => {
24
+ const text = clean(s);
25
+ if (!text) return '';
26
+ if (text.length % 2 === 0 && text.slice(0, text.length / 2) === text.slice(text.length / 2)) {
27
+ return text.slice(0, text.length / 2);
28
+ }
29
+ return text;
30
+ };
31
+ const readSection = (headingPattern) => {
32
+ const headings = Array.from(document.querySelectorAll('section h2, section h3, h2, h3'));
33
+ const heading = headings.find((el) => headingPattern.test(clean(el.innerText || el.textContent || '')));
34
+ const section = heading?.closest('section');
35
+ if (!section) return '';
36
+ const text = clean(section.innerText || section.textContent || '');
37
+ return clean(text.replace(headingPattern, '').replace(/\bShow all.*$/i, '').replace(/\bSee more.*$/i, ''));
38
+ };
39
+ const nameHeading = document.querySelector('main h1, main h2');
40
+ const intro = nameHeading?.closest('section') || document.querySelector('main section') || document.body;
41
+ const lines = (intro.innerText || intro.textContent || '').split(/\n+/).map(clean).filter(Boolean);
42
+ const name = compact(clean(nameHeading?.innerText || nameHeading?.textContent || lines[0] || ''));
43
+ const skipIntro = (line) => !line
44
+ || line === name
45
+ || /^(1st|2nd|3rd|contact info|message|more|follow|connect|open to|add section|enhance profile|resources|self employed)$/i.test(line)
46
+ || /^\d[\d,]*\s+(followers|connections)/i.test(line)
47
+ || line === '·';
48
+ const headline = compact(lines.find((line) => !skipIntro(line) && line.length > 20) || '');
49
+ const locationText = lines.find((line) => /(area|india|jaipur|bangalore|bengaluru|delhi|mumbai|hyderabad|pune)/i.test(line) && line.length < 120) || '';
50
+ const about = readSection(/^About$/i);
51
+ const experience = readSection(/^Experience$/i);
52
+ const education = readSection(/^Education$/i);
53
+ const featured = readSection(/^Featured$/i);
54
+ const services = readSection(/^Services$/i) || readSection(/^Providing services$/i);
55
+ return {
56
+ profile_url: window.location.href,
57
+ name,
58
+ headline,
59
+ location: locationText,
60
+ about,
61
+ experience,
62
+ education,
63
+ services,
64
+ featured,
65
+ };
66
+ })()`;
67
+ }
68
+
69
+ function buildAboutEditExtractionScript() {
70
+ return String.raw`(() => {
71
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
72
+ const dialog = document.querySelector('dialog') || document;
73
+ const editor = dialog.querySelector('[contenteditable="true"]');
74
+ const about = Array.from(editor?.querySelectorAll('p') || [])
75
+ .map((p) => clean(p.innerText || p.textContent || ''))
76
+ .join('\n')
77
+ .trim();
78
+ const about_skills = Array.from(dialog.querySelectorAll('[role="listitem"][aria-label]'))
79
+ .map((el) => clean(el.getAttribute('aria-label') || ''))
80
+ .filter(Boolean);
81
+ const about_character_count = Array.from(dialog.querySelectorAll('span, p'))
82
+ .map((el) => clean(el.innerText || el.textContent || ''))
83
+ .find((text) => /^\d[\d,]*\/2,600$/.test(text)) || '';
84
+ return { about, about_skills, about_character_count };
85
+ })()`;
86
+ }
87
+
88
+ function normalizeProfile(row) {
89
+ if (!row || typeof row !== 'object') {
90
+ throw new CommandExecutionError('LinkedIn profile-read returned malformed extraction payload');
91
+ }
92
+ const name = compactRepeatedText(row.name);
93
+ if (!name) throw new CommandExecutionError('LinkedIn profile-read could not find a profile name');
94
+ return {
95
+ profile_url: normalizeWhitespace(row.profile_url),
96
+ name,
97
+ headline: compactRepeatedText(row.headline),
98
+ location: normalizeWhitespace(row.location),
99
+ about: normalizeWhitespace(row.about),
100
+ about_character_count: normalizeWhitespace(row.about_character_count),
101
+ about_skills: Array.isArray(row.about_skills) ? row.about_skills.map(normalizeWhitespace).filter(Boolean).join('; ') : '',
102
+ experience: normalizeWhitespace(row.experience),
103
+ education: normalizeWhitespace(row.education),
104
+ services: normalizeWhitespace(row.services),
105
+ featured: normalizeWhitespace(row.featured),
106
+ };
107
+ }
108
+
109
+ cli({
110
+ site: 'linkedin',
111
+ name: 'profile-read',
112
+ access: 'read',
113
+ description: 'Read visible LinkedIn profile sections: headline, About, experience, education, services, and featured sections',
114
+ domain: 'www.linkedin.com',
115
+ strategy: Strategy.COOKIE,
116
+ browser: true,
117
+ args: [
118
+ { name: 'profile-url', type: 'string', required: false, help: 'LinkedIn /in/<handle>/ profile URL. Defaults to /in/me/.' },
119
+ ],
120
+ columns: ['profile_url', 'name', 'headline', 'location', 'about', 'about_character_count', 'about_skills', 'experience', 'education', 'services', 'featured'],
121
+ func: async (page, args) => {
122
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin profile-read');
123
+ const profileUrl = normalizeProfileReadUrl(args['profile-url']);
124
+ const shouldReadEditor = !normalizeWhitespace(args['profile-url']);
125
+ await page.goto(profileUrl);
126
+ await page.wait(5);
127
+ await assertLinkedInAuthenticated(page, 'LinkedIn profile-read');
128
+ await page.autoScroll({ times: 4, delayMs: 700 });
129
+ await page.wait(1);
130
+ const row = unwrapEvaluateResult(await page.evaluate(buildProfileExtractionScript()));
131
+ let aboutEdit = {};
132
+ if (shouldReadEditor) {
133
+ const currentProfileUrl = normalizeWhitespace(row?.profile_url) || profileUrl;
134
+ const profilePath = new URL(currentProfileUrl).pathname.replace(/\/?$/, '/');
135
+ const aboutEditUrl = new URL(`${profilePath}edit/forms/summary/new/`, 'https://www.linkedin.com').toString();
136
+ await page.goto(aboutEditUrl);
137
+ await page.wait(4);
138
+ await assertLinkedInAuthenticated(page, 'LinkedIn profile-read about editor');
139
+ aboutEdit = unwrapEvaluateResult(await page.evaluate(buildAboutEditExtractionScript()));
140
+ }
141
+ return [normalizeProfile({ ...row, ...aboutEdit })];
142
+ },
143
+ });
144
+
145
+ export const __test__ = {
146
+ normalizeProfileReadUrl,
147
+ normalizeProfile,
148
+ };
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './profile-read.js';
5
+
6
+ const { normalizeProfileReadUrl, normalizeProfile } = await import('./profile-read.js').then((m) => m.__test__);
7
+
8
+ describe('linkedin profile-read adapter', () => {
9
+ const command = getRegistry().get('linkedin/profile-read');
10
+
11
+ it('registers command shape', () => {
12
+ expect(command).toBeDefined();
13
+ expect(command.strategy).toBe('cookie');
14
+ expect(command.browser).toBe(true);
15
+ expect(command.columns).toEqual([
16
+ 'profile_url',
17
+ 'name',
18
+ 'headline',
19
+ 'location',
20
+ 'about',
21
+ 'about_character_count',
22
+ 'about_skills',
23
+ 'experience',
24
+ 'education',
25
+ 'services',
26
+ 'featured',
27
+ ]);
28
+ });
29
+
30
+ it('normalizes profile url default and explicit /in URL', () => {
31
+ expect(normalizeProfileReadUrl(undefined)).toBe('https://www.linkedin.com/in/me/');
32
+ expect(normalizeProfileReadUrl('https://www.linkedin.com/in/gauravsaxena1997?x=1')).toBe('https://www.linkedin.com/in/gauravsaxena1997?x=1');
33
+ });
34
+
35
+ it('rejects non-profile URLs', () => {
36
+ expect(() => normalizeProfileReadUrl('https://www.linkedin.com/jobs/')).toThrow(CommandExecutionError);
37
+ });
38
+
39
+ it('normalizes duplicated profile text and requires a name', () => {
40
+ expect(() => normalizeProfile({ name: '' })).toThrow(CommandExecutionError);
41
+ expect(normalizeProfile({
42
+ name: 'AliceAlice',
43
+ headline: 'EngineerEngineer',
44
+ about: ' Builds AI ',
45
+ about_character_count: '1,100/2,600',
46
+ about_skills: ['AI', 'TypeScript'],
47
+ })).toMatchObject({
48
+ name: 'Alice',
49
+ headline: 'Engineer',
50
+ about: 'Builds AI',
51
+ about_character_count: '1,100/2,600',
52
+ about_skills: 'AI; TypeScript',
53
+ });
54
+ });
55
+
56
+ it('does not require edit access when reading an explicit profile URL', async () => {
57
+ const page = {
58
+ goto: vi.fn(async () => {}),
59
+ wait: vi.fn(async () => {}),
60
+ autoScroll: vi.fn(async () => {}),
61
+ evaluate: vi.fn()
62
+ .mockResolvedValueOnce(false)
63
+ .mockResolvedValueOnce({
64
+ profile_url: 'https://www.linkedin.com/in/alice/',
65
+ name: 'Alice',
66
+ headline: 'Engineer',
67
+ about: 'Builds products',
68
+ }),
69
+ };
70
+
71
+ await expect(command.func(page, { 'profile-url': 'https://www.linkedin.com/in/alice/' }))
72
+ .resolves.toMatchObject([{ name: 'Alice', about: 'Builds products' }]);
73
+ expect(page.goto).toHaveBeenCalledTimes(1);
74
+ expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/in/alice/');
75
+ expect(page.goto.mock.calls.some(([url]) => String(url).includes('/edit/forms/'))).toBe(false);
76
+ });
77
+ });
@@ -0,0 +1,357 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { createHash } from 'node:crypto';
4
+
5
+ const LINKEDIN_DOMAIN = 'www.linkedin.com';
6
+
7
+ function normalizeWhitespace(value) {
8
+ return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
9
+ }
10
+
11
+ function unwrapEvaluateResult(payload) {
12
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
13
+ return payload;
14
+ }
15
+
16
+ function normalizeName(value) {
17
+ return normalizeWhitespace(value)
18
+ .replace(/\s*[•·]\s*(?:1st|2nd|3rd\+?|degree connection).*$/i, '')
19
+ .replace(/\s+LinkedIn.*$/i, '')
20
+ .toLowerCase();
21
+ }
22
+
23
+ function isLinkedInHost(hostname) {
24
+ const host = String(hostname || '').toLowerCase();
25
+ return host === 'linkedin.com' || host.endsWith('.linkedin.com');
26
+ }
27
+
28
+ function canonicalizeLinkedInThreadUrl(value) {
29
+ const raw = normalizeWhitespace(value);
30
+ if (!raw) return '';
31
+ try {
32
+ const url = new URL(raw);
33
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return '';
34
+ const match = url.pathname.match(/^\/messaging\/thread\/([^/]+)\/?$/i);
35
+ if (!match || !match[1]) return '';
36
+ url.hostname = 'www.linkedin.com';
37
+ url.hash = '';
38
+ url.search = '';
39
+ if (!url.pathname.endsWith('/')) url.pathname += '/';
40
+ return url.toString();
41
+ } catch {
42
+ return '';
43
+ }
44
+ }
45
+
46
+ function hashText(value) {
47
+ return createHash('sha256').update(normalizeWhitespace(value)).digest('hex');
48
+ }
49
+
50
+ function textContainsNormalized(haystack, needle) {
51
+ const h = normalizeWhitespace(haystack).toLowerCase();
52
+ const n = normalizeWhitespace(needle).toLowerCase();
53
+ return !n || h.includes(n);
54
+ }
55
+
56
+ function selectBestHeaderName(headerNames, expectedName) {
57
+ const expected = normalizeName(expectedName);
58
+ const names = (Array.isArray(headerNames) ? headerNames : [])
59
+ .map(normalizeWhitespace)
60
+ .filter(Boolean);
61
+ return names.find((name) => normalizeName(name) === expected) || names[0] || '';
62
+ }
63
+
64
+ function assessThreadSafety(probe, expected) {
65
+ const expectedName = normalizeWhitespace(expected.expectedName);
66
+ const actualName = selectBestHeaderName(probe?.headerNames, expectedName);
67
+ const expectedThreadUrl = canonicalizeLinkedInThreadUrl(expected.threadUrl);
68
+ const actualThreadUrl = canonicalizeLinkedInThreadUrl(probe?.url || '');
69
+ const bodyText = String(probe?.bodyText || '');
70
+
71
+ if (probe?.authRequired) {
72
+ return { ok: false, blockReason: 'auth_required', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
73
+ }
74
+
75
+ if (probe?.searchFailure || /we didn't find anything|no results found|no results for/i.test(bodyText)) {
76
+ return { ok: false, blockReason: 'search_failure_visible', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
77
+ }
78
+
79
+ if (expectedThreadUrl && actualThreadUrl && expectedThreadUrl !== actualThreadUrl) {
80
+ return { ok: false, blockReason: 'thread_url_mismatch', expectedValue: expectedThreadUrl, actualValue: actualThreadUrl, observedUrl: actualThreadUrl };
81
+ }
82
+
83
+ if (!actualName || normalizeName(actualName) !== normalizeName(expectedName)) {
84
+ return { ok: false, blockReason: 'recipient_header_mismatch', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
85
+ }
86
+
87
+ if (!probe?.composerFound) {
88
+ return { ok: false, blockReason: 'composer_not_found', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
89
+ }
90
+
91
+ const expectedLastHash = normalizeWhitespace(expected.expectedLastHash);
92
+ if (expectedLastHash && expectedLastHash !== probe?.latestMessageHash) {
93
+ return { ok: false, blockReason: 'latest_message_mismatch', expectedValue: expectedLastHash, actualValue: probe?.latestMessageHash || '', observedUrl: actualThreadUrl };
94
+ }
95
+
96
+ const expectedLastText = normalizeWhitespace(expected.expectedLastText);
97
+ if (expectedLastText && !textContainsNormalized(bodyText, expectedLastText)) {
98
+ return { ok: false, blockReason: 'latest_message_mismatch', expectedValue: expectedLastText, actualValue: '', observedUrl: actualThreadUrl };
99
+ }
100
+
101
+ return { ok: true, blockReason: 'verified', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
102
+ }
103
+
104
+ function requireStringArg(args, key, label = key) {
105
+ const value = normalizeWhitespace(args[key]);
106
+ if (!value) throw new ArgumentError(`${label} is required`);
107
+ return value;
108
+ }
109
+
110
+ function requireLinkedInThreadUrl(value, label) {
111
+ const url = canonicalizeLinkedInThreadUrl(value);
112
+ if (!url) throw new ArgumentError(`${label} must be an exact https://www.linkedin.com/messaging/thread/<id>/ URL`);
113
+ return url;
114
+ }
115
+
116
+ function buildThreadProbeScript() {
117
+ return String.raw`(() => {
118
+ const marker = '__OPENCLI_LINKEDIN_PROBE__';
119
+ void marker;
120
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
121
+ const text = document.body ? (document.body.innerText || '') : '';
122
+ const lower = text.toLowerCase();
123
+ const authRequired = /\b(sign in|log in|join linkedin)\b/i.test(text)
124
+ || /linkedin\.com\/(login|checkpoint|authwall)/i.test(location.href)
125
+ || /captcha|verification required/i.test(text);
126
+ const searchFailure = /we didn't find anything|no results found|no results for/i.test(text);
127
+
128
+ const headerCandidates = [];
129
+ const selectors = [
130
+ '.msg-thread__link-to-profile',
131
+ '.msg-thread__link-to-profile span[aria-hidden="true"]',
132
+ '.msg-entity-lockup__entity-title',
133
+ '.msg-conversation-card__participant-names',
134
+ 'main h1',
135
+ 'main h2',
136
+ '[data-anonymize="person-name"]',
137
+ 'a[href*="/in/"] span[aria-hidden="true"]',
138
+ 'a[href*="/in/"]'
139
+ ];
140
+ for (const selector of selectors) {
141
+ for (const el of Array.from(document.querySelectorAll(selector)).slice(0, 8)) {
142
+ const value = clean(el.innerText || el.textContent || el.getAttribute('aria-label'));
143
+ if (value && value.length <= 120 && !/^(message|messaging|send|profile|view profile)$/i.test(value)) {
144
+ headerCandidates.push(value);
145
+ }
146
+ }
147
+ }
148
+
149
+ const composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
150
+ .find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
151
+
152
+ const messageText = Array.from(document.querySelectorAll('.msg-s-message-list__event, .msg-s-event-listitem, [data-event-urn], .msg-s-message-group__meta, .msg-s-message-list-content'))
153
+ .map((el) => clean(el.innerText || el.textContent))
154
+ .filter(Boolean)
155
+ .join('\n');
156
+ const sourceText = messageText || text;
157
+ const sourceLines = sourceText.split(/\n+/).map(clean).filter(Boolean);
158
+ const lastMeaningfulLine = [...sourceLines].reverse().find((line) => !/^(send|reply|write a message|press enter to send)$/i.test(line)) || '';
159
+
160
+ return {
161
+ url: location.href,
162
+ title: document.title || '',
163
+ headerNames: Array.from(new Set(headerCandidates)).slice(0, 10),
164
+ bodyText: text,
165
+ composerFound: Boolean(composer),
166
+ composerText: composer ? clean(composer.innerText || composer.textContent) : '',
167
+ authRequired,
168
+ searchFailure,
169
+ latestMessageText: lastMeaningfulLine,
170
+ latestMessageHash: '',
171
+ };
172
+ })()`;
173
+ }
174
+
175
+ function buildFocusComposerScript() {
176
+ return String.raw`(() => {
177
+ const marker = '__OPENCLI_LINKEDIN_FOCUS_COMPOSER__';
178
+ void marker;
179
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
180
+ const composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
181
+ .find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
182
+ if (!composer) return { ok: false, error: 'composer_not_found', composerText: '' };
183
+ composer.focus();
184
+ composer.innerHTML = '';
185
+ composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
186
+ return { ok: true, composerText: clean(composer.innerText || composer.textContent) };
187
+ })()`;
188
+ }
189
+
190
+ function buildReadComposerScript() {
191
+ return String.raw`(() => {
192
+ const marker = '__OPENCLI_LINKEDIN_READ_COMPOSER__';
193
+ void marker;
194
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
195
+ const composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
196
+ .find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
197
+ return { ok: Boolean(composer), composerText: composer ? clean(composer.innerText || composer.textContent) : '' };
198
+ })()`;
199
+ }
200
+
201
+ function buildClickSendScript() {
202
+ return String.raw`(() => {
203
+ const marker = '__OPENCLI_LINKEDIN_CLICK_SEND__';
204
+ void marker;
205
+ const buttons = Array.from(document.querySelectorAll('button'));
206
+ const send = buttons.find((button) => {
207
+ const text = (button.innerText || button.textContent || button.getAttribute('aria-label') || '').trim().toLowerCase();
208
+ return text === 'send' || text === 'send message';
209
+ });
210
+ if (!send) return { ok: false, error: 'send_button_not_found', sent: false };
211
+ if (send.disabled || send.getAttribute('aria-disabled') === 'true') return { ok: false, error: 'send_button_disabled', sent: false };
212
+ send.click();
213
+ return { ok: true, sent: true };
214
+ })()`;
215
+ }
216
+
217
+ async function probeThread(page) {
218
+ const result = unwrapEvaluateResult(await page.evaluate(buildThreadProbeScript()));
219
+ const latestText = normalizeWhitespace(result?.latestMessageText || '');
220
+ return {
221
+ ...(result || {}),
222
+ latestMessageText: latestText,
223
+ latestMessageHash: latestText ? hashText(latestText) : '',
224
+ };
225
+ }
226
+
227
+ cli({
228
+ site: 'linkedin',
229
+ name: 'safe-send',
230
+ access: 'write',
231
+ description: 'Fail-closed LinkedIn message sender that verifies exact thread, recipient, and latest message before filling/sending',
232
+ domain: LINKEDIN_DOMAIN,
233
+ strategy: Strategy.UI,
234
+ browser: true,
235
+ args: [
236
+ { name: 'thread-url', required: true, help: 'Exact LinkedIn messaging thread URL to open and verify' },
237
+ { name: 'expected-name', required: true, help: 'Expected visible recipient name in the active thread header' },
238
+ { name: 'message', required: true, help: 'Message body to send or dry-run' },
239
+ { name: 'expected-last-text', help: 'Substring expected in the currently visible latest conversation context' },
240
+ { name: 'expected-last-hash', help: 'SHA-256 hash of expected latest visible message text' },
241
+ { name: 'send', type: 'bool', default: false, help: 'Actually click Send. Default is dry-run verification only.' },
242
+ { name: 'screenshot', type: 'bool', default: false, help: 'Capture a screenshot during verification' },
243
+ ],
244
+ columns: ['status', 'recipient', 'reason', 'thread_url', 'message_chars', 'screenshot'],
245
+ func: async (page, args) => {
246
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin safe-send');
247
+
248
+ const threadUrl = requireLinkedInThreadUrl(requireStringArg(args, 'thread-url', '--thread-url'), '--thread-url');
249
+ const expectedName = requireStringArg(args, 'expected-name', '--expected-name');
250
+ const message = requireStringArg(args, 'message', '--message');
251
+
252
+ await page.goto('https://www.linkedin.com/messaging/');
253
+ await page.wait(4);
254
+ await page.goto(threadUrl);
255
+ // LinkedIn messaging often renders the shell first and hydrates the active
256
+ // thread header/messages a few seconds later. Wait long enough for the
257
+ // recipient header to appear so we fail closed on a real mismatch, not on
258
+ // a premature blank DOM snapshot.
259
+ await page.wait(12);
260
+
261
+ let beforeProbe = await probeThread(page);
262
+ const expectedLastText = normalizeWhitespace(args['expected-last-text']);
263
+ for (let attempt = 0; expectedLastText && attempt < 6 && !textContainsNormalized(beforeProbe.bodyText, expectedLastText); attempt += 1) {
264
+ await page.wait(2);
265
+ beforeProbe = await probeThread(page);
266
+ }
267
+
268
+ const safety = assessThreadSafety(beforeProbe, {
269
+ expectedName,
270
+ threadUrl,
271
+ expectedLastText: args['expected-last-text'],
272
+ expectedLastHash: args['expected-last-hash'],
273
+ });
274
+
275
+ if (safety.blockReason === 'auth_required') {
276
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn safe-send requires an active signed-in LinkedIn browser session.');
277
+ }
278
+
279
+ if (!safety.ok) {
280
+ const observed = [
281
+ `Expected ${safety.expectedValue}; actual ${safety.actualValue || 'not_visible'} at ${safety.observedUrl || 'url_not_available'}`,
282
+ `Observed headers: ${(beforeProbe.headerNames || []).join(' | ') || 'no_visible_headers'}`,
283
+ `Title: ${beforeProbe.title || 'title_not_available'}`,
284
+ `Body: ${normalizeWhitespace(beforeProbe.bodyText || '').slice(0, 500)}`,
285
+ ].join('\n');
286
+ throw new CommandExecutionError(
287
+ `LinkedIn safe-send blocked: ${safety.blockReason}`,
288
+ observed,
289
+ );
290
+ }
291
+
292
+ let screenshot = '';
293
+ if (args.screenshot && typeof page.screenshot === 'function') {
294
+ screenshot = await page.screenshot({ fullPage: false });
295
+ }
296
+
297
+ if (!args.send) {
298
+ return [{
299
+ status: 'verified_dry_run',
300
+ recipient: safety.actualValue,
301
+ reason: safety.blockReason,
302
+ thread_url: safety.observedUrl,
303
+ message_chars: message.length,
304
+ screenshot: screenshot ? 'captured' : '',
305
+ }];
306
+ }
307
+
308
+ const focus = unwrapEvaluateResult(await page.evaluate(buildFocusComposerScript()));
309
+ if (!focus?.ok) throw new CommandExecutionError(`LinkedIn safe-send blocked: ${focus?.error || 'composer_focus_failed'}`);
310
+
311
+ await page.insertText(message);
312
+ await page.wait(0.6 + Math.random() * 0.8);
313
+
314
+ const composer = unwrapEvaluateResult(await page.evaluate(buildReadComposerScript()));
315
+ if (!composer?.ok || normalizeWhitespace(composer.composerText) !== normalizeWhitespace(message)) {
316
+ throw new CommandExecutionError(
317
+ 'LinkedIn safe-send blocked: composer_text_mismatch',
318
+ `Composer text did not exactly match intended message for ${expectedName}.`,
319
+ );
320
+ }
321
+
322
+ const afterFillProbe = await probeThread(page);
323
+ const afterFillSafety = assessThreadSafety(afterFillProbe, {
324
+ expectedName,
325
+ threadUrl,
326
+ expectedLastText: args['expected-last-text'],
327
+ expectedLastHash: args['expected-last-hash'],
328
+ });
329
+ if (!afterFillSafety.ok) {
330
+ throw new CommandExecutionError(`LinkedIn safe-send blocked after fill: ${afterFillSafety.blockReason}`);
331
+ }
332
+
333
+ const sent = unwrapEvaluateResult(await page.evaluate(buildClickSendScript()));
334
+ if (!sent?.ok || !sent.sent) {
335
+ throw new CommandExecutionError(`LinkedIn safe-send blocked: ${sent?.error || 'send_click_failed'}`);
336
+ }
337
+
338
+ await page.wait(0.8 + Math.random() * 1.2);
339
+ return [{
340
+ status: 'sent',
341
+ recipient: safety.actualValue,
342
+ reason: safety.blockReason,
343
+ thread_url: safety.observedUrl,
344
+ message_chars: message.length,
345
+ screenshot: screenshot ? 'captured' : '',
346
+ }];
347
+ },
348
+ });
349
+
350
+ export const __test__ = {
351
+ normalizeWhitespace,
352
+ unwrapEvaluateResult,
353
+ normalizeName,
354
+ canonicalizeLinkedInThreadUrl,
355
+ hashText,
356
+ assessThreadSafety,
357
+ };