@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,213 @@
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 './connect.js';
5
+
6
+ const {
7
+ normalizeName,
8
+ matchInvitationName,
9
+ canonicalizeLinkedInProfileUrl,
10
+ canonicalizeLinkedInInviteUrl,
11
+ unwrapEvaluateResult,
12
+ clampNote,
13
+ assessProfileSafety,
14
+ } = await import('./connect.js').then((m) => m.__test__);
15
+
16
+ function makeFakePage(probe, sendResult = { ok: true, status: 'sent', reason: 'connection_request_sent' }) {
17
+ return {
18
+ goto: vi.fn(async () => undefined),
19
+ wait: vi.fn(async () => undefined),
20
+ evaluate: vi.fn(async (script) => {
21
+ const text = String(script);
22
+ if (text.includes('custom-message') || text.includes('invite_dialog_not_found')) return sendResult;
23
+ return probe;
24
+ }),
25
+ };
26
+ }
27
+
28
+ function makeSequentialFakePage(values) {
29
+ let index = 0;
30
+ return {
31
+ goto: vi.fn(async () => undefined),
32
+ wait: vi.fn(async () => undefined),
33
+ evaluate: vi.fn(async (script) => {
34
+ const text = String(script);
35
+ if (text.includes('custom-message') || text.includes('invite_dialog_not_found')) return { ok: true, status: 'sent', reason: 'connection_request_sent' };
36
+ const value = values[Math.min(index, values.length - 1)];
37
+ index += 1;
38
+ return value;
39
+ }),
40
+ };
41
+ }
42
+
43
+ describe('linkedin connect helpers', () => {
44
+ it('normalizes names and profile URLs', () => {
45
+ expect(normalizeName('Jane Doe • 2nd degree connection')).toBe('jane doe');
46
+ expect(matchInvitationName('Jane Doe, P.Eng.', ' jane doe ')).toBe(true);
47
+ expect(matchInvitationName('Jane Q. Doe', 'Jane Doe')).toBe(true);
48
+ expect(matchInvitationName('Janet Doe', 'Jane Doe')).toBe(false);
49
+ expect(canonicalizeLinkedInProfileUrl('https://www.linkedin.com/in/jane/?mini=true#x'))
50
+ .toBe('https://www.linkedin.com/in/jane/');
51
+ expect(canonicalizeLinkedInProfileUrl('https://ca.linkedin.com/in/jane/?mini=true#x'))
52
+ .toBe('https://www.linkedin.com/in/jane/');
53
+ expect(canonicalizeLinkedInProfileUrl('https://www.linkedin.com/company/opencli/')).toBe('');
54
+ expect(canonicalizeLinkedInProfileUrl('https://evil-linkedin.com/in/jane/')).toBe('');
55
+ expect(canonicalizeLinkedInProfileUrl('http://www.linkedin.com/in/jane/')).toBe('');
56
+ });
57
+
58
+ it('only accepts LinkedIn invitation route hrefs for sending', () => {
59
+ expect(canonicalizeLinkedInInviteUrl('/preload/custom-invite/?vanityName=jane'))
60
+ .toBe('https://www.linkedin.com/preload/custom-invite/?vanityName=jane');
61
+ expect(canonicalizeLinkedInInviteUrl('https://www.linkedin.com/feed/')).toBe('');
62
+ expect(canonicalizeLinkedInInviteUrl('https://evil-linkedin.com/preload/custom-invite/?vanityName=jane')).toBe('');
63
+ });
64
+
65
+ it('unwraps browser bridge evaluate envelopes', () => {
66
+ expect(unwrapEvaluateResult({ session: 'site:linkedin:1', data: { ok: true } })).toEqual({ ok: true });
67
+ const raw = { ok: true };
68
+ expect(unwrapEvaluateResult(raw)).toBe(raw);
69
+ });
70
+
71
+ it('enforces LinkedIn note length', () => {
72
+ expect(clampNote(' hello\nthere ')).toBe('hello there');
73
+ expect(() => clampNote('x'.repeat(301))).toThrow('--note must be 300 characters or fewer');
74
+ });
75
+
76
+ it('fails closed on wrong profile name, pending state, or missing connect button', () => {
77
+ expect(assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true }, 'Janet Doe', 'https://www.linkedin.com/in/jane/').blockReason)
78
+ .toBe('profile_name_mismatch');
79
+ expect(assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', pending: true, connectAvailable: true }, 'Jane Doe', 'https://www.linkedin.com/in/jane/').blockReason)
80
+ .toBe('connection_pending');
81
+ expect(assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/' }, 'Jane Doe', 'https://www.linkedin.com/in/jane/').blockReason)
82
+ .toBe('connect_button_not_found');
83
+ });
84
+
85
+ it('classifies routine non-connectable profiles separately from unsafe blocks', () => {
86
+ expect(assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', alreadyConnected: true }, 'Jane Doe', 'https://www.linkedin.com/in/jane/'))
87
+ .toMatchObject({ ok: false, safety: 'routine_non_connectable', connectable: false, blockReason: 'already_connected' });
88
+ expect(assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', pending: true }, 'Jane Doe', 'https://www.linkedin.com/in/jane/'))
89
+ .toMatchObject({ ok: false, safety: 'routine_non_connectable', connectable: false, blockReason: 'connection_pending' });
90
+ expect(assessProfileSafety({ name: 'Wrong Person', url: 'https://www.linkedin.com/in/wrong/', connectAvailable: true }, 'Jane Doe', 'https://www.linkedin.com/in/jane/'))
91
+ .toMatchObject({ ok: false, safety: 'unsafe_block', connectable: null, blockReason: 'profile_name_mismatch' });
92
+ });
93
+
94
+ it('passes only when profile url, name, and connect affordance all match', () => {
95
+ const result = assessProfileSafety({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/?mini=true', connectAvailable: true }, 'Jane Doe', 'https://www.linkedin.com/in/jane/');
96
+ expect(result).toMatchObject({ ok: true, blockReason: 'verified', actualValue: 'Jane Doe', connectable: true });
97
+ });
98
+ });
99
+
100
+ describe('linkedin connect command', () => {
101
+ it('registers as a write command and dry-runs by default', async () => {
102
+ const command = getRegistry().get('linkedin/connect');
103
+ expect(command).toBeDefined();
104
+ expect(command.access).toBe('write');
105
+ const page = makeFakePage({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true, connectHref: '/preload/custom-invite/?vanityName=jane', buttonLabels: ['Connect'] });
106
+ const rows = await command.func(page, {
107
+ 'profile-url': 'https://www.linkedin.com/in/jane/',
108
+ 'expected-name': 'Jane Doe',
109
+ note: 'quick note',
110
+ });
111
+ expect(rows[0]).toMatchObject({ status: 'connectable_dry_run', recipient: 'Jane Doe', reason: 'verified', connectable: true });
112
+ expect(page.evaluate).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it('returns a clean not_connectable dry-run row for routine blocked states', async () => {
116
+ const command = getRegistry().get('linkedin/connect');
117
+ const page = makeFakePage({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', alreadyConnected: true, buttonLabels: ['Message'] });
118
+ const rows = await command.func(page, {
119
+ 'profile-url': 'https://www.linkedin.com/in/jane/',
120
+ 'expected-name': 'Jane Doe',
121
+ note: 'quick note',
122
+ });
123
+ expect(rows[0]).toMatchObject({ status: 'not_connectable', recipient: 'Jane Doe', reason: 'already_connected', connectable: false });
124
+ });
125
+
126
+ it('does not send when recipient verification fails', async () => {
127
+ const command = getRegistry().get('linkedin/connect');
128
+ const page = makeFakePage({ name: 'Wrong Person', url: 'https://www.linkedin.com/in/wrong/', connectAvailable: true, buttonLabels: ['Connect'] });
129
+ await expect(command.func(page, {
130
+ 'profile-url': 'https://www.linkedin.com/in/jane/',
131
+ 'expected-name': 'Jane Doe',
132
+ note: 'quick note',
133
+ send: true,
134
+ })).rejects.toBeInstanceOf(CommandExecutionError);
135
+ expect(page.evaluate).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ it('rejects non-profile URLs before navigating', async () => {
139
+ const command = getRegistry().get('linkedin/connect');
140
+ const page = makeFakePage({});
141
+
142
+ await expect(command.func(page, {
143
+ 'profile-url': 'https://www.linkedin.com/company/opencli/',
144
+ 'expected-name': 'Jane Doe',
145
+ send: true,
146
+ })).rejects.toBeInstanceOf(ArgumentError);
147
+ expect(page.goto).not.toHaveBeenCalled();
148
+ });
149
+
150
+ it('blocks send when the connect link is not LinkedIn invitation route', async () => {
151
+ const command = getRegistry().get('linkedin/connect');
152
+ const page = makeFakePage({ name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true, connectHref: 'https://www.linkedin.com/feed/', buttonLabels: ['Connect'] });
153
+
154
+ await expect(command.func(page, {
155
+ 'profile-url': 'https://www.linkedin.com/in/jane/',
156
+ 'expected-name': 'Jane Doe',
157
+ send: true,
158
+ })).rejects.toThrow('invalid_connect_link');
159
+ });
160
+
161
+ it('sends only when --send is true after verification and sent-invitations confirms delivery', async () => {
162
+ const command = getRegistry().get('linkedin/connect');
163
+ const page = makeSequentialFakePage([
164
+ { name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true, connectHref: '/preload/custom-invite/?vanityName=jane', buttonLabels: ['Connect'] },
165
+ { found: true, matchedName: 'Jane Doe', matchedUrl: 'https://www.linkedin.com/in/jane/' },
166
+ ]);
167
+ const rows = await command.func(page, {
168
+ 'profile-url': 'https://www.linkedin.com/in/jane/',
169
+ 'expected-name': 'Jane Doe',
170
+ note: 'quick note',
171
+ send: true,
172
+ });
173
+ expect(rows[0]).toMatchObject({ status: 'sent_verified', recipient: 'Jane Doe', reason: 'sent_invitation_verified', delivery_verified: true });
174
+ expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/mynetwork/invitation-manager/sent/');
175
+ });
176
+
177
+ it('retries sent-invitations verification before reporting unverified', async () => {
178
+ const command = getRegistry().get('linkedin/connect');
179
+ const page = makeSequentialFakePage([
180
+ { name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true, connectHref: '/preload/custom-invite/?vanityName=jane', buttonLabels: ['Connect'] },
181
+ { found: false, matchedName: '', matchedUrl: '', visibleNames: ['Other Person'] },
182
+ { found: false, matchedName: '', matchedUrl: '', visibleNames: ['Other Person'] },
183
+ { found: true, matchedName: 'Jane Doe, P.Eng.', matchedUrl: '' },
184
+ ]);
185
+ const rows = await command.func(page, {
186
+ 'profile-url': 'https://www.linkedin.com/in/jane/',
187
+ 'expected-name': 'Jane Doe',
188
+ note: 'quick note',
189
+ send: true,
190
+ });
191
+ expect(rows[0]).toMatchObject({ status: 'sent_verified', recipient: 'Jane Doe', reason: 'sent_invitation_verified', delivery_verified: true, matched_invitation_name: 'Jane Doe, P.Eng.' });
192
+ expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/mynetwork/invitation-manager/sent/');
193
+ expect(page.evaluate).toHaveBeenCalledTimes(5);
194
+ });
195
+
196
+ it('does not report sent when sent-invitations verification fails after retries', async () => {
197
+ const command = getRegistry().get('linkedin/connect');
198
+ const page = makeSequentialFakePage([
199
+ { name: 'Jane Doe', url: 'https://www.linkedin.com/in/jane/', connectAvailable: true, connectHref: '/preload/custom-invite/?vanityName=jane', buttonLabels: ['Connect'] },
200
+ { found: false, matchedName: '', matchedUrl: '' },
201
+ { found: false, matchedName: '', matchedUrl: '' },
202
+ { found: false, matchedName: '', matchedUrl: '' },
203
+ ]);
204
+ const rows = await command.func(page, {
205
+ 'profile-url': 'https://www.linkedin.com/in/jane/',
206
+ 'expected-name': 'Jane Doe',
207
+ note: 'quick note',
208
+ send: true,
209
+ });
210
+ expect(rows[0]).toMatchObject({ status: 'send_unverified', recipient: 'Jane Doe', reason: 'sent_invitation_not_found_after_retries', delivery_verified: false });
211
+ expect(page.evaluate).toHaveBeenCalledTimes(5);
212
+ });
213
+ });
@@ -0,0 +1,234 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+
4
+ const LINKEDIN_DOMAIN = 'linkedin.com';
5
+ const MESSAGING_URL = 'https://www.linkedin.com/messaging/';
6
+ const MIN_LIMIT = 1;
7
+ const MAX_LIMIT = 100;
8
+ const DEFAULT_LIMIT = 40;
9
+
10
+ // ── Why this command reads an API response instead of scraping the DOM ──
11
+ //
12
+ // LinkedIn's messaging UI is a realtime, virtualized SPA. Scraping the rendered
13
+ // conversation list is brittle: rows lazy-render, the list virtualizes, and the
14
+ // markup churns. Instead we let the page load /messaging/ exactly as a human
15
+ // would, which makes the page fire its own `messengerConversations` GraphQL
16
+ // call. We then re-issue that same request (URL lifted from the Performance API,
17
+ // so the rotating queryId is always current) and parse LinkedIn's normalized
18
+ // JSON. Same session, same origin, same request the page already makes.
19
+
20
+ function unwrapEvaluateResult(payload) {
21
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
22
+ return payload;
23
+ }
24
+
25
+ function threadUrl(threadId) {
26
+ return threadId ? `https://www.linkedin.com/messaging/thread/${threadId}/` : '';
27
+ }
28
+
29
+ // Runs in-page: locate the messengerConversations request the page already fired.
30
+ // Prefers the category-scoped query (the primary inbox) over the sync-token query.
31
+ function findMessagingApiUrl() {
32
+ if (/\/(login|checkpoint|authwall|uas)/i.test(location.pathname)) return { loginRequired: true };
33
+ const urls = performance.getEntriesByType('resource').map((e) => e.name);
34
+ const matches = (re) => urls.find((u) => /messengerConversations\.[a-f0-9]+/i.test(u) && re.test(u));
35
+ const url =
36
+ matches(/PRIMARY_INBOX/i) ||
37
+ matches(/conversationCategoryPredicate/i) ||
38
+ urls.find((u) => /messengerConversations\.[a-f0-9]+/i.test(u) && /mailboxUrn/i.test(u));
39
+ if (!url) return { url: null };
40
+ const mb = url.match(/mailboxUrn:(urn[^,)&]+)/i);
41
+ return { url, mailboxUrn: mb ? decodeURIComponent(mb[1]) : '' };
42
+ }
43
+
44
+ // Runs in-page: re-issue the messaging request with the session's csrf token.
45
+ async function fetchMessagingApi(url, csrf) {
46
+ try {
47
+ const res = await fetch(url, {
48
+ credentials: 'include',
49
+ headers: {
50
+ 'csrf-token': csrf,
51
+ accept: 'application/vnd.linkedin.normalized+json+2.1',
52
+ 'x-restli-protocol-version': '2.0.0',
53
+ },
54
+ });
55
+ if (res.status === 401 || res.status === 403) return { authRequired: true, error: 'HTTP ' + res.status };
56
+ if (!res.ok) return { error: 'HTTP ' + res.status };
57
+ return { json: await res.json() };
58
+ } catch (e) {
59
+ return { error: 'fetch failed: ' + ((e && e.message) || String(e)) };
60
+ }
61
+ }
62
+
63
+ // Parse LinkedIn's normalized messaging JSON into plain conversation rows.
64
+ // `included` is a flat entity array; conversations reference participants and
65
+ // messages by URN, which we resolve through a urn->entity index. Exported for
66
+ // unit testing against a captured fixture.
67
+ function parseConversations(normalized, mailboxUrn) {
68
+ if (!normalized || typeof normalized !== 'object' || Array.isArray(normalized) || !Array.isArray(normalized.included)) {
69
+ throw new CommandExecutionError('LinkedIn messaging API returned malformed normalized payload: missing included array');
70
+ }
71
+ const included = normalized.included;
72
+ const byUrn = new Map();
73
+ for (const o of included) {
74
+ if (o && o.entityUrn) byUrn.set(o.entityUrn, o);
75
+ }
76
+ const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
77
+
78
+ const participantInfo = (p) => {
79
+ if (!p) return { name: '', kind: '' };
80
+ const pt = p.participantType || {};
81
+ if (pt.organization && pt.organization.name) return { name: norm(pt.organization.name.text), kind: 'organization' };
82
+ if (pt.member) {
83
+ const fn = pt.member.firstName && pt.member.firstName.text;
84
+ const ln = pt.member.lastName && pt.member.lastName.text;
85
+ return { name: norm([fn, ln].filter(Boolean).join(' ')), kind: 'member' };
86
+ }
87
+ if (pt.agent && pt.agent.name) return { name: norm(pt.agent.name.text), kind: 'agent' };
88
+ return { name: '', kind: '' };
89
+ };
90
+
91
+ const entries = [];
92
+ for (const conv of included) {
93
+ if (!conv || conv.$type !== 'com.linkedin.messenger.Conversation') continue;
94
+ const threadId = String(conv.backendUrn || '').replace(/^urn:li:messagingThread:/, '');
95
+ if (!threadId) {
96
+ throw new CommandExecutionError('LinkedIn messaging API returned a conversation without thread id');
97
+ }
98
+
99
+ const others = [];
100
+ let counterpartyKind = '';
101
+ for (const urn of conv['*conversationParticipants'] || []) {
102
+ const p = byUrn.get(urn);
103
+ if (!p) continue;
104
+ if (mailboxUrn && p.hostIdentityUrn === mailboxUrn) continue; // exclude the inbox owner
105
+ const info = participantInfo(p);
106
+ if (info.name) {
107
+ others.push(info.name);
108
+ if (!counterpartyKind) counterpartyKind = info.kind;
109
+ }
110
+ }
111
+
112
+ const msgUrns = (conv.messages && conv.messages['*elements']) || [];
113
+ const lastMsg = byUrn.get(msgUrns[0]);
114
+ let preview = lastMsg && lastMsg.body ? norm(lastMsg.body.text) : '';
115
+ if (!preview) preview = norm(conv.descriptionText || '');
116
+
117
+ const activityMs = Number(conv.lastActivityAt || 0);
118
+ entries.push({
119
+ activityMs,
120
+ row: {
121
+ thread_id: threadId,
122
+ person_name: conv.title ? norm(conv.title) : others.join(', '),
123
+ last_message_preview: preview.slice(0, 300),
124
+ unread: Number(conv.unreadCount || 0) > 0 || conv.read === false,
125
+ counterparty_type: counterpartyKind,
126
+ category: Array.isArray(conv.categories) ? conv.categories.join(',') : '',
127
+ timestamp: activityMs ? new Date(activityMs).toISOString() : '',
128
+ },
129
+ });
130
+ }
131
+ // Most-recent first; the sort key is kept off the returned row.
132
+ entries.sort((a, b) => b.activityMs - a.activityMs);
133
+ return entries.map((entry) => entry.row);
134
+ }
135
+
136
+ cli({
137
+ site: 'linkedin',
138
+ name: 'inbox',
139
+ access: 'read',
140
+ description: 'List LinkedIn messaging inbox conversations and unread messages',
141
+ domain: 'www.linkedin.com',
142
+ strategy: Strategy.COOKIE,
143
+ browser: true,
144
+ args: [
145
+ { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: 'Maximum conversations to return (1-100)' },
146
+ { name: 'unread-only', type: 'bool', default: false, help: 'Return only conversations with unread messages' },
147
+ ],
148
+ columns: [
149
+ 'rank',
150
+ 'thread_url',
151
+ 'thread_id',
152
+ 'person_name',
153
+ 'last_message_preview',
154
+ 'unread',
155
+ 'counterparty_type',
156
+ 'category',
157
+ 'timestamp',
158
+ ],
159
+ func: async (page, kwargs) => {
160
+ // Validate --limit explicitly rather than silently clamping an out-of-range value.
161
+ let limit = DEFAULT_LIMIT;
162
+ if (kwargs.limit !== undefined && kwargs.limit !== null && kwargs.limit !== '') {
163
+ limit = Number(kwargs.limit);
164
+ if (!Number.isInteger(limit) || limit < MIN_LIMIT || limit > MAX_LIMIT) {
165
+ throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}`);
166
+ }
167
+ }
168
+ const unreadOnly = Boolean(kwargs['unread-only']);
169
+
170
+ await page.goto(MESSAGING_URL);
171
+ await page.wait(10);
172
+
173
+ // Locate the messaging API request the page fired on load; retry once if the
174
+ // SPA was slow to issue it.
175
+ let located = unwrapEvaluateResult(await page.evaluate(`(${findMessagingApiUrl.toString()})()`));
176
+ if (located && located.loginRequired) {
177
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn requires an active signed-in browser session.');
178
+ }
179
+ if (!located || !located.url) {
180
+ await page.wait(6);
181
+ located = unwrapEvaluateResult(await page.evaluate(`(${findMessagingApiUrl.toString()})()`));
182
+ }
183
+ if (!located || !located.url) {
184
+ throw new CommandExecutionError(
185
+ 'LinkedIn did not issue a messaging API request; the inbox may have failed to load.',
186
+ );
187
+ }
188
+
189
+ const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
190
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
191
+ if (!jsession) {
192
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn.');
193
+ }
194
+ const csrf = jsession.replace(/^"|"$/g, '');
195
+
196
+ // Widen the page size to the requested limit where the query supports it.
197
+ const targetUrl = located.url.replace(/count:\d+/, 'count:' + limit);
198
+ const fetched = unwrapEvaluateResult(
199
+ await page.evaluate(`(${fetchMessagingApi.toString()})(${JSON.stringify(targetUrl)}, ${JSON.stringify(csrf)})`),
200
+ );
201
+ if (fetched && fetched.authRequired) {
202
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn messaging API authentication failed: ' + fetched.error);
203
+ }
204
+ if (!fetched || fetched.error || !fetched.json) {
205
+ throw new CommandExecutionError(
206
+ 'LinkedIn messaging API returned an unexpected response: ' + ((fetched && fetched.error) || 'no data'),
207
+ );
208
+ }
209
+
210
+ let conversations = parseConversations(fetched.json, located.mailboxUrn || '');
211
+ if (unreadOnly) conversations = conversations.filter((c) => c.unread);
212
+ if (conversations.length === 0) {
213
+ if (unreadOnly) return [];
214
+ throw new EmptyResultError('linkedin inbox', 'No LinkedIn conversations were found in the inbox.');
215
+ }
216
+
217
+ return conversations.slice(0, limit).map((c, index) => ({
218
+ rank: index + 1,
219
+ thread_url: threadUrl(c.thread_id),
220
+ thread_id: c.thread_id,
221
+ person_name: c.person_name,
222
+ last_message_preview: c.last_message_preview,
223
+ unread: c.unread,
224
+ counterparty_type: c.counterparty_type,
225
+ category: c.category,
226
+ timestamp: c.timestamp,
227
+ }));
228
+ },
229
+ });
230
+
231
+ export const __test__ = {
232
+ parseConversations,
233
+ threadUrl,
234
+ };
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './inbox.js';
5
+
6
+ const { parseConversations, threadUrl } = await import('./inbox.js').then((m) => m.__test__);
7
+
8
+ const SELF = 'urn:li:fsd_profile:SELF';
9
+
10
+ // Minimal normalized messaging payload mirroring LinkedIn's real response shape:
11
+ // a flat `included` entity array where conversations reference participants and
12
+ // messages by URN.
13
+ function fixture() {
14
+ return {
15
+ included: [
16
+ {
17
+ $type: 'com.linkedin.messenger.MessagingParticipant',
18
+ entityUrn: 'urn:li:msg_messagingParticipant:SELF',
19
+ hostIdentityUrn: SELF,
20
+ participantType: { member: { firstName: { text: 'Hanzi' }, lastName: { text: 'Li' } } },
21
+ },
22
+ {
23
+ $type: 'com.linkedin.messenger.MessagingParticipant',
24
+ entityUrn: 'urn:li:msg_messagingParticipant:P1',
25
+ hostIdentityUrn: 'urn:li:fsd_profile:P1',
26
+ participantType: { member: { firstName: { text: 'Olga' }, lastName: { text: 'Magere' } } },
27
+ },
28
+ {
29
+ $type: 'com.linkedin.messenger.MessagingParticipant',
30
+ entityUrn: 'urn:li:msg_messagingParticipant:ORG',
31
+ hostIdentityUrn: 'urn:li:fsd_company:99',
32
+ participantType: { organization: { name: { text: 'American Express' } } },
33
+ },
34
+ { $type: 'com.linkedin.messenger.Message', entityUrn: 'urn:li:msg_message:M1', body: { text: 'hey, are you around this week?' } },
35
+ { $type: 'com.linkedin.messenger.Message', entityUrn: 'urn:li:msg_message:M2', body: { text: 'Sponsored offer' } },
36
+ {
37
+ $type: 'com.linkedin.messenger.Conversation',
38
+ entityUrn: 'urn:li:msg_conversation:C1',
39
+ backendUrn: 'urn:li:messagingThread:2-aaa==',
40
+ unreadCount: 2,
41
+ read: false,
42
+ categories: ['INBOX', 'PRIMARY_INBOX'],
43
+ lastActivityAt: 2000,
44
+ '*conversationParticipants': ['urn:li:msg_messagingParticipant:P1', 'urn:li:msg_messagingParticipant:SELF'],
45
+ messages: { '*elements': ['urn:li:msg_message:M1'] },
46
+ title: null,
47
+ },
48
+ {
49
+ $type: 'com.linkedin.messenger.Conversation',
50
+ entityUrn: 'urn:li:msg_conversation:C2',
51
+ backendUrn: 'urn:li:messagingThread:2-bbb==',
52
+ unreadCount: 0,
53
+ read: true,
54
+ categories: ['INBOX', 'PRIMARY_INBOX', 'INMAIL'],
55
+ lastActivityAt: 3000,
56
+ '*conversationParticipants': ['urn:li:msg_messagingParticipant:ORG', 'urn:li:msg_messagingParticipant:SELF'],
57
+ messages: { '*elements': ['urn:li:msg_message:M2'] },
58
+ title: null,
59
+ },
60
+ {
61
+ $type: 'com.linkedin.messenger.Conversation',
62
+ entityUrn: 'urn:li:msg_conversation:C3',
63
+ backendUrn: 'urn:li:messagingThread:2-ccc==',
64
+ unreadCount: 0,
65
+ read: true,
66
+ categories: ['INBOX', 'PRIMARY_INBOX'],
67
+ lastActivityAt: 1000,
68
+ '*conversationParticipants': ['urn:li:msg_messagingParticipant:P1', 'urn:li:msg_messagingParticipant:SELF'],
69
+ messages: { '*elements': [] },
70
+ title: 'Cohort 2 group',
71
+ },
72
+ ],
73
+ };
74
+ }
75
+
76
+ describe('linkedin inbox adapter', () => {
77
+ const command = getRegistry().get('linkedin/inbox');
78
+
79
+ it('registers the command with the expected shape', () => {
80
+ expect(command).toBeDefined();
81
+ expect(command.site).toBe('linkedin');
82
+ expect(command.name).toBe('inbox');
83
+ expect(command.domain).toBe('www.linkedin.com');
84
+ expect(command.strategy).toBe('cookie');
85
+ expect(command.browser).toBe(true);
86
+ expect(typeof command.func).toBe('function');
87
+ });
88
+
89
+ it('exposes channel-safe structured columns', () => {
90
+ expect(command.columns).toEqual(
91
+ expect.arrayContaining([
92
+ 'thread_url',
93
+ 'thread_id',
94
+ 'person_name',
95
+ 'last_message_preview',
96
+ 'unread',
97
+ 'counterparty_type',
98
+ 'category',
99
+ 'timestamp',
100
+ ]),
101
+ );
102
+ });
103
+
104
+ it('builds a thread URL from a thread id', () => {
105
+ expect(threadUrl('2-aaa==')).toBe('https://www.linkedin.com/messaging/thread/2-aaa==/');
106
+ expect(threadUrl('')).toBe('');
107
+ });
108
+
109
+ it('parses conversations and sorts them by most recent activity', () => {
110
+ const rows = parseConversations(fixture(), SELF);
111
+ expect(rows).toHaveLength(3);
112
+ expect(rows.map((r) => r.thread_id)).toEqual(['2-bbb==', '2-aaa==', '2-ccc==']);
113
+ });
114
+
115
+ it('resolves the member counterparty, excludes the inbox owner, and reports unread state', () => {
116
+ const c1 = parseConversations(fixture(), SELF).find((r) => r.thread_id === '2-aaa==');
117
+ expect(c1.person_name).toBe('Olga Magere');
118
+ expect(c1.counterparty_type).toBe('member');
119
+ expect(c1.unread).toBe(true);
120
+ expect(c1.last_message_preview).toBe('hey, are you around this week?');
121
+ });
122
+
123
+ it('flags organization counterparties and read conversations', () => {
124
+ const c2 = parseConversations(fixture(), SELF).find((r) => r.thread_id === '2-bbb==');
125
+ expect(c2.person_name).toBe('American Express');
126
+ expect(c2.counterparty_type).toBe('organization');
127
+ expect(c2.unread).toBe(false);
128
+ expect(c2.category).toBe('INBOX,PRIMARY_INBOX,INMAIL');
129
+ });
130
+
131
+ it('uses the group title as the conversation name', () => {
132
+ const c3 = parseConversations(fixture(), SELF).find((r) => r.thread_id === '2-ccc==');
133
+ expect(c3.person_name).toBe('Cohort 2 group');
134
+ });
135
+
136
+ it('returns an empty array when a valid payload has no conversations', () => {
137
+ expect(parseConversations({ included: [] }, SELF)).toEqual([]);
138
+ });
139
+
140
+ it('fails typed when the normalized payload shape is malformed', () => {
141
+ expect(() => parseConversations({}, SELF)).toThrow(CommandExecutionError);
142
+ expect(() => parseConversations(null, SELF)).toThrow(CommandExecutionError);
143
+ const malformed = fixture();
144
+ malformed.included.push({
145
+ $type: 'com.linkedin.messenger.Conversation',
146
+ entityUrn: 'urn:li:msg_conversation:MALFORMED',
147
+ '*conversationParticipants': [],
148
+ messages: { '*elements': [] },
149
+ });
150
+ expect(() => parseConversations(malformed, SELF)).toThrow(CommandExecutionError);
151
+ });
152
+ });