@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,79 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import './salesnav-thread.js';
4
+
5
+ const {
6
+ parseThreadInput,
7
+ threadApiUrl,
8
+ parseSalesnavThreadMessages,
9
+ threadMatchesInput,
10
+ salesnavThreadUrl,
11
+ } = await import('./salesnav-thread.js').then((m) => m.__test__);
12
+
13
+ describe('linkedin salesnav-thread command', () => {
14
+ it('accepts Sales Navigator inbox URLs, raw thread ids, lead URLs, urns, and names', () => {
15
+ expect(parseThreadInput('https://www.linkedin.com/sales/inbox/2-abc%3D%3D')).toEqual(['thread_id', '2-abc==']);
16
+ expect(parseThreadInput('2-abc==')).toEqual(['thread_id', '2-abc==']);
17
+ expect(parseThreadInput('https://www.linkedin.com/sales/lead/P1,NAME_SEARCH,T1')).toEqual(['recipient_urn', 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)']);
18
+ expect(parseThreadInput('urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)')).toEqual(['recipient_urn', 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)']);
19
+ expect(parseThreadInput('Rachael Stolberg')).toEqual(['name', 'rachael stolberg']);
20
+ expect(() => parseThreadInput('urn:li:fs_salesProfile:(P1,undefined,T1)')).toThrow(ArgumentError);
21
+ expect(() => parseThreadInput('https://www.linkedin.com/sales/lead/P1,undefined,T1')).toThrow(ArgumentError);
22
+ });
23
+
24
+ it('builds the decorated thread API URL with encoded parentheses and message pagination size', () => {
25
+ const url = threadApiUrl('2-thread==', 40);
26
+ expect(url).toContain('/sales-api/salesApiMessagingThreads/2-thread%3D%3D?');
27
+ expect(url).toContain('messageCount=40');
28
+ expect(url).toContain('count=1');
29
+ expect(url).toContain('decoration=%28');
30
+ expect(url).not.toContain('decoration=(');
31
+ });
32
+
33
+ it('parses thread messages in chronological order with sender names and timestamps', () => {
34
+ const rows = parseSalesnavThreadMessages({
35
+ id: '2-thread',
36
+ participants: [
37
+ 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)',
38
+ 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)',
39
+ ],
40
+ participantsResolutionResults: {
41
+ 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)': { fullName: 'Rachael Stolberg', entityUrn: 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)' },
42
+ 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)': { fullName: 'Hanzi Li', entityUrn: 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)' },
43
+ },
44
+ messages: [
45
+ { id: 'm2', author: 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)', body: 'Happy to chat', deliveredAt: 1778206803669, type: 'MEMBER_TO_MEMBER' },
46
+ { id: 'm1', author: 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)', subject: 'Quick question', body: 'Hi Rachael', deliveredAt: 1777188013523, type: 'INMAIL' },
47
+ ],
48
+ });
49
+ expect(rows).toHaveLength(2);
50
+ expect(rows[0]).toMatchObject({
51
+ index: 0,
52
+ message_id: 'm1',
53
+ thread_id: '2-thread',
54
+ sender: 'Hanzi Li',
55
+ subject: 'Quick question',
56
+ text: 'Hi Rachael',
57
+ timestamp: '2026-04-26T07:20:13.523Z',
58
+ });
59
+ expect(rows[1]).toMatchObject({ index: 1, message_id: 'm2', sender: 'Rachael Stolberg', text: 'Happy to chat' });
60
+ });
61
+
62
+ it('fails typed on malformed thread message payloads', () => {
63
+ expect(() => parseSalesnavThreadMessages({ messages: [] })).toThrow(CommandExecutionError);
64
+ expect(() => parseSalesnavThreadMessages({ id: '2-thread' })).toThrow(CommandExecutionError);
65
+ expect(() => parseSalesnavThreadMessages({ id: '2-thread', messages: [null] })).toThrow(CommandExecutionError);
66
+ });
67
+
68
+ it('matches recipient identifiers against inbox rows', () => {
69
+ const row = {
70
+ thread_id: '2-thread',
71
+ person_name: 'Rachael Stolberg',
72
+ participants: [{ name: 'Rachael Stolberg', entity_urn: 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)' }],
73
+ };
74
+ expect(threadMatchesInput(row, ['thread_id', '2-thread'])).toBe(true);
75
+ expect(threadMatchesInput(row, ['recipient_urn', 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)'])).toBe(true);
76
+ expect(threadMatchesInput(row, ['name', 'rachael stolberg'])).toBe(true);
77
+ expect(salesnavThreadUrl('2-thread')).toBe('https://www.linkedin.com/sales/inbox/2-thread');
78
+ });
79
+ });
@@ -0,0 +1,92 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+
4
+ const LINKEDIN_DOMAIN = 'www.linkedin.com';
5
+ const SENT_URL = 'https://www.linkedin.com/mynetwork/invitation-manager/sent/';
6
+
7
+ function unwrapEvaluateResult(payload) {
8
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
9
+ return payload;
10
+ }
11
+
12
+ function buildSentInvitationsScript() {
13
+ return String.raw`(() => {
14
+ const clean = (s) => String(s || '').replace(/[  ]/g, ' ').replace(/\s+/g, ' ').trim();
15
+ const text = document.body ? (document.body.innerText || '') : '';
16
+ const href = location.href;
17
+ const authRequired = /\b(sign in|log in|join linkedin)\b/i.test(text)
18
+ || /linkedin\.com\/(login|checkpoint|authwall|uas)/i.test(href);
19
+ const warning = /captcha|verification required|unusual activity|account restricted|temporarily restricted|security check|checkpoint/i.test(text);
20
+ const cleanName = (value) => {
21
+ const first = String(value || '').split(/\n+/).map(clean).filter(Boolean)[0] || '';
22
+ return clean(first
23
+ .replace(/^(view\s+)?profile\s+of\s+/i, '')
24
+ .replace(/\s*(?:View profile|LinkedIn|Pending|Sent|Withdraw).*$/i, ''));
25
+ };
26
+ const cards = Array.from(document.querySelectorAll('li, div, section, article')).filter((el) => {
27
+ if (!el || el.offsetParent === null) return false;
28
+ const t = clean(el.innerText || el.textContent || '');
29
+ return t && /withdraw/i.test(t) && t.length < 1200;
30
+ });
31
+ const byName = new Map();
32
+ for (const card of cards) {
33
+ const raw = card.innerText || card.textContent || '';
34
+ if (!/withdraw/i.test(raw)) continue;
35
+ const lines = raw.split(/\n+/).map(clean).filter(Boolean);
36
+ const link = card.querySelector('a[href*="/in/"]');
37
+ const linkName = cleanName(link ? (link.innerText || link.textContent || link.getAttribute('aria-label') || '') : '');
38
+ const name = linkName
39
+ || cleanName(lines.find((line) => !/^(pending|sent|withdraw|message|view profile|invitation|invited|ago|manage|received)\b/i.test(line)) || '');
40
+ if (!name) continue;
41
+ const hrefAttr = link ? (link.getAttribute('href') || '') : '';
42
+ const profile_url = hrefAttr ? new URL(hrefAttr, location.origin).toString().replace(/[?#].*$/, '') : '';
43
+ const invited_date_text = clean((raw.match(/(?:Sent|Invited)\s+(?:\d+\s+\w+\s+ago|yesterday|today)/i) || [''])[0]);
44
+ const key = name.toLowerCase();
45
+ const existing = byName.get(key);
46
+ if (!existing) {
47
+ byName.set(key, { name, profile_url, invited_date_text });
48
+ } else {
49
+ if (!existing.profile_url && profile_url) existing.profile_url = profile_url;
50
+ if (!existing.invited_date_text && invited_date_text) existing.invited_date_text = invited_date_text;
51
+ }
52
+ }
53
+ const rows = Array.from(byName.values());
54
+ return { url: href, title: document.title || '', authRequired, warning, count: rows.length, rows, bodyText: text.slice(0, 1000) };
55
+ })()`;
56
+ }
57
+
58
+ cli({
59
+ site: 'linkedin',
60
+ name: 'sent-invitations',
61
+ access: 'read',
62
+ description: 'List pending LinkedIn sent invitations for CRM reconciliation',
63
+ domain: LINKEDIN_DOMAIN,
64
+ strategy: Strategy.UI,
65
+ browser: true,
66
+ args: [],
67
+ columns: ['rank', 'name', 'profile_url', 'invited_date_text'],
68
+ func: async (page) => {
69
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin sent-invitations');
70
+ await page.goto(SENT_URL);
71
+ await page.wait(12);
72
+ let result = unwrapEvaluateResult(await page.evaluate(buildSentInvitationsScript()));
73
+ if (result?.authRequired) {
74
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn sent invitations requires an active signed-in browser session.');
75
+ }
76
+ if (result?.warning) {
77
+ throw new CommandExecutionError('LinkedIn warning/restriction state visible on sent invitations page.');
78
+ }
79
+ const rows = Array.isArray(result?.rows) ? result.rows : [];
80
+ return rows.map((row, index) => ({
81
+ rank: index + 1,
82
+ name: row.name || '',
83
+ profile_url: row.profile_url || '',
84
+ invited_date_text: row.invited_date_text || '',
85
+ }));
86
+ },
87
+ });
88
+
89
+ export const __test__ = {
90
+ buildSentInvitationsScript,
91
+ unwrapEvaluateResult,
92
+ };
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './sent-invitations.js';
5
+
6
+ const { buildSentInvitationsScript } = await import('./sent-invitations.js').then((m) => m.__test__);
7
+
8
+ describe('linkedin sent-invitations command', () => {
9
+ it('registers with structured columns that do not include raw blobs', () => {
10
+ const command = getRegistry().get('linkedin/sent-invitations');
11
+ expect(command).toBeDefined();
12
+ expect(command.access).toBe('read');
13
+ expect(command.columns).toEqual(['rank', 'name', 'profile_url', 'invited_date_text']);
14
+ });
15
+
16
+ it('extracts clean names and dedupes invitation cards by profile url', () => {
17
+ const dom = new JSDOM(`<!doctype html><body>
18
+ <ul>
19
+ <li>
20
+ <a href="/in/olga-magere/?miniProfileUrn=x"><span>Olga Magere</span></a>
21
+ <span>Pending</span><button>Withdraw</button><span>Sent 2 weeks ago</span>
22
+ </li>
23
+ <li>
24
+ <a href="/in/olga-magere/?trk=dup"><span>Olga Magere</span></a>
25
+ <span>Pending</span><button>Withdraw</button><span>Sent 2 weeks ago</span>
26
+ </li>
27
+ <li>
28
+ <div>Sam Founder\nSent yesterday\nWithdraw</div>
29
+ </li>
30
+ </ul>
31
+ </body>`, { url: 'https://www.linkedin.com/mynetwork/invitation-manager/sent/' });
32
+ const previousWindow = globalThis.window;
33
+ const previousDocument = globalThis.document;
34
+ const previousLocation = globalThis.location;
35
+ try {
36
+ globalThis.window = dom.window;
37
+ globalThis.document = dom.window.document;
38
+ globalThis.location = dom.window.location;
39
+ globalThis.getComputedStyle = dom.window.getComputedStyle;
40
+ Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetParent', { get() { return dom.window.document.body; }, configurable: true });
41
+ const run = Function(`return ${buildSentInvitationsScript()}`);
42
+ const result = run();
43
+ expect(result.rows).toEqual([
44
+ {
45
+ name: 'Olga Magere',
46
+ profile_url: 'https://www.linkedin.com/in/olga-magere/',
47
+ invited_date_text: 'Sent 2 weeks ago',
48
+ },
49
+ {
50
+ name: 'Sam Founder',
51
+ profile_url: '',
52
+ invited_date_text: 'Sent yesterday',
53
+ },
54
+ ]);
55
+ expect(result.rows[0]).not.toHaveProperty('raw');
56
+ } finally {
57
+ globalThis.window = previousWindow;
58
+ globalThis.document = previousDocument;
59
+ globalThis.location = previousLocation;
60
+ }
61
+ });
62
+ });
@@ -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
+ });
@@ -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 };