@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,214 @@
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
+
6
+ function normalizeWhitespace(value) {
7
+ return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
8
+ }
9
+
10
+ function unwrapEvaluateResult(payload) {
11
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
12
+ return payload;
13
+ }
14
+
15
+ function isLinkedInHost(hostname) {
16
+ const host = String(hostname || '').toLowerCase();
17
+ return host === 'linkedin.com' || host.endsWith('.linkedin.com');
18
+ }
19
+
20
+ function canonicalizeLinkedInThreadUrl(value) {
21
+ const raw = normalizeWhitespace(value);
22
+ if (!raw) return '';
23
+ try {
24
+ const url = new URL(raw);
25
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return '';
26
+ const match = url.pathname.match(/^\/messaging\/thread\/([^/]+)\/?$/i);
27
+ if (!match || !match[1]) return '';
28
+ url.hostname = 'www.linkedin.com';
29
+ url.hash = '';
30
+ url.search = '';
31
+ if (!url.pathname.endsWith('/')) url.pathname += '/';
32
+ return url.toString();
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ function requireStringArg(args, key, label = key) {
39
+ const value = normalizeWhitespace(args[key]);
40
+ if (!value) throw new ArgumentError(`${label} is required`);
41
+ return value;
42
+ }
43
+
44
+ function requireLinkedInThreadUrl(value, label) {
45
+ const url = canonicalizeLinkedInThreadUrl(value);
46
+ if (!url) throw new ArgumentError(`${label} must be an exact https://www.linkedin.com/messaging/thread/<id>/ URL`);
47
+ return url;
48
+ }
49
+
50
+ function parseMaxScrolls(value) {
51
+ if (value === undefined || value === null || value === '') return 30;
52
+ const scrolls = Number(value);
53
+ if (!Number.isInteger(scrolls) || scrolls < 0 || scrolls > 80) {
54
+ throw new ArgumentError('--max-scrolls must be an integer between 0 and 80');
55
+ }
56
+ return scrolls;
57
+ }
58
+
59
+ function buildThreadSnapshotScript(maxScrolls) {
60
+ const scrolls = maxScrolls;
61
+ return String.raw`(async () => {
62
+ const marker = '__OPENCLI_LINKEDIN_THREAD_SNAPSHOT__';
63
+ void marker;
64
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
65
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
66
+ const text = document.body ? (document.body.innerText || '') : '';
67
+ const authRequired = /\b(sign in|log in|join linkedin)\b/i.test(text)
68
+ || /linkedin\.com\/(login|checkpoint|authwall)/i.test(location.href)
69
+ || /captcha|verification required/i.test(text);
70
+
71
+ const selectors = [
72
+ '.msg-s-message-list',
73
+ '.msg-s-message-list-scrollable',
74
+ '.msg-thread',
75
+ 'main [role="main"]',
76
+ 'main'
77
+ ];
78
+ let scroller = null;
79
+ for (const selector of selectors) {
80
+ const el = document.querySelector(selector);
81
+ if (el && (el.scrollHeight > el.clientHeight || selector === 'main')) { scroller = el; break; }
82
+ }
83
+ scroller = scroller || document.scrollingElement || document.documentElement;
84
+ let previousHeight = -1;
85
+ let stable = 0;
86
+ for (let i = 0; i < ${scrolls}; i += 1) {
87
+ scroller.scrollTop = 0;
88
+ window.scrollTo(0, 0);
89
+ await sleep(750);
90
+ const height = scroller.scrollHeight || document.body.scrollHeight || 0;
91
+ if (height === previousHeight) stable += 1; else stable = 0;
92
+ previousHeight = height;
93
+ if (stable >= 3) break;
94
+ }
95
+ await sleep(1000);
96
+
97
+ const headerCandidates = [];
98
+ const headerSelectors = [
99
+ '.msg-thread__link-to-profile',
100
+ '.msg-thread__link-to-profile span[aria-hidden="true"]',
101
+ '.msg-entity-lockup__entity-title',
102
+ '.msg-conversation-card__participant-names',
103
+ 'main h1',
104
+ 'main h2',
105
+ '[data-anonymize="person-name"]',
106
+ 'a[href*="/in/"] span[aria-hidden="true"]',
107
+ 'a[href*="/in/"]'
108
+ ];
109
+ for (const selector of headerSelectors) {
110
+ for (const el of Array.from(document.querySelectorAll(selector)).slice(0, 8)) {
111
+ const value = clean(el.innerText || el.textContent || el.getAttribute('aria-label'));
112
+ if (value && value.length <= 120 && !/^(message|messaging|send|profile|view profile)$/i.test(value)) {
113
+ headerCandidates.push(value);
114
+ }
115
+ }
116
+ }
117
+
118
+ const seen = new Set();
119
+ const messages = [];
120
+ const nodes = Array.from(document.querySelectorAll('.msg-s-message-list__event, .msg-s-event-listitem, [data-event-urn], .msg-s-message-list-content'));
121
+ for (const [nodeIndex, el] of nodes.entries()) {
122
+ const raw = clean(el.innerText || el.textContent);
123
+ if (!raw || seen.has(raw)) continue;
124
+ seen.add(raw);
125
+ const lines = raw.split(/\n+/).map(clean).filter(Boolean);
126
+ const speaker = lines.length > 1 && lines[0].length <= 120 ? lines[0] : '';
127
+ messages.push({ index: messages.length, nodeIndex, speaker, text: raw });
128
+ }
129
+
130
+ const refreshedText = document.body ? (document.body.innerText || '') : '';
131
+ const fallbackLines = refreshedText.split(/\n+/).map(clean).filter(Boolean);
132
+ const latestMessageText = messages.length
133
+ ? messages[messages.length - 1].text
134
+ : ([...fallbackLines].reverse().find((line) => !/^(send|reply|write a message|press enter to send)$/i.test(line)) || '');
135
+
136
+ return {
137
+ url: location.href,
138
+ title: document.title || '',
139
+ headerNames: Array.from(new Set(headerCandidates)).slice(0, 10),
140
+ bodyText: refreshedText,
141
+ latestMessageText,
142
+ messages,
143
+ messageCount: messages.length,
144
+ authRequired,
145
+ extractedAt: new Date().toISOString(),
146
+ maxScrolls: ${scrolls}
147
+ };
148
+ })()`;
149
+ }
150
+
151
+ cli({
152
+ site: 'linkedin',
153
+ name: 'thread-snapshot',
154
+ access: 'read',
155
+ description: 'Load a LinkedIn messaging thread, scroll for available history, and return a full context snapshot',
156
+ domain: LINKEDIN_DOMAIN,
157
+ strategy: Strategy.UI,
158
+ browser: true,
159
+ args: [
160
+ { name: 'thread-url', required: true, help: 'Exact LinkedIn messaging thread URL to open and snapshot' },
161
+ { name: 'max-scrolls', type: 'number', default: 30, help: 'Maximum upward scroll attempts to load older messages' },
162
+ { name: 'json', type: 'bool', default: false, help: 'Return only JSON snapshot string in the snapshot_json field' },
163
+ ],
164
+ columns: ['thread_url', 'recipient', 'message_count', 'latest_text', 'snapshot_json'],
165
+ func: async (page, args) => {
166
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin thread-snapshot');
167
+ const threadUrl = requireLinkedInThreadUrl(requireStringArg(args, 'thread-url', '--thread-url'), '--thread-url');
168
+ const maxScrolls = parseMaxScrolls(args['max-scrolls']);
169
+
170
+ await page.goto('https://www.linkedin.com/messaging/');
171
+ await page.wait(4);
172
+ await page.goto(threadUrl);
173
+ await page.wait(10);
174
+
175
+ const snapshot = unwrapEvaluateResult(await page.evaluate(buildThreadSnapshotScript(maxScrolls)));
176
+ if (snapshot?.authRequired) {
177
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn thread-snapshot requires an active signed-in LinkedIn browser session.');
178
+ }
179
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot) || !Array.isArray(snapshot.headerNames) || !Array.isArray(snapshot.messages)) {
180
+ throw new CommandExecutionError('LinkedIn thread-snapshot returned malformed snapshot payload');
181
+ }
182
+
183
+ const actualUrl = canonicalizeLinkedInThreadUrl(snapshot?.url || '');
184
+ if (threadUrl && actualUrl && threadUrl !== actualUrl) {
185
+ throw new CommandExecutionError('LinkedIn thread-snapshot blocked: thread_url_mismatch', `Expected ${threadUrl}; actual ${actualUrl}`);
186
+ }
187
+
188
+ const recipient = normalizeWhitespace(snapshot.headerNames[0] || '');
189
+ const messageCount = snapshot.messages.length;
190
+ const normalized = {
191
+ ...snapshot,
192
+ url: actualUrl || threadUrl,
193
+ headerNames: snapshot.headerNames,
194
+ latestMessageText: normalizeWhitespace(snapshot?.latestMessageText || ''),
195
+ messages: snapshot.messages,
196
+ };
197
+
198
+ return [{
199
+ thread_url: normalized.url,
200
+ recipient,
201
+ message_count: messageCount,
202
+ latest_text: normalized.latestMessageText,
203
+ snapshot_json: JSON.stringify(normalized),
204
+ }];
205
+ },
206
+ });
207
+
208
+ export const __test__ = {
209
+ normalizeWhitespace,
210
+ canonicalizeLinkedInThreadUrl,
211
+ parseMaxScrolls,
212
+ unwrapEvaluateResult,
213
+ buildThreadSnapshotScript,
214
+ };
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './thread-snapshot.js';
5
+
6
+ const { canonicalizeLinkedInThreadUrl, parseMaxScrolls } = await import('./thread-snapshot.js').then((m) => m.__test__);
7
+
8
+ function makeFakePage(snapshot) {
9
+ return {
10
+ goto: vi.fn(async () => undefined),
11
+ wait: vi.fn(async () => undefined),
12
+ evaluate: vi.fn(async () => snapshot),
13
+ };
14
+ }
15
+
16
+ describe('linkedin thread-snapshot command', () => {
17
+ it('accepts only exact LinkedIn messaging thread URLs', () => {
18
+ expect(canonicalizeLinkedInThreadUrl('https://www.linkedin.com/messaging/thread/2-abc==/?mini=true#x'))
19
+ .toBe('https://www.linkedin.com/messaging/thread/2-abc==/');
20
+ expect(canonicalizeLinkedInThreadUrl('https://www.linkedin.com/messaging/thread/2-abc==/extra')).toBe('');
21
+ expect(canonicalizeLinkedInThreadUrl('https://evil-linkedin.com/messaging/thread/2-abc==/')).toBe('');
22
+ expect(canonicalizeLinkedInThreadUrl('http://www.linkedin.com/messaging/thread/2-abc==/')).toBe('');
23
+ });
24
+
25
+ it('validates max-scrolls without silent clamping', () => {
26
+ expect(parseMaxScrolls(undefined)).toBe(30);
27
+ expect(parseMaxScrolls(0)).toBe(0);
28
+ expect(parseMaxScrolls(80)).toBe(80);
29
+ expect(() => parseMaxScrolls(81)).toThrow('--max-scrolls must be an integer between 0 and 80');
30
+ expect(() => parseMaxScrolls(1.5)).toThrow('--max-scrolls must be an integer between 0 and 80');
31
+ });
32
+
33
+ it('registers as a read command for loading full thread context', () => {
34
+ const command = getRegistry().get('linkedin/thread-snapshot');
35
+ expect(command).toBeDefined();
36
+ expect(command.access).toBe('read');
37
+ expect(command.columns).toEqual(expect.arrayContaining(['thread_url', 'recipient', 'message_count', 'latest_text']));
38
+ });
39
+
40
+ it('opens messaging first, then exact thread, and returns extracted messages', async () => {
41
+ const command = getRegistry().get('linkedin/thread-snapshot');
42
+ const page = makeFakePage({
43
+ url: 'https://www.linkedin.com/messaging/thread/abc/',
44
+ headerNames: ['Neha Rudraraju'],
45
+ latestMessageText: 'safe-send test from hermes. pls ignore :)',
46
+ messages: [
47
+ { index: 0, speaker: 'Neha Rudraraju', text: 'damn i just saw ur msg sry sry' },
48
+ { index: 1, speaker: 'Me', text: 'safe-send test from hermes. pls ignore :)' },
49
+ ],
50
+ });
51
+
52
+ const rows = await command.func(page, {
53
+ 'thread-url': 'https://www.linkedin.com/messaging/thread/abc/',
54
+ 'max-scrolls': 8,
55
+ json: false,
56
+ });
57
+
58
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://www.linkedin.com/messaging/');
59
+ expect(page.goto).toHaveBeenNthCalledWith(2, 'https://www.linkedin.com/messaging/thread/abc/');
60
+ expect(rows[0]).toMatchObject({
61
+ thread_url: 'https://www.linkedin.com/messaging/thread/abc/',
62
+ recipient: 'Neha Rudraraju',
63
+ message_count: 2,
64
+ latest_text: 'safe-send test from hermes. pls ignore :)',
65
+ });
66
+ expect(rows[0].snapshot_json).toContain('damn i just saw ur msg sry sry');
67
+ });
68
+
69
+ it('rejects invalid thread URL before navigation', async () => {
70
+ const command = getRegistry().get('linkedin/thread-snapshot');
71
+ const page = makeFakePage({});
72
+
73
+ await expect(command.func(page, {
74
+ 'thread-url': 'https://www.linkedin.com/feed/',
75
+ 'max-scrolls': 8,
76
+ })).rejects.toBeInstanceOf(ArgumentError);
77
+ expect(page.goto).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('fails typed on malformed snapshot payloads', async () => {
81
+ const command = getRegistry().get('linkedin/thread-snapshot');
82
+ const page = makeFakePage({ url: 'https://www.linkedin.com/messaging/thread/abc/', headerNames: ['Neha Rudraraju'] });
83
+
84
+ await expect(command.func(page, {
85
+ 'thread-url': 'https://www.linkedin.com/messaging/thread/abc/',
86
+ 'max-scrolls': 8,
87
+ })).rejects.toBeInstanceOf(CommandExecutionError);
88
+ });
89
+ });
@@ -1,5 +1,6 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { compactRepeatedText } from './shared.js';
3
4
  function normalizeWhitespace(value) {
4
5
  return String(value ?? '').replace(/\s+/g, ' ').trim();
5
6
  }
@@ -23,9 +24,9 @@ function buildPostId(post) {
23
24
  const url = normalizeWhitespace(post.url);
24
25
  if (url)
25
26
  return url;
26
- const author = normalizeWhitespace(post.author);
27
+ const author = compactRepeatedText(post.author);
27
28
  const text = normalizeWhitespace(post.text);
28
- const postedAt = normalizeWhitespace(post.posted_at);
29
+ const postedAt = normalizeTimestamp(post.posted_at);
29
30
  return `${author}::${postedAt}::${text.slice(0, 120)}`;
30
31
  }
31
32
  function mergeTimelinePosts(existing, batch) {
@@ -34,11 +35,11 @@ function mergeTimelinePosts(existing, batch) {
34
35
  for (const rawPost of batch) {
35
36
  const post = {
36
37
  id: buildPostId(rawPost),
37
- author: normalizeWhitespace(rawPost.author),
38
+ author: compactRepeatedText(rawPost.author),
38
39
  author_url: normalizeWhitespace(rawPost.author_url),
39
- headline: normalizeWhitespace(rawPost.headline),
40
+ headline: compactRepeatedText(rawPost.headline),
40
41
  text: normalizeWhitespace(rawPost.text),
41
- posted_at: normalizeWhitespace(rawPost.posted_at),
42
+ posted_at: normalizeTimestamp(rawPost.posted_at),
42
43
  reactions: Number(rawPost.reactions) || 0,
43
44
  comments: Number(rawPost.comments) || 0,
44
45
  url: normalizeWhitespace(rawPost.url),
@@ -52,6 +53,11 @@ function mergeTimelinePosts(existing, batch) {
52
53
  }
53
54
  return merged;
54
55
  }
56
+ function normalizeTimestamp(value) {
57
+ const text = normalizeWhitespace(value).replace(/[•·.]$/g, '').trim();
58
+ const match = text.match(/\b(\d+\s*(?:s|m|h|d|w|mo|yr|min))\b/i);
59
+ return match ? match[1].replace(/\s+/g, '') : text;
60
+ }
55
61
  async function extractVisiblePosts(page) {
56
62
  return page.evaluate(`(function () {
57
63
  function normalize(value) {
@@ -376,7 +382,7 @@ async function extractVisiblePosts(page) {
376
382
  || /see more|more/i.test(normalize(el.getAttribute('aria-label')));
377
383
  })
378
384
  .slice(0, 8);
379
- var cards = Array.from(document.querySelectorAll('article, .feed-shared-update-v2, .occludable-update, [role="listitem"]'));
385
+ var cards = Array.from(document.querySelectorAll('article, [role="article"], .feed-shared-update-v2, .occludable-update, [role="listitem"]'));
380
386
  var seen = new Set();
381
387
  var posts = [];
382
388
  var i;
@@ -397,7 +403,7 @@ async function extractVisiblePosts(page) {
397
403
 
398
404
  for (i = 0; i < cards.length; i += 1) {
399
405
  card = cards[i];
400
- root = card.closest('article, .feed-shared-update-v2, .occludable-update, [role="listitem"]') || card;
406
+ root = card.closest('article, [role="article"], .feed-shared-update-v2, .occludable-update, [role="listitem"]') || card;
401
407
  if (!root || seen.has(root)) continue;
402
408
  seen.add(root);
403
409
 
@@ -501,4 +507,5 @@ export const __test__ = {
501
507
  parseMetric,
502
508
  buildPostId,
503
509
  mergeTimelinePosts,
510
+ normalizeTimestamp,
504
511
  };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * LinkedIn Learning course detail by slug, via /learning-api/courses?q=slug.
3
+ */
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
6
+
7
+ const DOMAIN = 'www.linkedin.com';
8
+
9
+ function normalizeWhitespace(value) {
10
+ return String(value ?? '').replace(/[ ]/g, ' ').replace(/\s+/g, ' ').trim();
11
+ }
12
+
13
+ function unwrapEvaluateResult(payload) {
14
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
15
+ return payload;
16
+ }
17
+
18
+ function parseSlug(value) {
19
+ const s = normalizeWhitespace(value);
20
+ if (!s) throw new ArgumentError('<slug> is required');
21
+ let slug = s;
22
+ if (/^https?:\/\//i.test(s)) {
23
+ let parsed;
24
+ try {
25
+ parsed = new URL(s);
26
+ } catch {
27
+ throw new ArgumentError(`Invalid LinkedIn Learning URL: "${s}"`);
28
+ }
29
+ const host = parsed.hostname.toLowerCase();
30
+ if (host !== 'linkedin.com' && host !== 'www.linkedin.com') {
31
+ throw new ArgumentError(`Invalid LinkedIn Learning host: "${parsed.hostname}"`);
32
+ }
33
+ const m = parsed.pathname.match(/^\/learning\/([^/?#]+)/);
34
+ if (!m) throw new ArgumentError(`Invalid LinkedIn Learning course URL: "${s}"`);
35
+ slug = m[1];
36
+ } else {
37
+ const m = s.match(/^\/?learning\/([^/?#]+)/);
38
+ slug = m ? m[1] : s;
39
+ }
40
+ if (!/^[a-zA-Z0-9-_]+$/.test(slug)) {
41
+ throw new ArgumentError(`Invalid LinkedIn Learning slug: "${slug}"`);
42
+ }
43
+ return slug;
44
+ }
45
+
46
+ function buildFetchScript(url, csrf) {
47
+ return String.raw`(async () => {
48
+ try {
49
+ const res = await fetch(${JSON.stringify(url)}, {
50
+ credentials: 'include',
51
+ headers: {
52
+ 'csrf-token': ${JSON.stringify(csrf)},
53
+ 'x-restli-protocol-version': '2.0.0',
54
+ accept: 'application/json',
55
+ },
56
+ });
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
+ function parseCourse(el, slug) {
67
+ const title = normalizeWhitespace(el?.title);
68
+ if (!title) return null;
69
+ const description = typeof el?.description === 'string'
70
+ ? el.description
71
+ : (el?.description?.text || '');
72
+ const duration = el?.duration?.unit === 'SECOND' ? String(el.duration.duration ?? '') : '';
73
+ const released = el?.activatedAt ? new Date(el.activatedAt).toISOString().slice(0, 10) : '';
74
+ return {
75
+ title,
76
+ slug,
77
+ description,
78
+ difficulty: el?.difficultyLevel || '',
79
+ duration_sec: duration,
80
+ videos_count: el?.videosCount ?? '',
81
+ rating: typeof el?.rating?.averageRating === 'number' ? el.rating.averageRating.toFixed(2) : '',
82
+ rating_count: el?.rating?.ratingCount ?? '',
83
+ released,
84
+ url: `https://www.linkedin.com/learning/${slug}`,
85
+ };
86
+ }
87
+
88
+ cli({
89
+ site: 'linkedin-learning',
90
+ name: 'course',
91
+ access: 'read',
92
+ description: 'Get LinkedIn Learning course detail by slug or course URL',
93
+ domain: DOMAIN,
94
+ strategy: Strategy.COOKIE,
95
+ browser: true,
96
+ args: [
97
+ { name: 'slug', type: 'string', required: true, positional: true, help: 'Course slug (e.g. agentic-ai-build-your-first-agentic-ai-system) or full /learning/<slug> URL' },
98
+ ],
99
+ columns: ['title', 'slug', 'description', 'difficulty', 'duration_sec', 'videos_count', 'rating', 'rating_count', 'released', 'url'],
100
+ func: async (page, args) => {
101
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin-learning course');
102
+ const slug = parseSlug(args.slug);
103
+
104
+ await page.goto('https://www.linkedin.com/learning/');
105
+ await page.wait(3);
106
+
107
+ const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
108
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
109
+ if (!jsession) {
110
+ throw new AuthRequiredError(DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn in the browser.');
111
+ }
112
+ const csrf = jsession.replace(/^"|"$/g, '');
113
+
114
+ const url = `https://www.linkedin.com/learning-api/courses?q=slug&slug=${encodeURIComponent(slug)}`;
115
+ const result = unwrapEvaluateResult(await page.evaluate(buildFetchScript(url, csrf)));
116
+ if (result?.authRequired) {
117
+ throw new AuthRequiredError(DOMAIN, `LinkedIn Learning auth failed (HTTP ${result.status ?? ''}).`);
118
+ }
119
+ if (!result?.json) {
120
+ throw new CommandExecutionError(`LinkedIn Learning courses lookup failed: ${result?.error ?? 'no payload'}`);
121
+ }
122
+ const elements = result.json?.elements;
123
+ if (!Array.isArray(elements)) {
124
+ throw new CommandExecutionError('LinkedIn Learning courses lookup returned malformed payload: missing elements array');
125
+ }
126
+ const el = elements[0];
127
+ if (!el) {
128
+ throw new EmptyResultError(`No LinkedIn Learning course found for slug "${slug}"`);
129
+ }
130
+ const row = parseCourse(el, slug);
131
+ if (!row) {
132
+ throw new CommandExecutionError('LinkedIn Learning courses lookup returned malformed course detail: missing title');
133
+ }
134
+ return [row];
135
+ },
136
+ });
137
+
138
+ export const __test__ = { parseSlug, parseCourse };
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import './course.js';
5
+
6
+ const { parseSlug, parseCourse } = await import('./course.js').then((m) => m.__test__);
7
+
8
+ function makePage({ evaluateResult, cookies = [{ name: 'JSESSIONID', value: '"ajax:abc"' }] } = {}) {
9
+ return {
10
+ goto: vi.fn().mockResolvedValue(undefined),
11
+ wait: vi.fn().mockResolvedValue(undefined),
12
+ getCookies: vi.fn().mockResolvedValue(cookies),
13
+ evaluate: vi.fn().mockResolvedValue(evaluateResult),
14
+ };
15
+ }
16
+
17
+ describe('linkedin-learning course', () => {
18
+ it('accepts a bare slug', () => {
19
+ expect(parseSlug('agentic-ai-build')).toBe('agentic-ai-build');
20
+ });
21
+
22
+ it('extracts a slug from a full /learning/<slug> URL', () => {
23
+ expect(parseSlug('https://www.linkedin.com/learning/agentic-ai-build/?foo=1'))
24
+ .toBe('agentic-ai-build');
25
+ });
26
+
27
+ it('rejects non-LinkedIn Learning URLs before navigation', () => {
28
+ expect(() => parseSlug('https://evil.example/learning/agentic-ai-build')).toThrow(ArgumentError);
29
+ expect(() => parseSlug('https://www.linkedin.com/feed/update/123')).toThrow(ArgumentError);
30
+ });
31
+
32
+ it('rejects empty or invalid slugs with ArgumentError', () => {
33
+ expect(() => parseSlug('')).toThrow(ArgumentError);
34
+ expect(() => parseSlug(' ')).toThrow(ArgumentError);
35
+ expect(() => parseSlug('not a slug!')).toThrow(ArgumentError);
36
+ });
37
+
38
+ it('maps a course detail element to the canonical row shape', () => {
39
+ const el = {
40
+ title: 'Agentic AI: Build Your First Agentic AI System',
41
+ description: { text: 'Dive into agentic AI...' },
42
+ duration: { duration: 3932, unit: 'SECOND' },
43
+ difficultyLevel: 'Intermediate',
44
+ videosCount: 18,
45
+ rating: { averageRating: 4.5, ratingCount: 259 },
46
+ activatedAt: 1774569600000,
47
+ };
48
+ const row = parseCourse(el, 'agentic-ai-build-your-first-agentic-ai-system');
49
+ expect(row.title).toBe('Agentic AI: Build Your First Agentic AI System');
50
+ expect(row.slug).toBe('agentic-ai-build-your-first-agentic-ai-system');
51
+ expect(row.description).toBe('Dive into agentic AI...');
52
+ expect(row.difficulty).toBe('Intermediate');
53
+ expect(row.duration_sec).toBe('3932');
54
+ expect(row.videos_count).toBe(18);
55
+ expect(row.rating).toBe('4.50');
56
+ expect(row.rating_count).toBe(259);
57
+ expect(row.released).toBe('2026-03-27');
58
+ expect(row.url).toBe('https://www.linkedin.com/learning/agentic-ai-build-your-first-agentic-ai-system');
59
+ });
60
+
61
+ it('handles description as a bare string', () => {
62
+ const row = parseCourse({ title: 't', description: 'plain string' }, 'x');
63
+ expect(row.description).toBe('plain string');
64
+ });
65
+
66
+ it('preserves the full course description', () => {
67
+ const text = 'x'.repeat(350);
68
+ const row = parseCourse({ title: 't', description: { text } }, 'x');
69
+ expect(row.description).toBe(text);
70
+ });
71
+
72
+ it('returns empty fields when upstream omits them', () => {
73
+ const row = parseCourse({ title: 't' }, 'x');
74
+ expect(row.title).toBe('t');
75
+ expect(row.duration_sec).toBe('');
76
+ expect(row.rating).toBe('');
77
+ expect(row.released).toBe('');
78
+ });
79
+
80
+ it('returns null when upstream omits the core title evidence', () => {
81
+ expect(parseCourse({}, 'x')).toBeNull();
82
+ expect(parseCourse({ title: ' ' }, 'x')).toBeNull();
83
+ });
84
+
85
+ it('throws AuthRequiredError when JSESSIONID is missing', async () => {
86
+ const cmd = getRegistry().get('linkedin-learning/course');
87
+ const page = makePage({ cookies: [], evaluateResult: { json: { elements: [{}] } } });
88
+ await expect(cmd.func(page, { slug: 'agentic-ai-build' })).rejects.toBeInstanceOf(AuthRequiredError);
89
+ });
90
+
91
+ it('throws EmptyResultError when no element matches the slug', async () => {
92
+ const cmd = getRegistry().get('linkedin-learning/course');
93
+ const page = makePage({ evaluateResult: { json: { elements: [] } } });
94
+ await expect(cmd.func(page, { slug: 'agentic-ai-build' })).rejects.toBeInstanceOf(EmptyResultError);
95
+ });
96
+
97
+ it('throws CommandExecutionError when the elements array is missing', async () => {
98
+ const cmd = getRegistry().get('linkedin-learning/course');
99
+ const page = makePage({ evaluateResult: { json: { data: {} } } });
100
+ await expect(cmd.func(page, { slug: 'agentic-ai-build' })).rejects.toBeInstanceOf(CommandExecutionError);
101
+ });
102
+
103
+ it('throws CommandExecutionError when the first detail element is malformed', async () => {
104
+ const cmd = getRegistry().get('linkedin-learning/course');
105
+ const page = makePage({ evaluateResult: { json: { elements: [{}] } } });
106
+ await expect(cmd.func(page, { slug: 'agentic-ai-build' })).rejects.toBeInstanceOf(CommandExecutionError);
107
+ });
108
+
109
+ it('throws CommandExecutionError on fetch errors', async () => {
110
+ const cmd = getRegistry().get('linkedin-learning/course');
111
+ const page = makePage({ evaluateResult: { error: 'HTTP 500' } });
112
+ await expect(cmd.func(page, { slug: 'agentic-ai-build' })).rejects.toBeInstanceOf(CommandExecutionError);
113
+ });
114
+ });