@jackwener/opencli 1.7.22 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (346) hide show
  1. package/README.md +35 -194
  2. package/README.zh-CN.md +42 -260
  3. package/cli-manifest.json +8160 -4392
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/_atlassian/shared.js +577 -0
  16. package/clis/_atlassian/shared.test.js +170 -0
  17. package/clis/apple-podcasts/commands.test.js +20 -0
  18. package/clis/apple-podcasts/search.js +2 -2
  19. package/clis/barchart/greeks.js +144 -56
  20. package/clis/barchart/greeks.test.js +138 -0
  21. package/clis/bilibili/comment.js +125 -0
  22. package/clis/bilibili/comment.test.js +153 -0
  23. package/clis/bilibili/comments.js +116 -21
  24. package/clis/bilibili/comments.test.js +77 -18
  25. package/clis/bilibili/subtitle.js +76 -31
  26. package/clis/bilibili/subtitle.test.js +156 -9
  27. package/clis/bilibili/summary.js +167 -0
  28. package/clis/bilibili/summary.test.js +210 -0
  29. package/clis/bilibili/utils.js +63 -5
  30. package/clis/bilibili/utils.test.js +45 -1
  31. package/clis/booking/booking.test.js +356 -0
  32. package/clis/booking/search.js +351 -0
  33. package/clis/chatgpt/envelope.test.js +108 -0
  34. package/clis/chatgpt/image.js +2 -2
  35. package/clis/chatgpt/image.test.js +6 -0
  36. package/clis/chatgpt/utils.js +148 -41
  37. package/clis/chatgpt/utils.test.js +92 -2
  38. package/clis/chess/analyze.js +35 -0
  39. package/clis/chess/analyze.test.js +79 -0
  40. package/clis/chess/game.js +114 -0
  41. package/clis/chess/game.test.js +178 -0
  42. package/clis/chess/games.js +67 -0
  43. package/clis/chess/games.test.js +164 -0
  44. package/clis/chess/stats.js +32 -0
  45. package/clis/chess/stats.test.js +79 -0
  46. package/clis/chess/utils.js +170 -0
  47. package/clis/chess/utils.test.js +230 -0
  48. package/clis/confluence/commands.test.js +195 -0
  49. package/clis/confluence/create.js +39 -0
  50. package/clis/confluence/page.js +23 -0
  51. package/clis/confluence/search.js +34 -0
  52. package/clis/confluence/shared.js +173 -0
  53. package/clis/confluence/update.js +38 -0
  54. package/clis/douyin/_shared/browser-fetch.js +44 -20
  55. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  56. package/clis/douyin/_shared/evaluate-result.js +16 -0
  57. package/clis/douyin/_shared/tos-upload.js +105 -69
  58. package/clis/douyin/_shared/vod-upload.js +212 -0
  59. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  60. package/clis/douyin/delete.js +137 -4
  61. package/clis/douyin/delete.test.js +90 -1
  62. package/clis/douyin/hashtag.js +84 -23
  63. package/clis/douyin/hashtag.test.js +113 -0
  64. package/clis/douyin/publish-upload-id.test.js +170 -0
  65. package/clis/douyin/publish.js +88 -42
  66. package/clis/douyin/user-videos.js +9 -2
  67. package/clis/douyin/user-videos.test.js +43 -0
  68. package/clis/flomo/memos.js +228 -0
  69. package/clis/flomo/memos.test.js +144 -0
  70. package/clis/geogebra/add-circle.js +46 -0
  71. package/clis/geogebra/add-line.js +35 -0
  72. package/clis/geogebra/add-point.js +27 -0
  73. package/clis/geogebra/add-polygon.js +25 -0
  74. package/clis/geogebra/eval.js +35 -0
  75. package/clis/geogebra/geogebra.test.js +175 -0
  76. package/clis/geogebra/hexagon.js +62 -0
  77. package/clis/geogebra/info.js +72 -0
  78. package/clis/geogebra/list.js +35 -0
  79. package/clis/geogebra/triangle.js +60 -0
  80. package/clis/geogebra/utils.js +271 -0
  81. package/clis/gitee/search.js +2 -2
  82. package/clis/gitee/search.test.js +65 -0
  83. package/clis/jike/post.js +27 -17
  84. package/clis/jike/read.test.js +86 -0
  85. package/clis/jike/topic.js +32 -19
  86. package/clis/jike/user.js +33 -20
  87. package/clis/jira/attachments.js +28 -0
  88. package/clis/jira/commands.test.js +287 -0
  89. package/clis/jira/comments.js +28 -0
  90. package/clis/jira/issue.js +28 -0
  91. package/clis/jira/links.js +28 -0
  92. package/clis/jira/search.js +47 -0
  93. package/clis/jira/shared.js +256 -0
  94. package/clis/lesswrong/comments.js +1 -1
  95. package/clis/lesswrong/curated.js +1 -1
  96. package/clis/lesswrong/frontpage.js +1 -1
  97. package/clis/lesswrong/frontpage.test.js +37 -0
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/top-month.js +1 -1
  104. package/clis/lesswrong/top-week.js +1 -1
  105. package/clis/lesswrong/top-year.js +1 -1
  106. package/clis/lesswrong/top.js +1 -1
  107. package/clis/linkedin/connect.js +401 -0
  108. package/clis/linkedin/connect.test.js +213 -0
  109. package/clis/linkedin/inbox.js +234 -0
  110. package/clis/linkedin/inbox.test.js +152 -0
  111. package/clis/linkedin/job-detail.js +167 -0
  112. package/clis/linkedin/job-detail.test.js +38 -0
  113. package/clis/linkedin/jobs-preferences.js +113 -0
  114. package/clis/linkedin/jobs-preferences.test.js +43 -0
  115. package/clis/linkedin/people-search.js +262 -0
  116. package/clis/linkedin/people-search.test.js +216 -0
  117. package/clis/linkedin/post-analytics.js +74 -0
  118. package/clis/linkedin/post-analytics.test.js +40 -0
  119. package/clis/linkedin/posts-core.js +241 -0
  120. package/clis/linkedin/posts.js +22 -0
  121. package/clis/linkedin/posts.test.js +40 -0
  122. package/clis/linkedin/profile-analytics.js +104 -0
  123. package/clis/linkedin/profile-analytics.test.js +67 -0
  124. package/clis/linkedin/profile-experience.js +671 -0
  125. package/clis/linkedin/profile-experience.test.js +152 -0
  126. package/clis/linkedin/profile-projects.js +311 -0
  127. package/clis/linkedin/profile-projects.test.js +111 -0
  128. package/clis/linkedin/profile-read.js +148 -0
  129. package/clis/linkedin/profile-read.test.js +77 -0
  130. package/clis/linkedin/safe-send.js +357 -0
  131. package/clis/linkedin/safe-send.test.js +204 -0
  132. package/clis/linkedin/salesnav-inbox.js +210 -0
  133. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  134. package/clis/linkedin/salesnav-message.js +360 -0
  135. package/clis/linkedin/salesnav-message.test.js +172 -0
  136. package/clis/linkedin/salesnav-search.js +186 -0
  137. package/clis/linkedin/salesnav-search.test.js +76 -0
  138. package/clis/linkedin/salesnav-thread.js +212 -0
  139. package/clis/linkedin/salesnav-thread.test.js +79 -0
  140. package/clis/linkedin/sent-invitations.js +92 -0
  141. package/clis/linkedin/sent-invitations.test.js +62 -0
  142. package/clis/linkedin/services-read.js +213 -0
  143. package/clis/linkedin/services-read.test.js +105 -0
  144. package/clis/linkedin/shared.js +124 -0
  145. package/clis/linkedin/thread-snapshot.js +214 -0
  146. package/clis/linkedin/thread-snapshot.test.js +89 -0
  147. package/clis/linkedin/timeline.js +14 -7
  148. package/clis/linkedin-learning/course.js +138 -0
  149. package/clis/linkedin-learning/course.test.js +114 -0
  150. package/clis/linkedin-learning/search.js +155 -0
  151. package/clis/linkedin-learning/search.test.js +144 -0
  152. package/clis/linkedin-learning/trending.js +133 -0
  153. package/clis/linkedin-learning/trending.test.js +123 -0
  154. package/clis/notebooklm/add-source.js +269 -0
  155. package/clis/notebooklm/add-source.test.js +97 -0
  156. package/clis/notebooklm/create.js +76 -0
  157. package/clis/notebooklm/create.test.js +58 -0
  158. package/clis/notebooklm/generate-audio.js +91 -0
  159. package/clis/notebooklm/generate-audio.test.js +63 -0
  160. package/clis/notebooklm/generate-slides.js +106 -0
  161. package/clis/notebooklm/generate-slides.test.js +75 -0
  162. package/clis/notebooklm/open.test.js +10 -10
  163. package/clis/notebooklm/rpc.js +20 -6
  164. package/clis/notebooklm/rpc.test.js +27 -1
  165. package/clis/notebooklm/utils.js +100 -24
  166. package/clis/notebooklm/utils.test.js +60 -1
  167. package/clis/notebooklm/write-note.js +103 -0
  168. package/clis/notebooklm/write-note.test.js +70 -0
  169. package/clis/pixiv/detail.js +41 -34
  170. package/clis/pixiv/detail.test.js +93 -0
  171. package/clis/pixiv/user.js +36 -31
  172. package/clis/pixiv/user.test.js +100 -0
  173. package/clis/pixiv/utils.js +56 -7
  174. package/clis/powerchina/search.js +3 -3
  175. package/clis/powerchina/search.test.js +27 -1
  176. package/clis/reddit/extract-media.test.js +149 -0
  177. package/clis/reddit/frontpage.js +47 -9
  178. package/clis/reddit/frontpage.test.js +34 -0
  179. package/clis/reddit/home.js +31 -1
  180. package/clis/reddit/home.test.js +46 -3
  181. package/clis/reddit/hot.js +32 -1
  182. package/clis/reddit/hot.test.js +15 -1
  183. package/clis/reddit/popular.js +39 -1
  184. package/clis/reddit/popular.test.js +26 -0
  185. package/clis/reddit/saved.js +1 -1
  186. package/clis/reddit/search.js +38 -1
  187. package/clis/reddit/search.test.js +26 -0
  188. package/clis/reddit/subreddit.js +52 -7
  189. package/clis/reddit/subreddit.test.js +31 -0
  190. package/clis/reddit/subscribed.js +165 -0
  191. package/clis/reddit/subscribed.test.js +168 -0
  192. package/clis/reddit/upvoted.js +1 -1
  193. package/clis/suno/commands.test.js +188 -0
  194. package/clis/suno/download.js +140 -0
  195. package/clis/suno/download.test.js +151 -0
  196. package/clis/suno/generate.js +231 -0
  197. package/clis/suno/generate.test.js +252 -0
  198. package/clis/suno/list.js +79 -0
  199. package/clis/suno/status.js +63 -0
  200. package/clis/suno/utils.js +549 -0
  201. package/clis/suno/utils.test.js +329 -0
  202. package/clis/twitter/device-follow.js +193 -0
  203. package/clis/twitter/device-follow.test.js +287 -0
  204. package/clis/twitter/download.js +443 -73
  205. package/clis/twitter/download.test.js +457 -0
  206. package/clis/twitter/followers.js +6 -2
  207. package/clis/twitter/followers.test.js +19 -1
  208. package/clis/twitter/following.js +14 -5
  209. package/clis/twitter/following.test.js +29 -0
  210. package/clis/twitter/likes.js +12 -4
  211. package/clis/twitter/likes.test.js +26 -1
  212. package/clis/twitter/list-add.js +1 -1
  213. package/clis/twitter/list-create.js +155 -0
  214. package/clis/twitter/list-create.test.js +169 -0
  215. package/clis/twitter/list-remove.js +13 -6
  216. package/clis/twitter/list-remove.test.js +74 -0
  217. package/clis/twitter/list-tweets.js +6 -2
  218. package/clis/twitter/list-tweets.test.js +41 -1
  219. package/clis/twitter/lists.js +31 -4
  220. package/clis/twitter/lists.test.js +152 -16
  221. package/clis/twitter/notifications.js +4 -4
  222. package/clis/twitter/post.js +62 -4
  223. package/clis/twitter/post.test.js +35 -3
  224. package/clis/twitter/profile.js +81 -28
  225. package/clis/twitter/profile.test.js +113 -2
  226. package/clis/twitter/quote.js +9 -4
  227. package/clis/twitter/reply.js +13 -10
  228. package/clis/twitter/reply.test.js +41 -0
  229. package/clis/twitter/search.js +7 -3
  230. package/clis/twitter/search.test.js +41 -0
  231. package/clis/twitter/shared.js +155 -0
  232. package/clis/twitter/shared.test.js +465 -1
  233. package/clis/twitter/thread.js +10 -2
  234. package/clis/twitter/thread.test.js +58 -0
  235. package/clis/twitter/timeline.js +6 -2
  236. package/clis/twitter/timeline.test.js +2 -0
  237. package/clis/twitter/tweets.js +3 -2
  238. package/clis/twitter/tweets.test.js +1 -1
  239. package/clis/twitter/utils.js +53 -16
  240. package/clis/upwork/detail.js +132 -0
  241. package/clis/upwork/feed.js +109 -0
  242. package/clis/upwork/search.js +115 -0
  243. package/clis/upwork/upwork.test.js +566 -0
  244. package/clis/upwork/utils.js +323 -0
  245. package/clis/weibo/delete.js +172 -0
  246. package/clis/weibo/delete.test.js +94 -0
  247. package/clis/weibo/publish.js +37 -14
  248. package/clis/weibo/publish.test.js +14 -5
  249. package/clis/weibo/user-posts.js +234 -0
  250. package/clis/weibo/user-posts.test.js +92 -0
  251. package/clis/weread/book-search.js +438 -0
  252. package/clis/weread/book-search.test.js +242 -0
  253. package/clis/weread/search-regression.test.js +98 -11
  254. package/clis/weread/search.js +32 -9
  255. package/clis/weread-official/book.js +135 -0
  256. package/clis/weread-official/commands.test.js +385 -0
  257. package/clis/weread-official/discover.js +107 -0
  258. package/clis/weread-official/list-apis.js +95 -0
  259. package/clis/weread-official/notes.js +171 -0
  260. package/clis/weread-official/readdata.js +158 -0
  261. package/clis/weread-official/review.js +93 -0
  262. package/clis/weread-official/search.js +106 -0
  263. package/clis/weread-official/shelf.js +97 -0
  264. package/clis/weread-official/utils.js +293 -0
  265. package/clis/weread-official/utils.test.js +242 -0
  266. package/clis/wikipedia/trending.js +7 -3
  267. package/clis/wikipedia/trending.test.js +57 -0
  268. package/clis/xianyu/chat.js +24 -109
  269. package/clis/xianyu/chat.test.js +5 -0
  270. package/clis/xianyu/im.js +322 -0
  271. package/clis/xianyu/im.test.js +253 -0
  272. package/clis/xianyu/inbox.js +96 -0
  273. package/clis/xianyu/messages.js +91 -0
  274. package/clis/xianyu/reply.js +82 -0
  275. package/clis/xiaohongshu/creator-note-detail.js +166 -28
  276. package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
  277. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  278. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  279. package/clis/xiaohongshu/creator-notes.js +252 -2
  280. package/clis/xiaohongshu/creator-notes.test.js +90 -1
  281. package/clis/xiaohongshu/creator-stats.js +2 -1
  282. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  283. package/clis/xiaohongshu/delete-note.js +260 -0
  284. package/clis/xiaohongshu/delete-note.test.js +172 -0
  285. package/clis/xiaohongshu/download.js +97 -39
  286. package/clis/xiaohongshu/download.test.js +201 -0
  287. package/clis/xiaohongshu/publish.js +48 -8
  288. package/clis/xiaohongshu/publish.test.js +65 -10
  289. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  290. package/clis/xiaohongshu/user.js +27 -4
  291. package/clis/xiaoyuzhou/download.js +1 -1
  292. package/clis/xiaoyuzhou/transcript.js +1 -1
  293. package/clis/youdao/note.js +258 -0
  294. package/clis/youdao/note.test.js +99 -0
  295. package/clis/youtube/transcript.js +397 -24
  296. package/clis/youtube/transcript.test.js +196 -6
  297. package/clis/zhihu/answer-comments.js +280 -0
  298. package/clis/zhihu/answer-comments.test.js +287 -0
  299. package/clis/zhihu/answer-detail.js +2 -19
  300. package/clis/zhihu/answer-detail.test.js +8 -0
  301. package/clis/zhihu/collection.js +17 -16
  302. package/clis/zhihu/collection.test.js +50 -3
  303. package/clis/zhihu/download.js +1 -1
  304. package/clis/zhihu/question.js +42 -17
  305. package/clis/zhihu/question.test.js +113 -11
  306. package/clis/zhihu/search.js +195 -43
  307. package/clis/zhihu/search.test.js +198 -0
  308. package/clis/zhihu/text.js +29 -0
  309. package/clis/zhihu/text.test.js +24 -0
  310. package/dist/src/browser/errors.js +4 -2
  311. package/dist/src/browser/errors.test.js +6 -0
  312. package/dist/src/browser/network-cache.js +13 -1
  313. package/dist/src/browser/network-cache.test.js +17 -0
  314. package/dist/src/browser/page.js +30 -4
  315. package/dist/src/browser/page.test.js +42 -0
  316. package/dist/src/browser/utils.d.ts +1 -1
  317. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  318. package/dist/src/cli-argv-preprocess.js +138 -0
  319. package/dist/src/cli-argv-preprocess.test.js +79 -0
  320. package/dist/src/convention-audit.js +15 -8
  321. package/dist/src/convention-audit.test.js +21 -0
  322. package/dist/src/download/index.js +13 -1
  323. package/dist/src/download/index.test.js +23 -1
  324. package/dist/src/download/media-download.js +15 -2
  325. package/dist/src/download/media-download.test.d.ts +1 -0
  326. package/dist/src/download/media-download.test.js +112 -0
  327. package/dist/src/download/progress.js +2 -2
  328. package/dist/src/download/progress.test.js +12 -1
  329. package/dist/src/electron-apps.js +1 -1
  330. package/dist/src/electron-apps.test.js +7 -2
  331. package/dist/src/errors.d.ts +17 -0
  332. package/dist/src/errors.js +22 -0
  333. package/dist/src/external-clis.yaml +8 -0
  334. package/dist/src/main.js +14 -2
  335. package/dist/src/output.js +11 -1
  336. package/dist/src/output.test.js +6 -0
  337. package/dist/src/registry.js +1 -0
  338. package/dist/src/registry.test.js +11 -0
  339. package/dist/src/utils.d.ts +43 -0
  340. package/dist/src/utils.js +97 -0
  341. package/dist/src/utils.test.d.ts +1 -0
  342. package/dist/src/utils.test.js +155 -0
  343. package/package.json +8 -2
  344. package/scripts/silent-column-drop-baseline.json +0 -52
  345. package/scripts/typed-error-lint-baseline.json +28 -380
  346. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './sent-invitations.js';
5
+
6
+ const { buildSentInvitationsScript } = await import('./sent-invitations.js').then((m) => m.__test__);
7
+
8
+ describe('linkedin sent-invitations command', () => {
9
+ it('registers with structured columns that do not include raw blobs', () => {
10
+ const command = getRegistry().get('linkedin/sent-invitations');
11
+ expect(command).toBeDefined();
12
+ expect(command.access).toBe('read');
13
+ expect(command.columns).toEqual(['rank', 'name', 'profile_url', 'invited_date_text']);
14
+ });
15
+
16
+ it('extracts clean names and dedupes invitation cards by profile url', () => {
17
+ const dom = new JSDOM(`<!doctype html><body>
18
+ <ul>
19
+ <li>
20
+ <a href="/in/olga-magere/?miniProfileUrn=x"><span>Olga Magere</span></a>
21
+ <span>Pending</span><button>Withdraw</button><span>Sent 2 weeks ago</span>
22
+ </li>
23
+ <li>
24
+ <a href="/in/olga-magere/?trk=dup"><span>Olga Magere</span></a>
25
+ <span>Pending</span><button>Withdraw</button><span>Sent 2 weeks ago</span>
26
+ </li>
27
+ <li>
28
+ <div>Sam Founder\nSent yesterday\nWithdraw</div>
29
+ </li>
30
+ </ul>
31
+ </body>`, { url: 'https://www.linkedin.com/mynetwork/invitation-manager/sent/' });
32
+ const previousWindow = globalThis.window;
33
+ const previousDocument = globalThis.document;
34
+ const previousLocation = globalThis.location;
35
+ try {
36
+ globalThis.window = dom.window;
37
+ globalThis.document = dom.window.document;
38
+ globalThis.location = dom.window.location;
39
+ globalThis.getComputedStyle = dom.window.getComputedStyle;
40
+ Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetParent', { get() { return dom.window.document.body; }, configurable: true });
41
+ const run = Function(`return ${buildSentInvitationsScript()}`);
42
+ const result = run();
43
+ expect(result.rows).toEqual([
44
+ {
45
+ name: 'Olga Magere',
46
+ profile_url: 'https://www.linkedin.com/in/olga-magere/',
47
+ invited_date_text: 'Sent 2 weeks ago',
48
+ },
49
+ {
50
+ name: 'Sam Founder',
51
+ profile_url: '',
52
+ invited_date_text: 'Sent yesterday',
53
+ },
54
+ ]);
55
+ expect(result.rows[0]).not.toHaveProperty('raw');
56
+ } finally {
57
+ globalThis.window = previousWindow;
58
+ globalThis.document = previousDocument;
59
+ globalThis.location = previousLocation;
60
+ }
61
+ });
62
+ });
@@ -0,0 +1,213 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import {
4
+ assertLinkedInAuthenticated,
5
+ assertSafeLinkedinUrl,
6
+ normalizeWhitespace,
7
+ unwrapEvaluateResult,
8
+ } from './shared.js';
9
+
10
+ function normalizeProfileUrl(value) {
11
+ const url = assertSafeLinkedinUrl(value || 'https://www.linkedin.com/in/me/', 'profile-url', '/in/me/');
12
+ const parsed = new URL(url);
13
+ if (!/^\/in\/[^/?#]+\/?$/.test(parsed.pathname)) {
14
+ throw new CommandExecutionError('LinkedIn services-read requires a /in/<handle>/ profile URL');
15
+ }
16
+ return parsed.toString();
17
+ }
18
+
19
+ function normalizeServicesUrl(value) {
20
+ const url = assertSafeLinkedinUrl(value, 'services-url', '/services/page/');
21
+ const parsed = new URL(url);
22
+ if (!/^\/services\/page\/[^/?#]+\/?$/.test(parsed.pathname)) {
23
+ throw new CommandExecutionError('LinkedIn services-read requires a /services/page/<id>/ URL');
24
+ }
25
+ return parsed.toString();
26
+ }
27
+
28
+ function buildFindServicesUrlScript() {
29
+ return String.raw`(() => {
30
+ const link = Array.from(document.querySelectorAll('a[href*="/services/page/"]'))
31
+ .map((a) => a.href || '')
32
+ .find(Boolean);
33
+ return { services_url: link || '' };
34
+ })()`;
35
+ }
36
+
37
+ function buildServicesPageScript() {
38
+ return String.raw`(() => {
39
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
40
+ const lines = (document.body?.innerText || '').split(/\n+/).map(clean).filter(Boolean);
41
+ const unique = (items) => Array.from(new Set(items.filter(Boolean)));
42
+ const collectAfter = (label, stops) => {
43
+ const index = lines.findIndex((line) => line === label);
44
+ if (index < 0) return [];
45
+ const out = [];
46
+ for (let i = index + 1; i < lines.length; i++) {
47
+ if (stops.includes(lines[i])) break;
48
+ if (lines[i] !== label) out.push(lines[i]);
49
+ }
50
+ return unique(out);
51
+ };
52
+ return {
53
+ service_url: location.href,
54
+ page_title: clean(document.querySelector('main h1, h1')?.innerText || document.title || ''),
55
+ overview: collectAfter('Overview', ['Availability', 'Pricing', 'Services provided', 'Media', 'Reviews']).join('\n'),
56
+ availability: collectAfter('Availability', ['Pricing', 'Services provided', 'Media', 'Reviews']).join('; '),
57
+ pricing: collectAfter('Pricing', ['Services provided', 'Media', 'Reviews']).join('; '),
58
+ services_provided: collectAfter('Services provided', ['Media', 'Reviews', 'Pricing', 'Availability', 'Overview']),
59
+ };
60
+ })()`;
61
+ }
62
+
63
+ function buildMediaPageScript() {
64
+ return String.raw`(() => {
65
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
66
+ const lines = (document.body?.innerText || '').split(/\n+/).map(clean).filter(Boolean);
67
+ const start = lines.findIndex((line) => line === 'Add media');
68
+ const end = lines.findIndex((line, index) => index > start && line === 'Done');
69
+ const media_lines = start >= 0 && end > start ? lines.slice(start + 1, end) : [];
70
+ return { media_lines };
71
+ })()`;
72
+ }
73
+
74
+ function buildServicesEditScript() {
75
+ return String.raw`(() => {
76
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
77
+ const dialog = document.querySelector('dialog') || document;
78
+ const overview = dialog.querySelector('textarea')?.value || '';
79
+ const checked = Array.from(dialog.querySelectorAll('[role="checkbox"], [role="switch"], input[type="checkbox"]'))
80
+ .map((el) => ({
81
+ label: clean(el.getAttribute('aria-label') || el.innerText || el.closest('div')?.innerText || el.parentElement?.innerText || ''),
82
+ checked: el.getAttribute('aria-checked') === 'true' || el.checked === true,
83
+ }));
84
+ const radios = Array.from(dialog.querySelectorAll('[role="radio"], input[type="radio"]'))
85
+ .map((el) => ({
86
+ label: clean(el.getAttribute('aria-label') || el.innerText || el.closest('div')?.innerText || el.parentElement?.innerText || ''),
87
+ checked: el.getAttribute('aria-checked') === 'true' || el.checked === true,
88
+ }));
89
+ return {
90
+ overview,
91
+ work_locations: checked.filter((item) => item.checked && !/message|linkedin members|reviews?/i.test(item.label)).map((item) => item.label),
92
+ messages: checked.find((item) => /message|open profile/i.test(item.label))?.checked ? 'on' : 'off',
93
+ reviews_visibility: checked.find((item) => /all linkedin members/i.test(item.label))?.checked ? 'on' : 'off',
94
+ pricing: radios.find((item) => item.checked)?.label || '',
95
+ };
96
+ })()`;
97
+ }
98
+
99
+ function pairsToMedia(items) {
100
+ const lines = Array.isArray(items) ? items.map(normalizeWhitespace).filter(Boolean) : [];
101
+ const pairs = [];
102
+ for (let i = 0; i < lines.length; i += 2) {
103
+ const title = lines[i] || '';
104
+ const description = lines[i + 1] || '';
105
+ if (title) pairs.push(description ? `${title} — ${description}` : title);
106
+ }
107
+ return pairs;
108
+ }
109
+
110
+ function normalizeServices(row) {
111
+ if (!row || typeof row !== 'object') {
112
+ throw new CommandExecutionError('LinkedIn services-read returned malformed extraction payload');
113
+ }
114
+ const services = Array.isArray(row.services_provided) ? row.services_provided.map(normalizeWhitespace).filter(Boolean) : [];
115
+ const mediaItems = pairsToMedia(row.media_lines);
116
+ const publicMedia = [];
117
+ const serviceUrl = normalizeWhitespace(row.service_url);
118
+ const pageTitle = normalizeWhitespace(row.page_title);
119
+ const overview = normalizeWhitespace(row.overview);
120
+ const availability = normalizeWhitespace(row.availability);
121
+ if (!serviceUrl || (!pageTitle && !overview && services.length === 0)) {
122
+ throw new CommandExecutionError('LinkedIn services-read could not find stable Services page content');
123
+ }
124
+ return {
125
+ service_url: serviceUrl,
126
+ page_title: pageTitle,
127
+ overview,
128
+ availability,
129
+ work_locations: Array.isArray(row.work_locations) ? row.work_locations.map((item) => {
130
+ const text = normalizeWhitespace(item);
131
+ const words = text.split(' ');
132
+ if (words.length % 2 === 0) {
133
+ const half = words.length / 2;
134
+ const left = words.slice(0, half).join(' ');
135
+ if (left === words.slice(half).join(' ')) return left;
136
+ }
137
+ return text;
138
+ }).filter(Boolean).join('; ') : '',
139
+ pricing: normalizeWhitespace(row.pricing).replace(/^Pricing,\s*Select one option,\s*/i, '').replace(/,\s*required$/i, ''),
140
+ services_provided: services.join('; '),
141
+ services_count: String(services.length),
142
+ media: (mediaItems.length > 0 ? mediaItems : publicMedia).join('\n'),
143
+ media_count: String(mediaItems.length || publicMedia.length),
144
+ messages: normalizeWhitespace(row.messages),
145
+ reviews_visibility: normalizeWhitespace(row.reviews_visibility),
146
+ };
147
+ }
148
+
149
+ async function readOwnerOnlyServicesEdit(page, servicesUrl) {
150
+ const editUrl = new URL(servicesUrl);
151
+ editUrl.pathname = editUrl.pathname.replace(/\/?$/, '/edit/');
152
+ await page.goto(editUrl.toString());
153
+ await page.wait(4);
154
+ await assertLinkedInAuthenticated(page, 'LinkedIn services-read edit');
155
+ return unwrapEvaluateResult(await page.evaluate(buildServicesEditScript()));
156
+ }
157
+
158
+ cli({
159
+ site: 'linkedin',
160
+ name: 'services-read',
161
+ access: 'read',
162
+ description: 'Read LinkedIn Services page details including services, overview, availability, pricing, and media titles/descriptions',
163
+ domain: 'www.linkedin.com',
164
+ strategy: Strategy.COOKIE,
165
+ browser: true,
166
+ args: [
167
+ { name: 'profile-url', type: 'string', required: false, help: 'LinkedIn /in/<handle>/ profile URL. Defaults to /in/me/.' },
168
+ { name: 'services-url', type: 'string', required: false, help: 'LinkedIn /services/page/<id>/ URL. If omitted, it is discovered from the profile.' },
169
+ ],
170
+ columns: ['service_url', 'page_title', 'overview', 'availability', 'work_locations', 'pricing', 'services_provided', 'services_count', 'media', 'media_count', 'messages', 'reviews_visibility'],
171
+ func: async (page, args) => {
172
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin services-read');
173
+ let servicesUrl = normalizeWhitespace(args['services-url']);
174
+ const shouldReadOwnerEdit = !servicesUrl && !normalizeWhitespace(args['profile-url']);
175
+ if (servicesUrl) {
176
+ servicesUrl = normalizeServicesUrl(servicesUrl);
177
+ } else {
178
+ await page.goto(normalizeProfileUrl(args['profile-url']));
179
+ await page.wait(5);
180
+ await assertLinkedInAuthenticated(page, 'LinkedIn services-read profile');
181
+ const found = unwrapEvaluateResult(await page.evaluate(buildFindServicesUrlScript()));
182
+ servicesUrl = normalizeWhitespace(found?.services_url);
183
+ if (!servicesUrl) throw new EmptyResultError('linkedin services-read', 'No LinkedIn Services page link was found on the profile.');
184
+ servicesUrl = normalizeServicesUrl(servicesUrl);
185
+ }
186
+
187
+ await page.goto(servicesUrl);
188
+ await page.wait(5);
189
+ await assertLinkedInAuthenticated(page, 'LinkedIn services-read');
190
+ const services = unwrapEvaluateResult(await page.evaluate(buildServicesPageScript()));
191
+
192
+ const edit = shouldReadOwnerEdit ? await readOwnerOnlyServicesEdit(page, servicesUrl) : {};
193
+
194
+ let media = {};
195
+ if (shouldReadOwnerEdit) {
196
+ const mediaUrl = new URL(servicesUrl);
197
+ mediaUrl.pathname = mediaUrl.pathname.replace(/\/?$/, '/media/');
198
+ await page.goto(mediaUrl.toString());
199
+ await page.wait(4);
200
+ await assertLinkedInAuthenticated(page, 'LinkedIn services-read media');
201
+ media = unwrapEvaluateResult(await page.evaluate(buildMediaPageScript()));
202
+ }
203
+
204
+ return [normalizeServices({ ...services, ...edit, ...media })];
205
+ },
206
+ });
207
+
208
+ export const __test__ = {
209
+ normalizeProfileUrl,
210
+ normalizeServicesUrl,
211
+ pairsToMedia,
212
+ normalizeServices,
213
+ };
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './services-read.js';
5
+
6
+ const {
7
+ normalizeProfileUrl,
8
+ normalizeServicesUrl,
9
+ normalizeServices,
10
+ pairsToMedia,
11
+ } = await import('./services-read.js').then((m) => m.__test__);
12
+
13
+ describe('linkedin services-read adapter', () => {
14
+ const command = getRegistry().get('linkedin/services-read');
15
+
16
+ it('registers command shape', () => {
17
+ expect(command).toBeDefined();
18
+ expect(command.strategy).toBe('cookie');
19
+ expect(command.browser).toBe(true);
20
+ expect(command.columns).toEqual([
21
+ 'service_url',
22
+ 'page_title',
23
+ 'overview',
24
+ 'availability',
25
+ 'work_locations',
26
+ 'pricing',
27
+ 'services_provided',
28
+ 'services_count',
29
+ 'media',
30
+ 'media_count',
31
+ 'messages',
32
+ 'reviews_visibility',
33
+ ]);
34
+ });
35
+
36
+ it('normalizes profile and services URLs', () => {
37
+ expect(normalizeProfileUrl(undefined)).toBe('https://www.linkedin.com/in/me/');
38
+ expect(normalizeProfileUrl('https://www.linkedin.com/in/gauravsaxena1997/')).toBe('https://www.linkedin.com/in/gauravsaxena1997/');
39
+ expect(normalizeServicesUrl('https://www.linkedin.com/services/page/854507342066b51989/')).toBe('https://www.linkedin.com/services/page/854507342066b51989/');
40
+ });
41
+
42
+ it('rejects invalid URL shapes', () => {
43
+ expect(() => normalizeProfileUrl('https://www.linkedin.com/jobs/')).toThrow(CommandExecutionError);
44
+ expect(() => normalizeServicesUrl('https://www.linkedin.com/in/gauravsaxena1997/')).toThrow(CommandExecutionError);
45
+ });
46
+
47
+ it('pairs media title and description lines', () => {
48
+ expect(pairsToMedia(['Portfolio', 'Builds AI products', 'GitHub', 'Open source work']))
49
+ .toEqual(['Portfolio — Builds AI products', 'GitHub — Open source work']);
50
+ });
51
+
52
+ it('normalizes services payload into command columns', () => {
53
+ expect(normalizeServices({
54
+ service_url: 'https://www.linkedin.com/services/page/abc/',
55
+ page_title: 'Gaurav Services',
56
+ overview: ' Builds AI ',
57
+ availability: 'Remote',
58
+ work_locations: ['Greater Jaipur Area', 'I am available to work remotely'],
59
+ pricing: 'Pricing, Select one option, Contact for pricing, required',
60
+ services_provided: ['Web Development', 'SaaS Development'],
61
+ media_lines: ['Portfolio', 'AI products'],
62
+ messages: 'on',
63
+ reviews_visibility: 'off',
64
+ })).toEqual({
65
+ service_url: 'https://www.linkedin.com/services/page/abc/',
66
+ page_title: 'Gaurav Services',
67
+ overview: 'Builds AI',
68
+ availability: 'Remote',
69
+ work_locations: 'Greater Jaipur Area; I am available to work remotely',
70
+ pricing: 'Contact for pricing',
71
+ services_provided: 'Web Development; SaaS Development',
72
+ services_count: '2',
73
+ media: 'Portfolio — AI products',
74
+ media_count: '1',
75
+ messages: 'on',
76
+ reviews_visibility: 'off',
77
+ });
78
+ });
79
+
80
+ it('fails closed when a services page payload has no stable content', () => {
81
+ expect(() => normalizeServices({ service_url: 'https://www.linkedin.com/services/page/abc/' }))
82
+ .toThrow(CommandExecutionError);
83
+ expect(() => normalizeServices({ page_title: 'Alice Services' }))
84
+ .toThrow(CommandExecutionError);
85
+ });
86
+
87
+ it('does not require edit access when reading an explicit services URL', async () => {
88
+ const page = {
89
+ goto: vi.fn(async () => {}),
90
+ wait: vi.fn(async () => {}),
91
+ evaluate: vi.fn()
92
+ .mockResolvedValueOnce(false)
93
+ .mockResolvedValueOnce({
94
+ service_url: 'https://www.linkedin.com/services/page/abc/',
95
+ page_title: 'Alice Services',
96
+ overview: 'Builds AI',
97
+ services_provided: ['AI Consulting'],
98
+ }),
99
+ };
100
+
101
+ await expect(command.func(page, { 'services-url': 'https://www.linkedin.com/services/page/abc/' }))
102
+ .resolves.toMatchObject([{ page_title: 'Alice Services', services_count: '1' }]);
103
+ expect(page.goto.mock.calls.map(([url]) => String(url))).toEqual(['https://www.linkedin.com/services/page/abc/']);
104
+ });
105
+ });
@@ -0,0 +1,124 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+
3
+ export const LINKEDIN_DOMAIN = 'www.linkedin.com';
4
+
5
+ export function unwrapEvaluateResult(payload) {
6
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
7
+ return payload;
8
+ }
9
+
10
+ export function normalizeWhitespace(value) {
11
+ return String(value ?? '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
12
+ }
13
+
14
+ export function normalizeHttpUrl(value, base) {
15
+ const raw = normalizeWhitespace(value);
16
+ if (!raw) return '';
17
+ try {
18
+ const parsed = base ? new URL(raw, base) : new URL(raw);
19
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
20
+ if (parsed.username || parsed.password) return '';
21
+ return parsed.toString();
22
+ } catch {
23
+ return '';
24
+ }
25
+ }
26
+
27
+ export function compactRepeatedText(value) {
28
+ const text = normalizeWhitespace(value);
29
+ if (!text) return '';
30
+ if (text.length % 2 === 0) {
31
+ const half = text.length / 2;
32
+ const left = text.slice(0, half);
33
+ if (left === text.slice(half)) return left;
34
+ }
35
+ const words = text.split(' ');
36
+ if (words.length % 2 === 0) {
37
+ const half = words.length / 2;
38
+ const left = words.slice(0, half).join(' ');
39
+ if (left === words.slice(half).join(' ')) return left;
40
+ }
41
+ return text;
42
+ }
43
+
44
+ export function looksLinkedInAuthWall(value) {
45
+ const text = normalizeWhitespace(value).toLowerCase();
46
+ if (!text) return false;
47
+ return /linkedin\.com\/(?:login|checkpoint|authwall|uas)/i.test(text)
48
+ || /\b(sign in|log in|join linkedin|captcha|verification required)\b/i.test(text)
49
+ || /(请登录|登录领英|安全验证)/.test(text);
50
+ }
51
+
52
+ export function assertSafeLinkedinUrl(value, label, fallbackPath = '/') {
53
+ const raw = normalizeWhitespace(value || `https://www.linkedin.com${fallbackPath}`);
54
+ let parsed;
55
+ try {
56
+ parsed = new URL(raw, 'https://www.linkedin.com');
57
+ } catch {
58
+ throw new ArgumentError(`${label} must be a LinkedIn URL`);
59
+ }
60
+ const host = parsed.hostname.toLowerCase();
61
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port) {
62
+ throw new ArgumentError(`${label} must be an https LinkedIn URL without credentials or port`);
63
+ }
64
+ if (host !== 'linkedin.com' && host !== 'www.linkedin.com') {
65
+ throw new ArgumentError(`${label} must point to linkedin.com`);
66
+ }
67
+ return parsed.toString();
68
+ }
69
+
70
+ export function requireStringArg(args, key, label = key) {
71
+ const value = normalizeWhitespace(args?.[key]);
72
+ if (!value) throw new ArgumentError(`${label} is required`);
73
+ return value;
74
+ }
75
+
76
+ export function parseLimit(value, fallback, max) {
77
+ if (value === undefined || value === null || value === '') return fallback;
78
+ const parsed = Number(value);
79
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {
80
+ throw new ArgumentError(`--limit must be an integer between 1 and ${max}`);
81
+ }
82
+ return parsed;
83
+ }
84
+
85
+ export async function requireLinkedInCookie(page, context) {
86
+ let cookies;
87
+ try {
88
+ cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
89
+ } catch (error) {
90
+ throw new CommandExecutionError(`LinkedIn cookie lookup failed: ${error?.message || error}`);
91
+ }
92
+ if (!Array.isArray(cookies)) {
93
+ throw new CommandExecutionError('LinkedIn cookie lookup returned malformed payload');
94
+ }
95
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
96
+ if (!jsession) {
97
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, `${context} requires an active signed-in LinkedIn browser session.`);
98
+ }
99
+ return jsession.replace(/^"|"$/g, '');
100
+ }
101
+
102
+ export function buildAuthProbeScript() {
103
+ return String.raw`(() => {
104
+ const text = [
105
+ window.location.href || '',
106
+ document.title || '',
107
+ document.body ? (document.body.innerText || '').slice(0, 4000) : '',
108
+ ].join('\n');
109
+ return /linkedin\.com\/(?:login|checkpoint|authwall|uas)/i.test(text)
110
+ || /\b(sign in|log in|join linkedin|captcha|verification required)\b/i.test(text)
111
+ || /(请登录|登录领英|安全验证)/.test(text);
112
+ })()`;
113
+ }
114
+
115
+ export async function assertLinkedInAuthenticated(page, context) {
116
+ const authRequired = unwrapEvaluateResult(await page.evaluate(buildAuthProbeScript()));
117
+ if (authRequired) {
118
+ throw new AuthRequiredError(LINKEDIN_DOMAIN, `${context} requires an active signed-in LinkedIn browser session.`);
119
+ }
120
+ }
121
+
122
+ export function splitVisibleLines(text) {
123
+ return String(text || '').split(/\n+/).map(normalizeWhitespace).filter(Boolean);
124
+ }