@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
package/clis/jike/user.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { cli } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
3
  cli({
3
4
  site: 'jike',
4
5
  name: 'user',
@@ -17,14 +18,15 @@ cli({
17
18
  { name: 'limit', type: 'int', default: 20, help: 'Number of posts' },
18
19
  ],
19
20
  columns: ['id', 'content', 'type', 'likes', 'comments', 'time', 'url'],
20
- pipeline: [
21
- { navigate: 'https://m.okjike.com/users/${{ args.username }}' },
22
- { evaluate: `(() => {
21
+ func: async (page, args) => {
22
+ await page.goto(`https://m.okjike.com/users/${args.username}`);
23
+ const limit = Number(args.limit) || 20;
24
+ const data = await page.evaluate(`(() => {
25
+ const el = document.querySelector('script[type="application/json"]');
26
+ if (!el) return { ok: false, reason: 'missing-data-script' };
23
27
  try {
24
- const el = document.querySelector('script[type="application/json"]');
25
- if (!el) return [];
26
- const data = JSON.parse(el.textContent);
27
- const posts = data?.props?.pageProps?.posts || [];
28
+ const data = JSON.parse(el.textContent || '{}');
29
+ const posts = Array.isArray(data?.props?.pageProps?.posts) ? data.props.pageProps.posts : [];
28
30
  return posts.map(p => ({
29
31
  content: (p.content || '').replace(/\\n/g, ' ').slice(0, 80),
30
32
  type: p.type === 'ORIGINAL_POST' ? 'post' : p.type === 'REPOST' ? 'repost' : p.type || '',
@@ -34,19 +36,30 @@ cli({
34
36
  id: p.id || '',
35
37
  }));
36
38
  } catch (e) {
37
- return [];
39
+ return { ok: false, reason: 'parse-error', message: e?.message || String(e) };
38
40
  }
39
41
  })()
40
- ` },
41
- { map: {
42
- id: '${{ item.id }}',
43
- content: '${{ item.content }}',
44
- type: '${{ item.type }}',
45
- likes: '${{ item.likes }}',
46
- comments: '${{ item.comments }}',
47
- time: '${{ item.time }}',
48
- url: 'https://web.okjike.com/originalPost/${{ item.id }}',
49
- } },
50
- { limit: '${{ args.limit }}' },
51
- ],
42
+ `);
43
+ if (Array.isArray(data)) {
44
+ if (data.length === 0) {
45
+ throw new EmptyResultError('jike user', `No posts were returned for user ${args.username}. Confirm the username and login state.`);
46
+ }
47
+ return data.slice(0, limit).map((item) => ({
48
+ id: item.id ?? '',
49
+ content: item.content ?? '',
50
+ type: item.type ?? '',
51
+ likes: item.likes ?? 0,
52
+ comments: item.comments ?? 0,
53
+ time: item.time ?? '',
54
+ url: `https://web.okjike.com/originalPost/${item.id ?? ''}`,
55
+ }));
56
+ }
57
+ if (data?.reason === 'missing-data-script') {
58
+ throw new CommandExecutionError('Jike user page did not expose the expected data script');
59
+ }
60
+ if (data?.reason === 'parse-error') {
61
+ throw new CommandExecutionError(`Failed to parse Jike user data: ${data.message || 'unknown error'}`);
62
+ }
63
+ throw new CommandExecutionError('Jike user returned an unreadable payload');
64
+ },
52
65
  });
@@ -56,7 +56,7 @@ cli({
56
56
  rows.push({
57
57
  rank: i + 1,
58
58
  score: item.baseScore ?? 0,
59
- author: user?.displayName ?? 'Unknown',
59
+ author: user?.displayName ?? '',
60
60
  text: raw.length > 500 ? `${raw.slice(0, 500)}...` : raw,
61
61
  });
62
62
  }
@@ -22,7 +22,7 @@ cli({
22
22
  return posts.map((item, i) => ({
23
23
  rank: i + 1,
24
24
  title: item.title ?? '',
25
- author: item.user?.displayName ?? 'Unknown',
25
+ author: item.user?.displayName ?? '',
26
26
  karma: item.baseScore ?? 0,
27
27
  comments: item.commentCount ?? 0,
28
28
  url: `https://${DOMAIN}/posts/${item._id}/${item.slug}`,
@@ -22,7 +22,7 @@ cli({
22
22
  return posts.map((item, i) => ({
23
23
  rank: i + 1,
24
24
  title: item.title ?? '',
25
- author: item.user?.displayName ?? 'Unknown',
25
+ author: item.user?.displayName ?? '',
26
26
  karma: item.baseScore ?? 0,
27
27
  comments: item.commentCount ?? 0,
28
28
  url: `https://${DOMAIN}/posts/${item._id}/${item.slug}`,
@@ -0,0 +1,37 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+
4
+ const { gqlRequestMock } = vi.hoisted(() => ({ gqlRequestMock: vi.fn() }));
5
+ vi.mock('./_helpers.js', async () => {
6
+ const actual = await vi.importActual('./_helpers.js');
7
+ return { ...actual, gqlRequest: gqlRequestMock };
8
+ });
9
+
10
+ import './frontpage.js';
11
+
12
+ describe('lesswrong frontpage', () => {
13
+ beforeEach(() => {
14
+ gqlRequestMock.mockReset();
15
+ });
16
+
17
+ it('emits empty-string for missing user.displayName instead of a sentinel', async () => {
18
+ const command = getRegistry().get('lesswrong/frontpage');
19
+ expect(command?.func).toBeDefined();
20
+ gqlRequestMock.mockResolvedValueOnce({
21
+ posts: {
22
+ results: [
23
+ { _id: 'a1', slug: 'post-a', title: 'Has author', user: { displayName: 'Real Person' }, baseScore: 10, commentCount: 3 },
24
+ { _id: 'b2', slug: 'post-b', title: 'Deleted user', user: null, baseScore: 5, commentCount: 0 },
25
+ { _id: 'c3', slug: 'post-c', title: 'Missing name', user: {}, baseScore: 7, commentCount: 1 },
26
+ ],
27
+ },
28
+ });
29
+ const rows = await command.func({ limit: 3 });
30
+ expect(rows).toHaveLength(3);
31
+ expect(rows[0]).toMatchObject({ rank: 1, title: 'Has author', author: 'Real Person', karma: 10, comments: 3 });
32
+ expect(rows[1].author).toBe('');
33
+ expect(rows[1].title).toBe('Deleted user');
34
+ expect(rows[2].author).toBe('');
35
+ expect(rows[2].title).toBe('Missing name');
36
+ });
37
+ });
@@ -22,7 +22,7 @@ cli({
22
22
  return posts.map((item, i) => ({
23
23
  rank: i + 1,
24
24
  title: item.title ?? '',
25
- author: item.user?.displayName ?? 'Unknown',
25
+ author: item.user?.displayName ?? '',
26
26
  karma: item.baseScore ?? 0,
27
27
  comments: item.commentCount ?? 0,
28
28
  url: `https://${DOMAIN}/posts/${item._id}/${item.slug}`,
@@ -34,7 +34,7 @@ cli({
34
34
  return [
35
35
  {
36
36
  title: post.title ?? '',
37
- author: post.user?.displayName ?? 'Unknown',
37
+ author: post.user?.displayName ?? '',
38
38
  karma: post.baseScore ?? 0,
39
39
  comments: post.commentCount ?? 0,
40
40
  tags: (post.tags ?? []).map((tag) => tag.name ?? '').filter(Boolean).join(', '),
@@ -22,7 +22,7 @@ cli({
22
22
  return sequences.map((item, i) => ({
23
23
  rank: i + 1,
24
24
  title: item.title ?? '',
25
- author: item.user?.displayName ?? 'Unknown',
25
+ author: item.user?.displayName ?? '',
26
26
  }));
27
27
  },
28
28
  });
@@ -22,7 +22,7 @@ cli({
22
22
  return posts.map((item, i) => ({
23
23
  rank: i + 1,
24
24
  title: item.title ?? '',
25
- author: item.user?.displayName ?? 'Unknown',
25
+ author: item.user?.displayName ?? '',
26
26
  karma: item.baseScore ?? 0,
27
27
  comments: item.commentCount ?? 0,
28
28
  url: `https://${DOMAIN}/posts/${item._id}/${item.slug}`,
@@ -37,7 +37,7 @@ cli({
37
37
  return posts.map((item, i) => ({
38
38
  rank: i + 1,
39
39
  title: item.title ?? '',
40
- author: item.user?.displayName ?? 'Unknown',
40
+ author: item.user?.displayName ?? '',
41
41
  karma: item.baseScore ?? 0,
42
42
  comments: item.commentCount ?? 0,
43
43
  url: `https://${DOMAIN}/posts/${item._id}/${item.slug}`,
@@ -22,7 +22,7 @@ cli({
22
22
  return posts.map((item, i) => ({
23
23
  rank: i + 1,
24
24
  title: item.title ?? '',
25
- author: item.user?.displayName ?? 'Unknown',
25
+ author: item.user?.displayName ?? '',
26
26
  karma: item.baseScore ?? 0,
27
27
  comments: item.commentCount ?? 0,
28
28
  url: `https://${DOMAIN}/posts/${item._id}/${item.slug}`,
@@ -22,7 +22,7 @@ cli({
22
22
  return posts.map((item, i) => ({
23
23
  rank: i + 1,
24
24
  title: item.title ?? '',
25
- author: item.user?.displayName ?? 'Unknown',
25
+ author: item.user?.displayName ?? '',
26
26
  karma: item.baseScore ?? 0,
27
27
  comments: item.commentCount ?? 0,
28
28
  url: `https://${DOMAIN}/posts/${item._id}/${item.slug}`,
@@ -22,7 +22,7 @@ cli({
22
22
  return posts.map((item, i) => ({
23
23
  rank: i + 1,
24
24
  title: item.title ?? '',
25
- author: item.user?.displayName ?? 'Unknown',
25
+ author: item.user?.displayName ?? '',
26
26
  karma: item.baseScore ?? 0,
27
27
  comments: item.commentCount ?? 0,
28
28
  url: `https://${DOMAIN}/posts/${item._id}/${item.slug}`,
@@ -22,7 +22,7 @@ cli({
22
22
  return posts.map((item, i) => ({
23
23
  rank: i + 1,
24
24
  title: item.title ?? '',
25
- author: item.user?.displayName ?? 'Unknown',
25
+ author: item.user?.displayName ?? '',
26
26
  karma: item.baseScore ?? 0,
27
27
  comments: item.commentCount ?? 0,
28
28
  url: `https://${DOMAIN}/posts/${item._id}/${item.slug}`,
@@ -0,0 +1,401 @@
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 normalizeName(value) {
11
+ return normalizeWhitespace(value)
12
+ .replace(/\s*[•·]\s*(?:1st|2nd|3rd\+?|degree connection).*$/i, '')
13
+ .replace(/\s+LinkedIn.*$/i, '')
14
+ .replace(/\b(p\.?eng\.?|cpa|mba|ph\.?d\.?)\b/ig, '')
15
+ .replace(/[^\p{L}\p{N}\s.'-]+/gu, ' ')
16
+ .replace(/\s+/g, ' ')
17
+ .trim()
18
+ .toLowerCase();
19
+ }
20
+
21
+ function nameTokens(value) {
22
+ return normalizeName(value)
23
+ .replace(/[.'-]+/g, ' ')
24
+ .split(/\s+/)
25
+ .map((token) => token.trim())
26
+ .filter((token) => token.length >= 2);
27
+ }
28
+
29
+ function matchInvitationName(candidate, expected) {
30
+ const candidateName = normalizeName(candidate);
31
+ const expectedName = normalizeName(expected);
32
+ if (!candidateName || !expectedName) return false;
33
+ if (candidateName === expectedName) return true;
34
+ if (candidateName.includes(expectedName) || expectedName.includes(candidateName)) return true;
35
+ const candidateTokens = new Set(nameTokens(candidateName));
36
+ const expectedTokens = nameTokens(expectedName);
37
+ if (expectedTokens.length < 2 || candidateTokens.size < 2) return false;
38
+ const matched = expectedTokens.filter((token) => candidateTokens.has(token)).length;
39
+ return matched >= 2 && matched / expectedTokens.length >= 0.8;
40
+ }
41
+
42
+ function isLinkedInHost(hostname) {
43
+ const host = String(hostname || '').toLowerCase();
44
+ return host === 'linkedin.com' || host.endsWith('.linkedin.com');
45
+ }
46
+
47
+ function canonicalizeLinkedInProfileUrl(value) {
48
+ const raw = normalizeWhitespace(value);
49
+ if (!raw) return '';
50
+ try {
51
+ const url = new URL(raw);
52
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return '';
53
+ const match = url.pathname.match(/^\/in\/([^/]+)\/?$/i);
54
+ if (!match || !match[1]) return '';
55
+ // LinkedIn redirects country subdomains (ca./uk./...) to www.; normalize the
56
+ // host so an expected `ca.linkedin.com/in/x` matches the landed `www.linkedin.com/in/x`.
57
+ url.hostname = 'www.linkedin.com';
58
+ url.hash = '';
59
+ url.search = '';
60
+ if (!url.pathname.endsWith('/')) url.pathname += '/';
61
+ return url.toString();
62
+ }
63
+ catch {
64
+ return '';
65
+ }
66
+ }
67
+
68
+ function requireStringArg(args, key, label = key) {
69
+ const value = normalizeWhitespace(args[key]);
70
+ if (!value) throw new ArgumentError(`${label} is required`);
71
+ return value;
72
+ }
73
+
74
+ function requireLinkedInProfileUrl(value, label) {
75
+ const url = canonicalizeLinkedInProfileUrl(value);
76
+ if (!url) throw new ArgumentError(`${label} must be an exact https://www.linkedin.com/in/<profile>/ URL`);
77
+ return url;
78
+ }
79
+
80
+ function unwrapEvaluateResult(payload) {
81
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
82
+ return payload;
83
+ }
84
+
85
+ function clampNote(note) {
86
+ const value = normalizeWhitespace(note);
87
+ if (value.length > 300) throw new ArgumentError('--note must be 300 characters or fewer for LinkedIn connection requests');
88
+ return value;
89
+ }
90
+
91
+ function canonicalizeLinkedInInviteUrl(value) {
92
+ try {
93
+ const url = new URL(normalizeWhitespace(value), 'https://www.linkedin.com');
94
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return '';
95
+ if (!/^\/preload\/custom-invite\/?$/i.test(url.pathname)) return '';
96
+ url.hostname = 'www.linkedin.com';
97
+ url.hash = '';
98
+ if (!url.pathname.endsWith('/')) url.pathname += '/';
99
+ return url.toString();
100
+ }
101
+ catch {
102
+ return '';
103
+ }
104
+ }
105
+
106
+ function assessProfileSafety(probe, expectedName, expectedProfileUrl) {
107
+ const expected = normalizeWhitespace(expectedName);
108
+ const actual = normalizeWhitespace(probe?.name || '');
109
+ const expectedUrl = canonicalizeLinkedInProfileUrl(expectedProfileUrl);
110
+ const actualUrl = canonicalizeLinkedInProfileUrl(probe?.url || '');
111
+ if (probe?.authRequired) return { ok: false, safety: 'unsafe_block', connectable: null, blockReason: 'auth_required', expectedValue: expected, actualValue: actual, observedUrl: actualUrl };
112
+ if (!actual) return { ok: false, safety: 'unsafe_block', connectable: null, blockReason: 'profile_name_not_found', expectedValue: expected, actualValue: actual, observedUrl: actualUrl };
113
+ if (expected && normalizeName(actual) !== normalizeName(expected)) {
114
+ return { ok: false, safety: 'unsafe_block', connectable: null, blockReason: 'profile_name_mismatch', expectedValue: expected, actualValue: actual, observedUrl: actualUrl };
115
+ }
116
+ if (expectedUrl && actualUrl && expectedUrl !== actualUrl) {
117
+ return { ok: false, safety: 'unsafe_block', connectable: null, blockReason: 'profile_url_mismatch', expectedValue: expectedUrl, actualValue: actualUrl, observedUrl: actualUrl };
118
+ }
119
+ if (probe?.alreadyConnected) return { ok: false, safety: 'routine_non_connectable', connectable: false, blockReason: 'already_connected', expectedValue: expected, actualValue: actual, observedUrl: actualUrl };
120
+ if (probe?.pending) return { ok: false, safety: 'routine_non_connectable', connectable: false, blockReason: 'connection_pending', expectedValue: expected, actualValue: actual, observedUrl: actualUrl };
121
+ if (!probe?.connectAvailable) return { ok: false, safety: 'routine_non_connectable', connectable: false, blockReason: 'connect_button_not_found', expectedValue: expected, actualValue: actual, observedUrl: actualUrl };
122
+ return { ok: true, safety: 'connectable', connectable: true, blockReason: 'verified', expectedValue: expected, actualValue: actual, observedUrl: actualUrl };
123
+ }
124
+
125
+ function buildProfileProbeScript() {
126
+ return String.raw`(() => {
127
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
128
+ const text = document.body ? (document.body.innerText || '') : '';
129
+ const authRequired = /\b(sign in|log in|join linkedin)\b/i.test(text)
130
+ || /linkedin\.com\/(login|checkpoint|authwall)/i.test(location.href)
131
+ || /captcha|verification required/i.test(text);
132
+ const main = document.querySelector('main') || document.body;
133
+ // LinkedIn profile pages no longer expose the name in an <h1>; the heading
134
+ // markup churns, but document.title is a stable "Name | LinkedIn" pattern.
135
+ const heading = main?.querySelector('h1, .text-heading-xlarge, [class*="heading-xlarge"]');
136
+ const titleName = clean((document.title || '')
137
+ .replace(/^\(\d+\+?\)\s*/, '')
138
+ .replace(/\s*[||]\s*LinkedIn\s*$/i, ''));
139
+ const name = clean(heading?.innerText || heading?.textContent || '') || titleName;
140
+ const buttons = Array.from(document.querySelectorAll('button, [role="button"], a')).filter((el) => el.offsetParent !== null);
141
+ const buttonLabels = buttons.map((button) => clean(button.innerText || button.textContent || button.getAttribute('aria-label'))).filter(Boolean);
142
+ const lowerLabels = buttonLabels.map((label) => label.toLowerCase());
143
+ const alreadyConnected = lowerLabels.some((label) => label === 'message' || label.includes('1st degree connection'));
144
+ const pending = lowerLabels.some((label) => label === 'pending' || label.includes('pending'));
145
+ const connectAvailable = lowerLabels.some((label) => label === 'connect' || label.startsWith('connect ') || label.includes(' invite '));
146
+ // The Connect control is an <a> linking to LinkedIn's invitation route
147
+ // (/preload/custom-invite/?vanityName=...). Capture it so the sender can
148
+ // navigate straight to the invite dialog.
149
+ const connectAnchor = buttons.find((el) => el.tagName === 'A'
150
+ && /^connect$/i.test(clean(el.innerText || el.textContent || el.getAttribute('aria-label'))));
151
+ const connectHref = connectAnchor ? (connectAnchor.getAttribute('href') || '') : '';
152
+ return {
153
+ url: location.href,
154
+ title: document.title || '',
155
+ name,
156
+ authRequired,
157
+ alreadyConnected,
158
+ pending,
159
+ connectAvailable,
160
+ connectHref,
161
+ buttonLabels: buttonLabels.slice(0, 30),
162
+ bodyText: text,
163
+ };
164
+ })()`;
165
+ }
166
+
167
+ // Runs in-page on LinkedIn's invitation route (/preload/custom-invite/...),
168
+ // where the "Add a note to your invitation?" dialog is already open.
169
+
170
+ function buildSentInvitationsProbeScript(expectedName, expectedProfileUrl) {
171
+ return String.raw`(() => {
172
+ const expectedName = ${JSON.stringify(expectedName)};
173
+ const expectedUrl = ${JSON.stringify(canonicalizeLinkedInProfileUrl(expectedProfileUrl))};
174
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
175
+ const normName = (s) => clean(s)
176
+ .replace(/\s*[•·]\s*(?:1st|2nd|3rd\+?|degree connection).*$/i, '')
177
+ .replace(/\s+LinkedIn.*$/i, '')
178
+ .replace(/\b(p\.?eng\.?|cpa|mba|ph\.?d\.?)\b/ig, '')
179
+ .replace(/[^\p{L}\p{N}\s.'-]+/gu, ' ')
180
+ .replace(/\s+/g, ' ')
181
+ .trim()
182
+ .toLowerCase();
183
+ const tokens = (s) => normName(s).replace(/[.'-]+/g, ' ').split(/\s+/).map((t) => t.trim()).filter((t) => t.length >= 2);
184
+ const nameMatchesReasonably = (candidate, expected) => {
185
+ const c = normName(candidate);
186
+ const e = normName(expected);
187
+ if (!c || !e) return false;
188
+ if (c === e || c.includes(e) || e.includes(c)) return true;
189
+ const candidateTokens = new Set(tokens(c));
190
+ const expectedTokens = tokens(e);
191
+ if (expectedTokens.length < 2 || candidateTokens.size < 2) return false;
192
+ const matched = expectedTokens.filter((token) => candidateTokens.has(token)).length;
193
+ return matched >= 2 && matched / expectedTokens.length >= 0.8;
194
+ };
195
+ const canon = (value) => {
196
+ try {
197
+ const url = new URL(value, 'https://www.linkedin.com');
198
+ if (!/^\/in\/[^/]+\/?$/i.test(url.pathname)) return '';
199
+ url.protocol = 'https:';
200
+ url.hostname = 'www.linkedin.com';
201
+ url.hash = '';
202
+ url.search = '';
203
+ if (!url.pathname.endsWith('/')) url.pathname += '/';
204
+ return url.toString();
205
+ } catch { return ''; }
206
+ };
207
+ const text = document.body ? (document.body.innerText || '') : '';
208
+ const authRequired = /\b(sign in|log in|join linkedin)\b/i.test(text)
209
+ || /linkedin\.com\/(login|checkpoint|authwall)/i.test(location.href)
210
+ || /captcha|verification required/i.test(text);
211
+ if (authRequired) return { authRequired: true, found: false, matchedName: '', matchedUrl: '', visibleNames: [] };
212
+ const structuralRows = Array.from(document.querySelectorAll('li, article, [data-view-name], .mn-invitation-card'));
213
+ const linkRows = Array.from(document.querySelectorAll('a[href*="/in/"]'))
214
+ .map((a) => a.closest('li') || a.closest('[data-view-name]') || a.closest('[class*="invitation"]') || a.closest('div'))
215
+ .filter(Boolean);
216
+ const rows = Array.from(new Set([...structuralRows, ...linkRows]));
217
+ const visibleNames = [];
218
+ for (const row of rows.slice(0, 25)) {
219
+ const rowText = clean(row.innerText || row.textContent || '');
220
+ if (!rowText) continue;
221
+ const link = Array.from(row.querySelectorAll('a[href*="/in/"]'))
222
+ .map((a) => ({ href: canon(a.href || a.getAttribute('href') || ''), text: clean(a.innerText || a.textContent || '') }))
223
+ .find((a) => a.href || a.text);
224
+ const candidateName = clean(link?.text || row.querySelector('span[aria-hidden="true"], h3, h2')?.textContent || rowText.split('\n')[0]);
225
+ if (candidateName) visibleNames.push(candidateName);
226
+ const candidateUrl = link?.href || '';
227
+ const nameMatches = expectedName && candidateName && nameMatchesReasonably(candidateName, expectedName);
228
+ const urlMatches = expectedUrl && candidateUrl && candidateUrl === expectedUrl;
229
+ if (urlMatches || nameMatches) return { authRequired: false, found: true, matchedName: candidateName, matchedUrl: candidateUrl, visibleNames: visibleNames.slice(0, 20) };
230
+ }
231
+ return { authRequired: false, found: false, matchedName: '', matchedUrl: '', visibleNames: visibleNames.slice(0, 20) };
232
+ })()`;
233
+ }
234
+
235
+ function buildInviteScript(note) {
236
+ return String.raw`(async () => {
237
+ const note = ${JSON.stringify(note)};
238
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
239
+ const jitter = async (min = 450, max = 1150) => sleep(min + Math.floor(Math.random() * (max - min + 1)));
240
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
241
+ const visible = (el) => el && el.offsetParent !== null;
242
+ const label = (el) => clean(el?.innerText || el?.textContent || el?.getAttribute('aria-label'));
243
+ const dialog = () => document.querySelector('[role="dialog"]');
244
+ const dialogButton = (pattern) => {
245
+ const dlg = dialog();
246
+ if (!dlg) return null;
247
+ return Array.from(dlg.querySelectorAll('button, [role="button"]')).filter(visible)
248
+ .find((button) => pattern.test(label(button)));
249
+ };
250
+
251
+ if (!dialog()) return { ok: false, status: 'blocked', reason: 'invite_dialog_not_found' };
252
+
253
+ if (!note) {
254
+ const sendDirect = dialogButton(/^send without a note$/i) || dialogButton(/^send$/i);
255
+ if (!sendDirect) return { ok: false, status: 'blocked', reason: 'send_button_not_found' };
256
+ await jitter();
257
+ sendDirect.click();
258
+ await jitter(1400, 2400);
259
+ return { ok: true, status: 'sent', reason: 'invitation_sent_without_note' };
260
+ }
261
+
262
+ const addNote = dialogButton(/^add a note$/i);
263
+ if (!addNote) return { ok: false, status: 'blocked', reason: 'add_note_button_not_found' };
264
+ await jitter();
265
+ addNote.click();
266
+ await jitter(800, 1400);
267
+
268
+ const textarea = document.querySelector('#custom-message')
269
+ || Array.from(document.querySelectorAll('textarea')).find(visible);
270
+ if (!textarea) return { ok: false, status: 'blocked', reason: 'note_textarea_not_found' };
271
+ textarea.focus();
272
+ // React tracks textarea values through the native setter; assigning .value
273
+ // directly would leave component state (and the Send button) unchanged.
274
+ const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
275
+ nativeSetter.call(textarea, note);
276
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
277
+ textarea.dispatchEvent(new Event('change', { bubbles: true }));
278
+ await jitter(700, 1300);
279
+
280
+ const send = dialogButton(/^send$/i);
281
+ if (!send) return { ok: false, status: 'blocked', reason: 'send_button_not_found' };
282
+ if (send.disabled || send.getAttribute('aria-disabled') === 'true') {
283
+ return { ok: false, status: 'blocked', reason: 'send_button_disabled' };
284
+ }
285
+ send.click();
286
+ await jitter(1400, 2400);
287
+ return { ok: true, status: 'sent', reason: 'invitation_sent_with_note' };
288
+ })()`;
289
+ }
290
+
291
+ async function probeProfile(page) {
292
+ return unwrapEvaluateResult(await page.evaluate(buildProfileProbeScript()));
293
+ }
294
+
295
+ cli({
296
+ site: 'linkedin',
297
+ name: 'connect',
298
+ access: 'write',
299
+ description: 'Fail-closed LinkedIn connection request sender that verifies the exact profile before optionally sending a note',
300
+ domain: LINKEDIN_DOMAIN,
301
+ strategy: Strategy.UI,
302
+ browser: true,
303
+ args: [
304
+ { name: 'profile-url', type: 'string', required: true, positional: true, help: 'Exact LinkedIn profile URL to open and verify' },
305
+ { name: 'expected-name', type: 'string', required: true, help: 'Expected visible profile name' },
306
+ { name: 'note', type: 'string', required: false, default: '', help: 'Optional connection note, max 300 chars' },
307
+ { name: 'send', type: 'bool', required: false, default: false, help: 'Actually click Send. Default is dry-run verification only.' },
308
+ ],
309
+ columns: ['status', 'recipient', 'reason', 'profile_url', 'note_chars', 'connectable', 'delivery_verified', 'matched_invitation_name', 'matched_invitation_url', 'actualValue', 'blockReason', 'expectedValue', 'observedUrl', 'safety'],
310
+ func: async (page, args) => {
311
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin connect');
312
+ const profileUrl = requireLinkedInProfileUrl(requireStringArg(args, 'profile-url', '--profile-url'), '--profile-url');
313
+ const expectedName = requireStringArg(args, 'expected-name', '--expected-name');
314
+ const note = clampNote(args.note || '');
315
+
316
+ await page.goto(profileUrl);
317
+ await page.wait(6);
318
+ let probe = await probeProfile(page);
319
+ // The name resolves early (from document.title), but the profile action
320
+ // buttons (Connect / Message / Pending) render later. Keep probing until
321
+ // the action state has resolved, not merely until the name is visible.
322
+ for (let attempt = 0; attempt < 8; attempt += 1) {
323
+ const resolved = probe?.name
324
+ && (probe.connectAvailable || probe.alreadyConnected || probe.pending);
325
+ if (resolved) break;
326
+ await page.wait(2);
327
+ probe = await probeProfile(page);
328
+ }
329
+ const safety = assessProfileSafety(probe, expectedName, profileUrl);
330
+ if (safety.blockReason === 'auth_required') {
331
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn connect requires an active signed-in LinkedIn browser session.');
332
+ }
333
+ if (!safety.ok && safety.safety === 'routine_non_connectable') {
334
+ return [{ status: 'not_connectable', recipient: safety.actualValue, reason: safety.blockReason, profile_url: safety.observedUrl, note_chars: note.length, connectable: false }];
335
+ }
336
+ if (!safety.ok) {
337
+ throw new CommandExecutionError(
338
+ `LinkedIn connect blocked: ${safety.blockReason}`,
339
+ `Expected ${safety.expectedValue}; actual ${safety.actualValue || 'not_visible'} at ${safety.observedUrl || 'url_not_available'}\nButtons: ${(probe?.buttonLabels || []).join(' | ')}`,
340
+ );
341
+ }
342
+ if (!args.send) {
343
+ return [{ status: 'connectable_dry_run', recipient: safety.actualValue, reason: safety.blockReason, profile_url: safety.observedUrl, note_chars: note.length, connectable: true }];
344
+ }
345
+ const inviteHref = probe?.connectHref || '';
346
+ if (!inviteHref) {
347
+ throw new CommandExecutionError('LinkedIn connect blocked: connect_link_not_found');
348
+ }
349
+ const inviteUrl = canonicalizeLinkedInInviteUrl(inviteHref);
350
+ if (!inviteUrl) {
351
+ throw new CommandExecutionError('LinkedIn connect blocked: invalid_connect_link');
352
+ }
353
+ await page.goto(inviteUrl);
354
+ await page.wait(6);
355
+ let result = unwrapEvaluateResult(await page.evaluate(buildInviteScript(note)));
356
+ if (result?.reason === 'invite_dialog_not_found') {
357
+ await page.wait(5);
358
+ result = unwrapEvaluateResult(await page.evaluate(buildInviteScript(note)));
359
+ }
360
+ if (!result?.ok) throw new CommandExecutionError(`LinkedIn connect blocked: ${result?.reason || 'send_failed'}`);
361
+ // LinkedIn can take a few seconds after the Send click to materialize the
362
+ // new invite in /mynetwork/invitation-manager/sent/. Wait before the
363
+ // first check, then retry page loads for propagation lag.
364
+ await page.wait(8);
365
+ let sentProbe = null;
366
+ for (let attempt = 0; attempt < 3; attempt += 1) {
367
+ await page.goto('https://www.linkedin.com/mynetwork/invitation-manager/sent/');
368
+ await page.wait(attempt === 0 ? 6 : 4);
369
+ sentProbe = unwrapEvaluateResult(await page.evaluate(buildSentInvitationsProbeScript(expectedName, profileUrl)));
370
+ if (sentProbe?.found || sentProbe?.authRequired) break;
371
+ if (attempt < 2) await page.wait(5);
372
+ }
373
+ if (sentProbe?.authRequired) {
374
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn sent-invitations verification requires an active signed-in LinkedIn browser session.');
375
+ }
376
+ const verified = Boolean(sentProbe?.found);
377
+ return [{
378
+ status: verified ? 'sent_verified' : 'send_unverified',
379
+ recipient: safety.actualValue,
380
+ reason: verified ? 'sent_invitation_verified' : 'sent_invitation_not_found_after_retries',
381
+ profile_url: safety.observedUrl,
382
+ note_chars: note.length,
383
+ connectable: true,
384
+ delivery_verified: verified,
385
+ matched_invitation_name: sentProbe?.matchedName || '',
386
+ matched_invitation_url: sentProbe?.matchedUrl || '',
387
+ }];
388
+ },
389
+ });
390
+
391
+ export const __test__ = {
392
+ normalizeWhitespace,
393
+ normalizeName,
394
+ matchInvitationName,
395
+ canonicalizeLinkedInProfileUrl,
396
+ canonicalizeLinkedInInviteUrl,
397
+ unwrapEvaluateResult,
398
+ clampNote,
399
+ assessProfileSafety,
400
+ buildSentInvitationsProbeScript,
401
+ };