@jackwener/opencli 1.7.22 → 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 (222) hide show
  1. package/README.md +30 -148
  2. package/README.zh-CN.md +37 -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/chatgpt/envelope.test.js +108 -0
  24. package/clis/chatgpt/image.js +2 -2
  25. package/clis/chatgpt/image.test.js +6 -0
  26. package/clis/chatgpt/utils.js +148 -41
  27. package/clis/chatgpt/utils.test.js +92 -2
  28. package/clis/douyin/_shared/browser-fetch.js +44 -20
  29. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  30. package/clis/douyin/_shared/evaluate-result.js +16 -0
  31. package/clis/douyin/_shared/tos-upload.js +105 -69
  32. package/clis/douyin/_shared/vod-upload.js +212 -0
  33. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  34. package/clis/douyin/delete.js +137 -4
  35. package/clis/douyin/delete.test.js +90 -1
  36. package/clis/douyin/publish-upload-id.test.js +170 -0
  37. package/clis/douyin/publish.js +88 -42
  38. package/clis/douyin/user-videos.js +9 -2
  39. package/clis/douyin/user-videos.test.js +43 -0
  40. package/clis/flomo/memos.js +228 -0
  41. package/clis/flomo/memos.test.js +144 -0
  42. package/clis/gitee/search.js +2 -2
  43. package/clis/gitee/search.test.js +65 -0
  44. package/clis/jike/post.js +27 -17
  45. package/clis/jike/read.test.js +86 -0
  46. package/clis/jike/topic.js +32 -19
  47. package/clis/jike/user.js +33 -20
  48. package/clis/lesswrong/comments.js +1 -1
  49. package/clis/lesswrong/curated.js +1 -1
  50. package/clis/lesswrong/frontpage.js +1 -1
  51. package/clis/lesswrong/frontpage.test.js +37 -0
  52. package/clis/lesswrong/new.js +1 -1
  53. package/clis/lesswrong/read.js +1 -1
  54. package/clis/lesswrong/sequences.js +1 -1
  55. package/clis/lesswrong/shortform.js +1 -1
  56. package/clis/lesswrong/tag.js +1 -1
  57. package/clis/lesswrong/top-month.js +1 -1
  58. package/clis/lesswrong/top-week.js +1 -1
  59. package/clis/lesswrong/top-year.js +1 -1
  60. package/clis/lesswrong/top.js +1 -1
  61. package/clis/linkedin/connect.js +401 -0
  62. package/clis/linkedin/connect.test.js +213 -0
  63. package/clis/linkedin/inbox.js +234 -0
  64. package/clis/linkedin/inbox.test.js +152 -0
  65. package/clis/linkedin/people-search.js +262 -0
  66. package/clis/linkedin/people-search.test.js +216 -0
  67. package/clis/linkedin/safe-send.js +357 -0
  68. package/clis/linkedin/safe-send.test.js +204 -0
  69. package/clis/linkedin/salesnav-inbox.js +210 -0
  70. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  71. package/clis/linkedin/salesnav-message.js +360 -0
  72. package/clis/linkedin/salesnav-message.test.js +172 -0
  73. package/clis/linkedin/salesnav-search.js +186 -0
  74. package/clis/linkedin/salesnav-search.test.js +76 -0
  75. package/clis/linkedin/salesnav-thread.js +212 -0
  76. package/clis/linkedin/salesnav-thread.test.js +79 -0
  77. package/clis/linkedin/sent-invitations.js +92 -0
  78. package/clis/linkedin/sent-invitations.test.js +62 -0
  79. package/clis/linkedin/thread-snapshot.js +214 -0
  80. package/clis/linkedin/thread-snapshot.test.js +89 -0
  81. package/clis/linkedin-learning/course.js +138 -0
  82. package/clis/linkedin-learning/course.test.js +114 -0
  83. package/clis/linkedin-learning/search.js +155 -0
  84. package/clis/linkedin-learning/search.test.js +144 -0
  85. package/clis/linkedin-learning/trending.js +133 -0
  86. package/clis/linkedin-learning/trending.test.js +123 -0
  87. package/clis/powerchina/search.js +3 -3
  88. package/clis/powerchina/search.test.js +27 -1
  89. package/clis/reddit/extract-media.test.js +149 -0
  90. package/clis/reddit/frontpage.js +47 -9
  91. package/clis/reddit/frontpage.test.js +34 -0
  92. package/clis/reddit/home.js +31 -1
  93. package/clis/reddit/home.test.js +46 -3
  94. package/clis/reddit/hot.js +32 -1
  95. package/clis/reddit/hot.test.js +15 -1
  96. package/clis/reddit/popular.js +39 -1
  97. package/clis/reddit/popular.test.js +26 -0
  98. package/clis/reddit/saved.js +1 -1
  99. package/clis/reddit/search.js +38 -1
  100. package/clis/reddit/search.test.js +26 -0
  101. package/clis/reddit/subreddit.js +52 -7
  102. package/clis/reddit/subreddit.test.js +31 -0
  103. package/clis/reddit/subscribed.js +165 -0
  104. package/clis/reddit/subscribed.test.js +168 -0
  105. package/clis/reddit/upvoted.js +1 -1
  106. package/clis/suno/commands.test.js +188 -0
  107. package/clis/suno/download.js +140 -0
  108. package/clis/suno/download.test.js +151 -0
  109. package/clis/suno/generate.js +226 -0
  110. package/clis/suno/generate.test.js +243 -0
  111. package/clis/suno/list.js +79 -0
  112. package/clis/suno/status.js +62 -0
  113. package/clis/suno/utils.js +540 -0
  114. package/clis/suno/utils.test.js +223 -0
  115. package/clis/twitter/device-follow.js +193 -0
  116. package/clis/twitter/device-follow.test.js +287 -0
  117. package/clis/twitter/download.js +443 -73
  118. package/clis/twitter/download.test.js +457 -0
  119. package/clis/twitter/list-create.js +155 -0
  120. package/clis/twitter/list-create.test.js +169 -0
  121. package/clis/twitter/list-remove.js +12 -5
  122. package/clis/twitter/list-remove.test.js +74 -0
  123. package/clis/twitter/list-tweets.js +6 -2
  124. package/clis/twitter/list-tweets.test.js +41 -1
  125. package/clis/twitter/lists.js +31 -4
  126. package/clis/twitter/lists.test.js +152 -16
  127. package/clis/twitter/search.js +6 -2
  128. package/clis/twitter/search.test.js +6 -0
  129. package/clis/twitter/shared.js +144 -0
  130. package/clis/twitter/shared.test.js +429 -1
  131. package/clis/twitter/thread.js +10 -2
  132. package/clis/twitter/thread.test.js +58 -0
  133. package/clis/twitter/timeline.js +6 -2
  134. package/clis/twitter/timeline.test.js +2 -0
  135. package/clis/twitter/tweets.js +3 -2
  136. package/clis/twitter/tweets.test.js +1 -1
  137. package/clis/weibo/delete.js +172 -0
  138. package/clis/weibo/delete.test.js +94 -0
  139. package/clis/weibo/publish.js +37 -14
  140. package/clis/weibo/publish.test.js +14 -5
  141. package/clis/weibo/user-posts.js +234 -0
  142. package/clis/weibo/user-posts.test.js +92 -0
  143. package/clis/weread/search-regression.test.js +18 -11
  144. package/clis/weread/search.js +15 -7
  145. package/clis/weread-official/book.js +135 -0
  146. package/clis/weread-official/commands.test.js +385 -0
  147. package/clis/weread-official/discover.js +107 -0
  148. package/clis/weread-official/list-apis.js +95 -0
  149. package/clis/weread-official/notes.js +171 -0
  150. package/clis/weread-official/readdata.js +158 -0
  151. package/clis/weread-official/review.js +93 -0
  152. package/clis/weread-official/search.js +106 -0
  153. package/clis/weread-official/shelf.js +97 -0
  154. package/clis/weread-official/utils.js +293 -0
  155. package/clis/weread-official/utils.test.js +242 -0
  156. package/clis/wikipedia/trending.js +7 -3
  157. package/clis/wikipedia/trending.test.js +57 -0
  158. package/clis/xianyu/chat.js +24 -109
  159. package/clis/xianyu/chat.test.js +5 -0
  160. package/clis/xianyu/im.js +322 -0
  161. package/clis/xianyu/im.test.js +253 -0
  162. package/clis/xianyu/inbox.js +96 -0
  163. package/clis/xianyu/messages.js +91 -0
  164. package/clis/xianyu/reply.js +82 -0
  165. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  166. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  167. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  168. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  169. package/clis/xiaohongshu/creator-notes.js +2 -1
  170. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  171. package/clis/xiaohongshu/creator-stats.js +2 -1
  172. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  173. package/clis/xiaohongshu/delete-note.js +260 -0
  174. package/clis/xiaohongshu/delete-note.test.js +172 -0
  175. package/clis/xiaohongshu/publish.js +48 -8
  176. package/clis/xiaohongshu/publish.test.js +65 -10
  177. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  178. package/clis/xiaohongshu/user.js +27 -4
  179. package/clis/xiaoyuzhou/download.js +1 -1
  180. package/clis/xiaoyuzhou/transcript.js +1 -1
  181. package/clis/youdao/note.js +258 -0
  182. package/clis/youdao/note.test.js +99 -0
  183. package/clis/youtube/transcript.js +397 -24
  184. package/clis/youtube/transcript.test.js +196 -6
  185. package/clis/zhihu/answer-comments.js +299 -0
  186. package/clis/zhihu/answer-comments.test.js +287 -0
  187. package/clis/zhihu/answer-detail.js +12 -0
  188. package/clis/zhihu/answer-detail.test.js +8 -0
  189. package/clis/zhihu/collection.js +15 -2
  190. package/clis/zhihu/collection.test.js +46 -0
  191. package/clis/zhihu/download.js +1 -1
  192. package/clis/zhihu/question.js +42 -9
  193. package/clis/zhihu/question.test.js +111 -9
  194. package/clis/zhihu/search.js +206 -43
  195. package/clis/zhihu/search.test.js +198 -0
  196. package/dist/src/browser/errors.js +4 -2
  197. package/dist/src/browser/errors.test.js +6 -0
  198. package/dist/src/browser/page.js +30 -4
  199. package/dist/src/browser/page.test.js +42 -0
  200. package/dist/src/browser/utils.d.ts +1 -1
  201. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  202. package/dist/src/cli-argv-preprocess.js +138 -0
  203. package/dist/src/cli-argv-preprocess.test.js +79 -0
  204. package/dist/src/convention-audit.js +15 -8
  205. package/dist/src/convention-audit.test.js +21 -0
  206. package/dist/src/download/media-download.js +15 -2
  207. package/dist/src/download/media-download.test.d.ts +1 -0
  208. package/dist/src/download/media-download.test.js +110 -0
  209. package/dist/src/electron-apps.js +1 -1
  210. package/dist/src/electron-apps.test.js +7 -2
  211. package/dist/src/errors.d.ts +17 -0
  212. package/dist/src/errors.js +22 -0
  213. package/dist/src/external-clis.yaml +8 -0
  214. package/dist/src/main.js +14 -2
  215. package/dist/src/utils.d.ts +43 -0
  216. package/dist/src/utils.js +97 -0
  217. package/dist/src/utils.test.d.ts +1 -0
  218. package/dist/src/utils.test.js +155 -0
  219. package/package.json +8 -2
  220. package/scripts/silent-column-drop-baseline.json +0 -52
  221. package/scripts/typed-error-lint-baseline.json +28 -380
  222. package/clis/slock/_utils.js +0 -12
@@ -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
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * LinkedIn Learning search via the public learning-api REST endpoint.
3
+ * Shares cookie session with linkedin.com; no Commercial Use Limit.
4
+ */
5
+ import { cli, Strategy } from '@jackwener/opencli/registry';
6
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
7
+
8
+ const DOMAIN = 'www.linkedin.com';
9
+ const MAX_LIMIT = 50;
10
+
11
+ function normalizeWhitespace(value) {
12
+ return String(value ?? '').replace(/[ ]/g, ' ').replace(/\s+/g, ' ').trim();
13
+ }
14
+
15
+ function parseLimit(value) {
16
+ if (value === undefined || value === null || value === '') return 10;
17
+ const limit = Number(value);
18
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
19
+ throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
20
+ }
21
+ return limit;
22
+ }
23
+
24
+ function unwrapEvaluateResult(payload) {
25
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
26
+ return payload;
27
+ }
28
+
29
+ function buildFetchScript(url, csrf) {
30
+ return String.raw`(async () => {
31
+ try {
32
+ const res = await fetch(${JSON.stringify(url)}, {
33
+ credentials: 'include',
34
+ headers: {
35
+ 'csrf-token': ${JSON.stringify(csrf)},
36
+ 'x-restli-protocol-version': '2.0.0',
37
+ accept: 'application/json',
38
+ },
39
+ });
40
+ if (res.status === 401 || res.status === 403) return { authRequired: true, status: res.status };
41
+ if (!res.ok) return { error: 'HTTP ' + res.status };
42
+ return { json: await res.json() };
43
+ } catch (e) {
44
+ return { error: 'fetch failed: ' + ((e && e.message) || String(e)) };
45
+ }
46
+ })()`;
47
+ }
48
+
49
+ function parseAuthors(authors) {
50
+ if (!Array.isArray(authors)) return '';
51
+ return authors
52
+ .map((a) => normalizeWhitespace((a?.firstName ?? '') + ' ' + (a?.lastName ?? '')))
53
+ .filter(Boolean)
54
+ .join(', ');
55
+ }
56
+
57
+ function durationSeconds(length) {
58
+ const ts = length?.['com.linkedin.common.TimeSpan'];
59
+ if (!ts || ts.unit !== 'SECOND') return '';
60
+ return String(ts.duration ?? '');
61
+ }
62
+
63
+ function averageRating(rating) {
64
+ if (!rating) return '';
65
+ if (typeof rating.averageRating === 'number') return rating.averageRating.toFixed(2);
66
+ if (typeof rating.ratingSum === 'number' && typeof rating.ratingCount === 'number' && rating.ratingCount > 0) {
67
+ return (rating.ratingSum / rating.ratingCount).toFixed(2);
68
+ }
69
+ return '';
70
+ }
71
+
72
+ function parseRow(el, rank) {
73
+ const type = el?.entityType || '';
74
+ const slug = el?.slug || '';
75
+ if (!slug) return null;
76
+ return {
77
+ rank,
78
+ type,
79
+ title: el?.headline?.title?.text || '',
80
+ instructor: parseAuthors(el?.authors),
81
+ difficulty: el?.difficultyLevel || '',
82
+ duration_sec: durationSeconds(el?.length),
83
+ rating: averageRating(el?.rating),
84
+ rating_count: el?.rating?.ratingCount ?? '',
85
+ viewers: el?.viewerCount ?? '',
86
+ url: slug ? `https://www.linkedin.com/learning/${slug}` : '',
87
+ };
88
+ }
89
+
90
+ cli({
91
+ site: 'linkedin-learning',
92
+ name: 'search',
93
+ access: 'read',
94
+ description: 'Search LinkedIn Learning courses, videos, and learning paths by keyword',
95
+ domain: DOMAIN,
96
+ strategy: Strategy.COOKIE,
97
+ browser: true,
98
+ args: [
99
+ { name: 'keywords', type: 'string', required: true, positional: true, help: 'Search keywords, e.g. "AI agent"' },
100
+ { name: 'limit', type: 'int', default: 10, help: `Maximum results to return (1-${MAX_LIMIT})` },
101
+ ],
102
+ columns: ['rank', 'type', 'title', 'instructor', 'difficulty', 'duration_sec', 'rating', 'rating_count', 'viewers', 'url'],
103
+ func: async (page, args) => {
104
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin-learning search');
105
+ const keywords = normalizeWhitespace(args.keywords);
106
+ if (!keywords) throw new ArgumentError('--keywords is required');
107
+ const limit = parseLimit(args.limit);
108
+
109
+ await page.goto('https://www.linkedin.com/learning/');
110
+ await page.wait(3);
111
+
112
+ const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
113
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
114
+ if (!jsession) {
115
+ throw new AuthRequiredError(DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn in the browser.');
116
+ }
117
+ const csrf = jsession.replace(/^"|"$/g, '');
118
+
119
+ const url = `https://www.linkedin.com/learning-api/searchV2?keywords=${encodeURIComponent(keywords)}&q=keywords`;
120
+ const result = unwrapEvaluateResult(await page.evaluate(buildFetchScript(url, csrf)));
121
+ if (result?.authRequired) {
122
+ throw new AuthRequiredError(DOMAIN, `LinkedIn Learning auth failed (HTTP ${result.status ?? ''}).`);
123
+ }
124
+ if (!result?.json) {
125
+ throw new CommandExecutionError(`LinkedIn Learning searchV2 failed: ${result?.error ?? 'no payload'}`);
126
+ }
127
+ const elements = result.json?.elements;
128
+ if (!Array.isArray(elements)) {
129
+ throw new CommandExecutionError('LinkedIn Learning searchV2 returned malformed payload: missing elements array');
130
+ }
131
+ if (elements.length === 0) {
132
+ throw new EmptyResultError(`No LinkedIn Learning results for "${keywords}"`);
133
+ }
134
+ const rows = [];
135
+ for (const el of elements) {
136
+ if (rows.length >= limit) break;
137
+ const row = parseRow(el, rows.length + 1);
138
+ if (row) rows.push(row);
139
+ }
140
+ if (rows.length === 0) {
141
+ throw new CommandExecutionError('LinkedIn Learning searchV2 returned no parseable rows with slug identity');
142
+ }
143
+ return rows;
144
+ },
145
+ });
146
+
147
+ export const __test__ = {
148
+ normalizeWhitespace,
149
+ parseLimit,
150
+ parseAuthors,
151
+ durationSeconds,
152
+ averageRating,
153
+ parseRow,
154
+ buildFetchScript,
155
+ };
@@ -0,0 +1,144 @@
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 './search.js';
5
+
6
+ const { parseLimit, parseAuthors, durationSeconds, averageRating, parseRow, buildFetchScript } = await import('./search.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 search', () => {
18
+ it('validates --limit without silent clamping', () => {
19
+ expect(parseLimit(undefined)).toBe(10);
20
+ expect(parseLimit(1)).toBe(1);
21
+ expect(parseLimit(50)).toBe(50);
22
+ expect(() => parseLimit(0)).toThrow(ArgumentError);
23
+ expect(() => parseLimit(51)).toThrow(ArgumentError);
24
+ expect(() => parseLimit('abc')).toThrow(ArgumentError);
25
+ expect(() => parseLimit(1.5)).toThrow(ArgumentError);
26
+ });
27
+
28
+ it('joins author first/last names', () => {
29
+ expect(parseAuthors([{ firstName: 'Jane', lastName: 'Doe' }])).toBe('Jane Doe');
30
+ expect(parseAuthors([{ firstName: 'A', lastName: 'B' }, { firstName: 'C', lastName: 'D' }])).toBe('A B, C D');
31
+ expect(parseAuthors([])).toBe('');
32
+ expect(parseAuthors(undefined)).toBe('');
33
+ });
34
+
35
+ it('extracts duration from TimeSpan only when unit is SECOND', () => {
36
+ expect(durationSeconds({ 'com.linkedin.common.TimeSpan': { duration: 600, unit: 'SECOND' } })).toBe('600');
37
+ expect(durationSeconds({ 'com.linkedin.common.TimeSpan': { duration: 10, unit: 'MINUTE' } })).toBe('');
38
+ expect(durationSeconds(undefined)).toBe('');
39
+ });
40
+
41
+ it('computes average rating from sum/count when averageRating is missing', () => {
42
+ expect(averageRating({ ratingSum: 1165, ratingCount: 259 })).toBe('4.50');
43
+ expect(averageRating({ averageRating: 4.32 })).toBe('4.32');
44
+ expect(averageRating({ ratingSum: 0, ratingCount: 0 })).toBe('');
45
+ expect(averageRating(undefined)).toBe('');
46
+ });
47
+
48
+ it('maps a search result element to the canonical row shape', () => {
49
+ const el = {
50
+ entityType: 'COURSE',
51
+ slug: 'agentic-ai-build-your-first-agentic-ai-system',
52
+ headline: { title: { text: 'Agentic AI: Build Your First Agentic AI System' } },
53
+ authors: [{ firstName: 'Aishwarya', lastName: 'Naresh Reganti' }],
54
+ difficultyLevel: 'INTERMEDIATE',
55
+ length: { 'com.linkedin.common.TimeSpan': { duration: 3932, unit: 'SECOND' } },
56
+ rating: { ratingSum: 1165, ratingCount: 259 },
57
+ viewerCount: 25323,
58
+ };
59
+ expect(parseRow(el, 1)).toEqual({
60
+ rank: 1,
61
+ type: 'COURSE',
62
+ title: 'Agentic AI: Build Your First Agentic AI System',
63
+ instructor: 'Aishwarya Naresh Reganti',
64
+ difficulty: 'INTERMEDIATE',
65
+ duration_sec: '3932',
66
+ rating: '4.50',
67
+ rating_count: 259,
68
+ viewers: 25323,
69
+ url: 'https://www.linkedin.com/learning/agentic-ai-build-your-first-agentic-ai-system',
70
+ });
71
+ });
72
+
73
+ it('drops rows without slug identity', () => {
74
+ const row = parseRow({ entityType: 'COURSE', headline: { title: { text: 't' } } }, 2);
75
+ expect(row).toBeNull();
76
+ });
77
+
78
+ it('escapes the URL and csrf into the fetch script as literal strings', () => {
79
+ const s = buildFetchScript('https://www.linkedin.com/learning-api/searchV2?keywords=AI', 'csrf-token-value');
80
+ expect(s).toContain('"https://www.linkedin.com/learning-api/searchV2?keywords=AI"');
81
+ expect(s).toContain('"csrf-token-value"');
82
+ expect(s).toContain("'x-restli-protocol-version': '2.0.0'");
83
+ expect(s).toContain('authRequired: true');
84
+ });
85
+
86
+ it('throws AuthRequiredError when JSESSIONID cookie is missing', async () => {
87
+ const cmd = getRegistry().get('linkedin-learning/search');
88
+ const page = makePage({ cookies: [], evaluateResult: { json: { elements: [] } } });
89
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
90
+ });
91
+
92
+ it('throws AuthRequiredError when the fetch returns 403', async () => {
93
+ const cmd = getRegistry().get('linkedin-learning/search');
94
+ const page = makePage({ evaluateResult: { authRequired: true, status: 403 } });
95
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
96
+ });
97
+
98
+ it('throws CommandExecutionError when the upstream payload is empty', async () => {
99
+ const cmd = getRegistry().get('linkedin-learning/search');
100
+ const page = makePage({ evaluateResult: { error: 'fetch failed: socket' } });
101
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
102
+ });
103
+
104
+ it('throws EmptyResultError when zero elements come back', async () => {
105
+ const cmd = getRegistry().get('linkedin-learning/search');
106
+ const page = makePage({ evaluateResult: { json: { elements: [] } } });
107
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
108
+ });
109
+
110
+ it('throws CommandExecutionError when the elements array is missing', async () => {
111
+ const cmd = getRegistry().get('linkedin-learning/search');
112
+ const page = makePage({ evaluateResult: { json: { data: {} } } });
113
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
114
+ });
115
+
116
+ it('throws CommandExecutionError when elements lack slug identity', async () => {
117
+ const cmd = getRegistry().get('linkedin-learning/search');
118
+ const page = makePage({ evaluateResult: { json: { elements: [{ headline: { title: { text: 'No slug' } } }] } } });
119
+ await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
120
+ });
121
+
122
+ it('rejects empty keywords with ArgumentError before navigation', async () => {
123
+ const cmd = getRegistry().get('linkedin-learning/search');
124
+ const page = makePage({ evaluateResult: { json: { elements: [] } } });
125
+ await expect(cmd.func(page, { keywords: ' ', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
126
+ expect(page.goto).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it('returns ranked rows when the API responds normally', async () => {
130
+ const cmd = getRegistry().get('linkedin-learning/search');
131
+ const elements = [
132
+ { entityType: 'COURSE', slug: 'a', headline: { title: { text: 'Course A' } }, authors: [{ firstName: 'Inst', lastName: 'A' }], difficultyLevel: 'BEGINNER', length: { 'com.linkedin.common.TimeSpan': { duration: 100, unit: 'SECOND' } } },
133
+ { entityType: 'COURSE', headline: { title: { text: 'No slug' } } },
134
+ { entityType: 'VIDEO', slug: 'b', headline: { title: { text: 'Video B' } } },
135
+ ];
136
+ const page = makePage({ evaluateResult: { json: { elements } } });
137
+ const rows = await cmd.func(page, { keywords: 'test', limit: 5 });
138
+ expect(rows).toHaveLength(2);
139
+ expect(rows[0].rank).toBe(1);
140
+ expect(rows[0].title).toBe('Course A');
141
+ expect(rows[1].title).toBe('Video B');
142
+ expect(rows[1].url).toBe('https://www.linkedin.com/learning/b');
143
+ });
144
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * LinkedIn Learning personalized recommendations via the
3
+ * feedRecommendationGroups carousels endpoint. The `learner` view
4
+ * returns a small set of carousels (e.g. "Top picks for you"); this
5
+ * command flattens the cards across them into a ranked list.
6
+ */
7
+ import { cli, Strategy } from '@jackwener/opencli/registry';
8
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
9
+
10
+ const DOMAIN = 'www.linkedin.com';
11
+ const MAX_LIMIT = 50;
12
+ const MAX_PER_CAROUSEL = 25;
13
+
14
+ function parseLimit(value) {
15
+ if (value === undefined || value === null || value === '') return 10;
16
+ const limit = Number(value);
17
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
18
+ throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
19
+ }
20
+ return limit;
21
+ }
22
+
23
+ function unwrapEvaluateResult(payload) {
24
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
25
+ return payload;
26
+ }
27
+
28
+ function buildFetchScript(url, csrf) {
29
+ return String.raw`(async () => {
30
+ try {
31
+ const res = await fetch(${JSON.stringify(url)}, {
32
+ credentials: 'include',
33
+ headers: {
34
+ 'csrf-token': ${JSON.stringify(csrf)},
35
+ 'x-restli-protocol-version': '2.0.0',
36
+ accept: 'application/json',
37
+ },
38
+ });
39
+ if (res.status === 401 || res.status === 403) return { authRequired: true, status: res.status };
40
+ if (!res.ok) return { error: 'HTTP ' + res.status };
41
+ return { json: await res.json() };
42
+ } catch (e) {
43
+ return { error: 'fetch failed: ' + ((e && e.message) || String(e)) };
44
+ }
45
+ })()`;
46
+ }
47
+
48
+ function parseCard(card, group, rank) {
49
+ const slug = card?.slug || '';
50
+ if (!slug) return null;
51
+ return {
52
+ rank,
53
+ group: group?.title?.text || group?.annotation || '',
54
+ type: card?.entityType || card?.localizedEntityName || '',
55
+ title: card?.title?.text || card?.headline?.title?.text || card?.headline?.text || '',
56
+ difficulty: card?.difficultyLevel || '',
57
+ viewers: card?.viewerCount ?? '',
58
+ url: slug ? `https://www.linkedin.com/learning/${slug}` : '',
59
+ };
60
+ }
61
+
62
+ cli({
63
+ site: 'linkedin-learning',
64
+ name: 'trending',
65
+ access: 'read',
66
+ description: 'Browse LinkedIn Learning recommended courses across personalized carousels',
67
+ domain: DOMAIN,
68
+ strategy: Strategy.COOKIE,
69
+ browser: true,
70
+ args: [
71
+ { name: 'limit', type: 'int', default: 10, help: `Maximum results to return (1-${MAX_LIMIT})` },
72
+ ],
73
+ columns: ['rank', 'group', 'type', 'title', 'difficulty', 'viewers', 'url'],
74
+ func: async (page, args) => {
75
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin-learning trending');
76
+ const limit = parseLimit(args.limit);
77
+
78
+ await page.goto('https://www.linkedin.com/learning/');
79
+ await page.wait(3);
80
+
81
+ const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
82
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
83
+ if (!jsession) {
84
+ throw new AuthRequiredError(DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn in the browser.');
85
+ }
86
+ const csrf = jsession.replace(/^"|"$/g, '');
87
+
88
+ const url = `https://www.linkedin.com/learning-api/feedRecommendationGroups?countPerCarousel=${MAX_PER_CAROUSEL}&q=learner`;
89
+ const result = unwrapEvaluateResult(await page.evaluate(buildFetchScript(url, csrf)));
90
+ if (result?.authRequired) {
91
+ throw new AuthRequiredError(DOMAIN, `LinkedIn Learning auth failed (HTTP ${result.status ?? ''}).`);
92
+ }
93
+ if (!result?.json) {
94
+ throw new CommandExecutionError(`LinkedIn Learning feedRecommendationGroups failed: ${result?.error ?? 'no payload'}`);
95
+ }
96
+ const groups = result.json?.elements;
97
+ if (!Array.isArray(groups)) {
98
+ throw new CommandExecutionError('LinkedIn Learning feedRecommendationGroups returned malformed payload: missing elements array');
99
+ }
100
+ const rows = [];
101
+ const seen = new Set();
102
+ let rank = 1;
103
+ let sawCards = false;
104
+ for (const group of groups) {
105
+ const carousels = Array.isArray(group?.carousels) ? group.carousels : [];
106
+ for (const carousel of carousels) {
107
+ const cards = Array.isArray(carousel?.cards) ? carousel.cards : [];
108
+ for (const card of cards) {
109
+ sawCards = true;
110
+ if (rows.length >= limit) break;
111
+ const slug = card?.slug;
112
+ if (!slug || seen.has(slug)) continue;
113
+ seen.add(slug);
114
+ const row = parseCard(card, carousel, rank);
115
+ if (!row) continue;
116
+ rows.push(row);
117
+ rank += 1;
118
+ }
119
+ if (rows.length >= limit) break;
120
+ }
121
+ if (rows.length >= limit) break;
122
+ }
123
+ if (rows.length === 0) {
124
+ if (sawCards) {
125
+ throw new CommandExecutionError('LinkedIn Learning feedRecommendationGroups returned no parseable cards with slug identity');
126
+ }
127
+ throw new EmptyResultError('LinkedIn Learning returned no personalized recommendations');
128
+ }
129
+ return rows;
130
+ },
131
+ });
132
+
133
+ export const __test__ = { parseLimit, parseCard };
@@ -0,0 +1,123 @@
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 './trending.js';
5
+
6
+ const { parseLimit, parseCard } = await import('./trending.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 trending', () => {
18
+ it('validates --limit without silent clamping', () => {
19
+ expect(parseLimit(undefined)).toBe(10);
20
+ expect(parseLimit(50)).toBe(50);
21
+ expect(() => parseLimit(0)).toThrow(ArgumentError);
22
+ expect(() => parseLimit(51)).toThrow(ArgumentError);
23
+ });
24
+
25
+ it('maps a carousel card to the canonical row shape', () => {
26
+ const card = {
27
+ entityType: 'COURSE',
28
+ slug: 'storytelling-editing',
29
+ difficultyLevel: 'BEGINNER_INTERMEDIATE',
30
+ description: { text: 'Go beyond basic video editing.' },
31
+ viewerCount: 12345,
32
+ headline: { title: { text: 'The Art of Storytelling through Editing' } },
33
+ };
34
+ const group = { annotation: 'TOP_PICKS', title: { text: 'Top picks for you' } };
35
+ expect(parseCard(card, group, 1)).toEqual({
36
+ rank: 1,
37
+ group: 'Top picks for you',
38
+ type: 'COURSE',
39
+ title: 'The Art of Storytelling through Editing',
40
+ difficulty: 'BEGINNER_INTERMEDIATE',
41
+ viewers: 12345,
42
+ url: 'https://www.linkedin.com/learning/storytelling-editing',
43
+ });
44
+ });
45
+
46
+ it('drops cards without slug identity', () => {
47
+ expect(parseCard({ title: { text: 'No slug' } }, { title: { text: 'G' } }, 1)).toBeNull();
48
+ });
49
+
50
+ it('flattens carousels and dedups cards across them', async () => {
51
+ const cmd = getRegistry().get('linkedin-learning/trending');
52
+ const page = makePage({
53
+ evaluateResult: {
54
+ json: {
55
+ elements: [{
56
+ carousels: [
57
+ {
58
+ title: { text: 'Top picks' },
59
+ cards: [
60
+ { slug: 'a', headline: { title: { text: 'Course A' } } },
61
+ { headline: { title: { text: 'No slug' } } },
62
+ { slug: 'b', headline: { title: { text: 'Course B' } } },
63
+ ],
64
+ },
65
+ {
66
+ title: { text: 'Trending in your network' },
67
+ cards: [
68
+ { slug: 'a', headline: { title: { text: 'Dup of A' } } },
69
+ { slug: 'c', headline: { title: { text: 'Course C' } } },
70
+ ],
71
+ },
72
+ ],
73
+ }],
74
+ },
75
+ },
76
+ });
77
+ const rows = await cmd.func(page, { limit: 5 });
78
+ expect(rows.map((r) => r.title)).toEqual(['Course A', 'Course B', 'Course C']);
79
+ expect(rows.map((r) => r.rank)).toEqual([1, 2, 3]);
80
+ expect(rows[0].group).toBe('Top picks');
81
+ expect(rows[2].group).toBe('Trending in your network');
82
+ });
83
+
84
+ it('respects --limit', async () => {
85
+ const cmd = getRegistry().get('linkedin-learning/trending');
86
+ const cards = Array.from({ length: 6 }, (_, i) => ({ slug: `s${i}`, headline: { title: { text: `T${i}` } } }));
87
+ const page = makePage({
88
+ evaluateResult: {
89
+ json: { elements: [{ carousels: [{ title: { text: 'G' }, cards }] }] },
90
+ },
91
+ });
92
+ const rows = await cmd.func(page, { limit: 3 });
93
+ expect(rows).toHaveLength(3);
94
+ });
95
+
96
+ it('throws AuthRequiredError when JSESSIONID is missing', async () => {
97
+ const cmd = getRegistry().get('linkedin-learning/trending');
98
+ const page = makePage({ cookies: [], evaluateResult: { json: { elements: [] } } });
99
+ await expect(cmd.func(page, { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
100
+ });
101
+
102
+ it('throws EmptyResultError when no carousels yield cards', async () => {
103
+ const cmd = getRegistry().get('linkedin-learning/trending');
104
+ const page = makePage({ evaluateResult: { json: { elements: [{ carousels: [] }] } } });
105
+ await expect(cmd.func(page, { limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
106
+ });
107
+
108
+ it('throws CommandExecutionError when the elements array is missing', async () => {
109
+ const cmd = getRegistry().get('linkedin-learning/trending');
110
+ const page = makePage({ evaluateResult: { json: { data: {} } } });
111
+ await expect(cmd.func(page, { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
112
+ });
113
+
114
+ it('throws CommandExecutionError when cards lack slug identity', async () => {
115
+ const cmd = getRegistry().get('linkedin-learning/trending');
116
+ const page = makePage({
117
+ evaluateResult: {
118
+ json: { elements: [{ carousels: [{ title: { text: 'G' }, cards: [{ headline: { title: { text: 'No slug' } } }] }] }] },
119
+ },
120
+ });
121
+ await expect(cmd.func(page, { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
122
+ });
123
+ });