@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
@@ -19,6 +19,8 @@ export const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gi
19
19
 
20
20
  /** 20 MB hard cap. Twitter allows ~5MB images / 15MB GIFs; 20MB is a safety net. */
21
21
  export const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024;
22
+ const MEDIA_UPLOAD_POLL_MS = 500;
23
+ const MEDIA_UPLOAD_TIMEOUT_MS = 30_000;
22
24
 
23
25
  const CONTENT_TYPE_TO_EXTENSION = {
24
26
  'image/jpeg': '.jpg',
@@ -133,7 +135,7 @@ export async function attachComposerImage(page, absImagePath, fileInputSelector
133
135
  uploaded = true;
134
136
  } catch (err) {
135
137
  const msg = err instanceof Error ? err.message : String(err);
136
- if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
138
+ if (!isRecoverableFileInputError(msg)) {
137
139
  throw new Error(`Image upload failed: ${msg}`);
138
140
  }
139
141
  // setFileInput not supported by extension — fall through to base64 fallback.
@@ -167,7 +169,21 @@ export async function attachComposerImage(page, absImagePath, fileInputSelector
167
169
  const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} });
168
170
  dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} }));
169
171
 
170
- Object.defineProperty(input, 'files', { value: dt.files, writable: false });
172
+ let assigned = false;
173
+ try {
174
+ Object.defineProperty(input, 'files', { value: dt.files, writable: false, configurable: true });
175
+ assigned = input.files && input.files.length > 0;
176
+ } catch(e) {
177
+ // files property not redefinable — use nativeInputValueSetter trick
178
+ try {
179
+ const nativeInputFileSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'files');
180
+ if (nativeInputFileSetter && nativeInputFileSetter.set) {
181
+ nativeInputFileSetter.set.call(input, dt.files);
182
+ assigned = input.files && input.files.length > 0;
183
+ }
184
+ } catch(e2) { /* ignore */ }
185
+ }
186
+ if (!assigned) return { ok: false, error: 'Could not assign files to input' };
171
187
  input.dispatchEvent(new Event('change', { bubbles: true }));
172
188
  input.dispatchEvent(new Event('input', { bubbles: true }));
173
189
  return { ok: true };
@@ -177,23 +193,42 @@ export async function attachComposerImage(page, absImagePath, fileInputSelector
177
193
  throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`);
178
194
  }
179
195
  }
180
- await page.wait(2);
181
- const uploadState = await page.evaluate(`
182
- (() => {
183
- const previewCount = document.querySelectorAll(
184
- '[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]'
185
- ).length;
186
- const hasMedia = previewCount > 0
187
- || !!document.querySelector('[data-testid="attachments"]')
188
- || !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) =>
189
- /remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
196
+ const uploadState = await waitForComposerMediaReady(page, 1);
197
+ if (!uploadState?.ok) {
198
+ throw new Error(uploadState?.message ?? 'Image upload failed: preview did not appear.');
199
+ }
200
+ }
201
+
202
+ export function isRecoverableFileInputError(error) {
203
+ const msg = error instanceof Error ? error.message : String(error);
204
+ return /unknown action|not supported|not[-\s]?allowed|notallowederror/i.test(msg);
205
+ }
206
+
207
+ export async function waitForComposerMediaReady(page, expectedCount = 1) {
208
+ const iterations = Math.ceil(MEDIA_UPLOAD_TIMEOUT_MS / MEDIA_UPLOAD_POLL_MS);
209
+ return page.evaluate(`
210
+ (async () => {
211
+ const expected = ${JSON.stringify(expectedCount)};
212
+ for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
213
+ await new Promise(r => setTimeout(r, ${JSON.stringify(MEDIA_UPLOAD_POLL_MS)}));
214
+ const previewCount = document.querySelectorAll(
215
+ '[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="attachments"] [role="group"], [data-testid="tweetPhoto"]'
216
+ ).length;
217
+ const blobCount = document.querySelectorAll('img[src^="blob:"], video[src^="blob:"]').length;
218
+ const removeButtonCount = Array.from(document.querySelectorAll('button,[role="button"]')).filter((el) =>
219
+ /remove media|remove image|remove|编辑/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
220
+ ).length;
221
+ const explicitPreviewCount = Math.max(
222
+ previewCount,
223
+ blobCount,
224
+ removeButtonCount,
225
+ document.querySelectorAll('[data-testid="media-upload-preview"], [data-testid="card.layoutLarge.media"]').length
190
226
  );
191
- return { ok: hasMedia, previewCount };
227
+ if (explicitPreviewCount >= expected) return { ok: true, previewCount: explicitPreviewCount };
228
+ }
229
+ return { ok: false, message: 'Image upload timed out (${MEDIA_UPLOAD_TIMEOUT_MS / 1000}s).' };
192
230
  })()
193
231
  `);
194
- if (!uploadState?.ok) {
195
- throw new Error('Image upload failed: preview did not appear.');
196
- }
197
232
  }
198
233
 
199
234
  // ── Engagement scoring (P3) ────────────────────────────────────────────
@@ -280,6 +315,8 @@ export const __test__ = {
280
315
  resolveImageExtension,
281
316
  downloadRemoteImage,
282
317
  attachComposerImage,
318
+ isRecoverableFileInputError,
319
+ waitForComposerMediaReady,
283
320
  computeEngagementScore,
284
321
  applyTopByEngagement,
285
322
  ENGAGEMENT_WEIGHTS,
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Upwork job detail.
3
+ *
4
+ * Reads the full job posting (title, description, budget, experience
5
+ * level, workload, client stats, applicant count) for a given
6
+ * ciphertext id (the `~02…` form surfaced by `upwork search` /
7
+ * `upwork feed`). Unlike search/feed, the job-detail page does not
8
+ * populate `window.__NUXT__.state` for the job — it rehydrates into
9
+ * the Vuex store at `window.$nuxt.$store.state.jobDetails.{job, buyer,
10
+ * applicants, …}`. We read straight from that store.
11
+ */
12
+
13
+ import { cli, Strategy } from '@jackwener/opencli/registry';
14
+ import {
15
+ CommandExecutionError,
16
+ EmptyResultError,
17
+ AuthRequiredError,
18
+ } from '@jackwener/opencli/errors';
19
+ import {
20
+ DETAIL_COLUMNS,
21
+ buildJobUrl,
22
+ decodeExperienceLevel,
23
+ decodeWorkload,
24
+ formatBudgetFromDetail,
25
+ formatSkills,
26
+ isPlainObject,
27
+ jobType,
28
+ requireCiphertext,
29
+ stripHighlight,
30
+ unwrapBrowserResult,
31
+ } from './utils.js';
32
+
33
+ cli({
34
+ site: 'upwork',
35
+ name: 'detail',
36
+ aliases: ['job', 'view'],
37
+ access: 'read',
38
+ description: 'Read the full Upwork job posting by ciphertext id (e.g. ~022054964136512093518)',
39
+ domain: 'www.upwork.com',
40
+ strategy: Strategy.COOKIE,
41
+ browser: true,
42
+ navigateBefore: false,
43
+ args: [
44
+ { name: 'id', positional: true, required: true, help: 'Job ciphertext id (~01… / ~02…) or full /jobs/~02… URL' },
45
+ ],
46
+ columns: DETAIL_COLUMNS,
47
+ func: async (page, kwargs) => {
48
+ const id = requireCiphertext(kwargs.id);
49
+ const url = buildJobUrl(id);
50
+ await page.goto(url);
51
+ await page.wait(5);
52
+
53
+ let payload;
54
+ try {
55
+ payload = unwrapBrowserResult(await page.evaluate(`(async () => {
56
+ const haveStore = () => !!(window.$nuxt && window.$nuxt.$store && window.$nuxt.$store.state && window.$nuxt.$store.state.jobDetails && window.$nuxt.$store.state.jobDetails.job);
57
+ let ready = haveStore();
58
+ for (let i = 0; i < 30; i++) {
59
+ if (ready) break;
60
+ await new Promise(r => setTimeout(r, 500));
61
+ ready = haveStore();
62
+ }
63
+ const onLogin = /\\/(ab\\/account-security\\/login|nx\\/login)/.test(location.pathname);
64
+ const challenge = (document.title || '').toLowerCase().includes('just a moment') || !!document.querySelector('[id^="cf-"]');
65
+ if (!ready) {
66
+ return { ready, onLogin, challenge, job: null, buyer: null };
67
+ }
68
+ const s = window.$nuxt.$store.state.jobDetails;
69
+ return {
70
+ ready,
71
+ onLogin,
72
+ challenge,
73
+ job: s.job ? JSON.parse(JSON.stringify(s.job)) : null,
74
+ buyer: s.buyer ? JSON.parse(JSON.stringify(s.buyer)) : null,
75
+ };
76
+ })()`));
77
+ }
78
+ catch (e) {
79
+ throw new CommandExecutionError(`Failed to read Upwork job-detail store: ${e?.message ?? e}`, 'The Vuex store was not reachable; try again after opening Upwork in the connected browser.');
80
+ }
81
+
82
+ if (payload?.onLogin) {
83
+ throw new AuthRequiredError('upwork.com', 'Upwork redirected to login. Open https://www.upwork.com in the connected browser and sign in, then retry.');
84
+ }
85
+ if (payload?.challenge) {
86
+ throw new CommandExecutionError('Upwork served a Cloudflare challenge page', 'Open https://www.upwork.com in the connected browser and clear the challenge, then retry.');
87
+ }
88
+ if (!isPlainObject(payload)) {
89
+ throw new CommandExecutionError('Upwork detail returned an unexpected Browser Bridge payload shape');
90
+ }
91
+ if (!payload?.ready || !payload.job) {
92
+ throw new EmptyResultError('upwork detail', `No Upwork job posting found for id "${id}" (may be closed, expired, or private)`);
93
+ }
94
+ if (!isPlainObject(payload.job)) {
95
+ throw new CommandExecutionError('Upwork job-detail store had an unexpected job shape; expected an object.');
96
+ }
97
+
98
+ const job = payload.job;
99
+ const returnedCiphertext = String(job?.ciphertext ?? '').trim();
100
+ if (returnedCiphertext && returnedCiphertext !== id) {
101
+ throw new CommandExecutionError(`Upwork job-detail store returned ciphertext "${returnedCiphertext}" while reading "${id}".`);
102
+ }
103
+ const buyer = payload.buyer || {};
104
+ const stats = buyer?.stats || {};
105
+ const location = buyer?.location || {};
106
+ const category = job?.category?.name || '';
107
+ const skills = formatSkills(job);
108
+ const totalSpent = Number(stats?.totalCharges?.amount);
109
+ const totalHires = Number(stats?.totalJobsWithHires);
110
+ const score = Number(stats?.score);
111
+ const totalApplicants = Number(job?.clientActivity?.totalApplicants);
112
+
113
+ return [{
114
+ id,
115
+ title: stripHighlight(job?.title),
116
+ type: jobType(job?.type),
117
+ budget: formatBudgetFromDetail(job),
118
+ experienceLevel: decodeExperienceLevel(job?.contractorTier),
119
+ workload: decodeWorkload(job?.workload),
120
+ category,
121
+ skills,
122
+ description: String(job?.description ?? '').trim(),
123
+ clientCountry: location?.country || '',
124
+ clientSpent: Number.isFinite(totalSpent) && totalSpent > 0 ? totalSpent : null,
125
+ clientHires: Number.isFinite(totalHires) ? totalHires : null,
126
+ clientRating: Number.isFinite(score) && score > 0 ? score : null,
127
+ proposalsCount: Number.isFinite(totalApplicants) ? totalApplicants : null,
128
+ publishedOn: job?.publishTime || job?.postedOn || job?.createdOn || '',
129
+ url,
130
+ }];
131
+ },
132
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Upwork personalized feed (Best Matches / Most Recent).
3
+ *
4
+ * Reads the logged-in user's recommended-jobs feed. Two tabs are
5
+ * supported: `best-matches` (default, Upwork's relevance-ranked feed)
6
+ * and `most-recent` (chronological). Both surface the full job list in
7
+ * `window.__NUXT__.state.feed{BestMatch,MostRecent}.{jobs, paging}`.
8
+ *
9
+ * Login is required — bare visitors get redirected through the
10
+ * onboarding flow and never see the feed state.
11
+ */
12
+
13
+ import { cli, Strategy } from '@jackwener/opencli/registry';
14
+ import {
15
+ CommandExecutionError,
16
+ EmptyResultError,
17
+ AuthRequiredError,
18
+ } from '@jackwener/opencli/errors';
19
+ import {
20
+ buildFeedUrl,
21
+ feedStateKey,
22
+ isPlainObject,
23
+ jobsToListRows,
24
+ LIST_COLUMNS,
25
+ requireBoundedInt,
26
+ requireFeedTab,
27
+ unwrapBrowserResult,
28
+ } from './utils.js';
29
+
30
+ cli({
31
+ site: 'upwork',
32
+ name: 'feed',
33
+ aliases: ['best-matches'],
34
+ access: 'read',
35
+ description: 'Upwork personalized jobs feed (best-matches | most-recent) — requires login',
36
+ domain: 'www.upwork.com',
37
+ strategy: Strategy.COOKIE,
38
+ browser: true,
39
+ navigateBefore: false,
40
+ args: [
41
+ { name: 'tab', positional: true, required: false, default: 'best-matches', help: 'Feed tab: best-matches | most-recent' },
42
+ { name: 'limit', type: 'int', default: 20, help: 'Max rows to return (1-50, capped at one page)' },
43
+ ],
44
+ columns: LIST_COLUMNS,
45
+ func: async (page, kwargs) => {
46
+ const tab = requireFeedTab(kwargs.tab);
47
+ const limit = requireBoundedInt(kwargs.limit, 20, 1, 50, 'limit');
48
+ const stateKey = feedStateKey(tab);
49
+ const url = buildFeedUrl(tab);
50
+
51
+ await page.goto(url);
52
+ await page.wait(5);
53
+
54
+ let payload;
55
+ try {
56
+ payload = unwrapBrowserResult(await page.evaluate(`(async () => {
57
+ const key = ${JSON.stringify(stateKey)};
58
+ const haveState = () => !!(window.__NUXT__ && window.__NUXT__.state && window.__NUXT__.state[key]);
59
+ let ready = haveState();
60
+ for (let i = 0; i < 30; i++) {
61
+ if (ready) break;
62
+ await new Promise(r => setTimeout(r, 500));
63
+ ready = haveState();
64
+ }
65
+ const onLogin = /\\/(ab\\/account-security\\/login|nx\\/login)/.test(location.pathname);
66
+ const challenge = (document.title || '').toLowerCase().includes('just a moment') || !!document.querySelector('[id^="cf-"]');
67
+ const state = window.__NUXT__ && window.__NUXT__.state && window.__NUXT__.state[key];
68
+ return {
69
+ ready,
70
+ onLogin,
71
+ challenge,
72
+ jobsPresent: !!(state && Object.prototype.hasOwnProperty.call(state, 'jobs')),
73
+ jobs: state ? state.jobs : undefined,
74
+ paging: state && state.paging ? state.paging : null,
75
+ };
76
+ })()`));
77
+ }
78
+ catch (e) {
79
+ throw new CommandExecutionError(`Failed to read Upwork feed state: ${e?.message ?? e}`, 'The Nuxt state global was not reachable; try again after opening Upwork in the connected browser.');
80
+ }
81
+
82
+ if (payload?.onLogin) {
83
+ throw new AuthRequiredError('upwork.com', 'Upwork redirected to login. Open https://www.upwork.com in the connected browser and sign in, then retry.');
84
+ }
85
+ if (payload?.challenge) {
86
+ throw new CommandExecutionError('Upwork served a Cloudflare challenge page', 'Open https://www.upwork.com in the connected browser and clear the challenge, then retry.');
87
+ }
88
+ if (!payload?.ready) {
89
+ throw new CommandExecutionError(`Upwork feed state (window.__NUXT__.state.${stateKey}) was not present within 15s`, 'The page may not have finished hydrating, or the SSR state shape may have changed.');
90
+ }
91
+ if (!isPlainObject(payload)) {
92
+ throw new CommandExecutionError('Upwork feed returned an unexpected Browser Bridge payload shape');
93
+ }
94
+ if (!payload.jobsPresent || !Array.isArray(payload.jobs)) {
95
+ throw new CommandExecutionError(`Upwork feed state had an unexpected jobs shape; expected window.__NUXT__.state.${stateKey}.jobs to be an array.`);
96
+ }
97
+
98
+ const jobs = payload.jobs;
99
+ if (jobs.length === 0) {
100
+ throw new EmptyResultError(`upwork feed ${tab}`, `Upwork ${tab} feed is empty for the current account`);
101
+ }
102
+
103
+ const rows = jobsToListRows(jobs, { limit });
104
+ if (rows.length === 0) {
105
+ throw new CommandExecutionError('Upwork feed results did not include any job with a valid ciphertext id; cannot produce round-trippable detail rows.');
106
+ }
107
+ return rows;
108
+ },
109
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Upwork job search.
3
+ *
4
+ * Drives the public `/nx/search/jobs/?q=` page through a real browser
5
+ * session. The full search payload is embedded in `window.__NUXT__.state.
6
+ * jobsSearch.{jobs, paging}` after Nuxt SSR — we read straight from that
7
+ * global rather than DOM-scraping cards, because Upwork's card classes
8
+ * change frequently while the state shape has been stable.
9
+ */
10
+
11
+ import { cli, Strategy } from '@jackwener/opencli/registry';
12
+ import {
13
+ CommandExecutionError,
14
+ EmptyResultError,
15
+ AuthRequiredError,
16
+ } from '@jackwener/opencli/errors';
17
+ import {
18
+ buildSearchUrl,
19
+ isPlainObject,
20
+ jobsToListRows,
21
+ LIST_COLUMNS,
22
+ requireBoundedInt,
23
+ requirePositiveInt,
24
+ requireQuery,
25
+ requireSort,
26
+ unwrapBrowserResult,
27
+ } from './utils.js';
28
+
29
+ cli({
30
+ site: 'upwork',
31
+ name: 'search',
32
+ access: 'read',
33
+ description: 'Upwork keyword job search (logged-in browser session, US site)',
34
+ domain: 'www.upwork.com',
35
+ strategy: Strategy.COOKIE,
36
+ browser: true,
37
+ navigateBefore: false,
38
+ args: [
39
+ { name: 'query', positional: true, required: true, help: 'Job keyword (skill / title / company)' },
40
+ { name: 'location', type: 'string', default: '', help: 'Country/city filter (e.g. "United States", "Remote")' },
41
+ { name: 'category', type: 'string', default: '', help: 'Category uid filter (advanced; from job detail `category` slug)' },
42
+ { name: 'sort', type: 'string', default: 'recency', help: 'Sort: recency | relevance | client_total_charge | client_total_reviews' },
43
+ { name: 'page', type: 'int', default: 1, help: 'Page number (1-based)' },
44
+ { name: 'per_page', type: 'int', default: 10, help: 'Rows per page (10-50, capped at one page)' },
45
+ ],
46
+ columns: LIST_COLUMNS,
47
+ func: async (page, kwargs) => {
48
+ const query = requireQuery(kwargs.query);
49
+ const location = String(kwargs.location ?? '').trim();
50
+ const category = String(kwargs.category ?? '').trim();
51
+ const sort = requireSort(kwargs.sort);
52
+ const pageNum = requirePositiveInt(kwargs.page, 1, 'page');
53
+ const perPage = requireBoundedInt(kwargs.per_page, 10, 10, 50, 'per_page');
54
+
55
+ const url = buildSearchUrl({ query, location, category, sort, page: pageNum, perPage });
56
+ await page.goto(url);
57
+ await page.wait(4);
58
+
59
+ let payload;
60
+ try {
61
+ payload = unwrapBrowserResult(await page.evaluate(`(async () => {
62
+ const haveState = () => !!(window.__NUXT__ && window.__NUXT__.state && window.__NUXT__.state.jobsSearch);
63
+ let ready = haveState();
64
+ for (let i = 0; i < 30; i++) {
65
+ if (ready) break;
66
+ await new Promise(r => setTimeout(r, 500));
67
+ ready = haveState();
68
+ }
69
+ const onLogin = /\\/(ab\\/account-security\\/login|nx\\/login)/.test(location.pathname);
70
+ const challenge = (document.title || '').toLowerCase().includes('just a moment') || !!document.querySelector('[id^="cf-"]');
71
+ const state = window.__NUXT__ && window.__NUXT__.state && window.__NUXT__.state.jobsSearch;
72
+ return {
73
+ ready,
74
+ onLogin,
75
+ challenge,
76
+ jobsPresent: !!(state && Object.prototype.hasOwnProperty.call(state, 'jobs')),
77
+ jobs: state ? state.jobs : undefined,
78
+ paging: state && state.paging ? state.paging : null,
79
+ status: state ? state.status : null,
80
+ };
81
+ })()`));
82
+ }
83
+ catch (e) {
84
+ throw new CommandExecutionError(`Failed to read Upwork search state: ${e?.message ?? e}`, 'The Nuxt state global was not reachable; try again after opening Upwork in the connected browser.');
85
+ }
86
+
87
+ if (payload?.onLogin) {
88
+ throw new AuthRequiredError('upwork.com', 'Upwork redirected to login. Open https://www.upwork.com in the connected browser and sign in, then retry.');
89
+ }
90
+ if (payload?.challenge) {
91
+ throw new CommandExecutionError('Upwork served a Cloudflare challenge page', 'Open https://www.upwork.com in the connected browser and clear the challenge, then retry.');
92
+ }
93
+ if (!payload?.ready) {
94
+ throw new CommandExecutionError('Upwork search state (window.__NUXT__.state.jobsSearch) was not present within 15s', 'The page may not have finished hydrating, or the SSR state shape may have changed.');
95
+ }
96
+ if (!isPlainObject(payload)) {
97
+ throw new CommandExecutionError('Upwork search returned an unexpected Browser Bridge payload shape');
98
+ }
99
+ if (!payload.jobsPresent || !Array.isArray(payload.jobs)) {
100
+ throw new CommandExecutionError('Upwork search state had an unexpected jobs shape; expected window.__NUXT__.state.jobsSearch.jobs to be an array.');
101
+ }
102
+
103
+ const jobs = payload.jobs;
104
+ if (jobs.length === 0) {
105
+ throw new EmptyResultError('upwork search', `No Upwork jobs matched "${query}"${location ? ` in ${location}` : ''}`);
106
+ }
107
+
108
+ const offset = (pageNum - 1) * perPage;
109
+ const rows = jobsToListRows(jobs, { offset, limit: perPage });
110
+ if (rows.length === 0) {
111
+ throw new CommandExecutionError('Upwork search results did not include any job with a valid ciphertext id; cannot produce round-trippable detail rows.');
112
+ }
113
+ return rows;
114
+ },
115
+ });