@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,323 @@
1
+ /**
2
+ * Upwork adapter utilities.
3
+ *
4
+ * Upwork is a Nuxt (Vue) SSR app behind Cloudflare. Every adapter runs
5
+ * through the user's logged-in browser session (Strategy.COOKIE,
6
+ * browser: true) because bare fetches hit a `__cf_bm` challenge and
7
+ * because most surfaces only render data for an authenticated user.
8
+ *
9
+ * The list pages (search, best-matches feed) ship their full result
10
+ * payload inside `window.__NUXT__.state` — we read straight from that
11
+ * global instead of DOM-scraping rendered cards. Job detail uses the
12
+ * Vuex store (`window.$nuxt.$store.state.jobDetails.*`). All helpers
13
+ * below are pure (arg validation, decoders, URL builders, row mappers)
14
+ * so they stay unit-testable without a browser.
15
+ */
16
+
17
+ import {
18
+ ArgumentError,
19
+ CommandExecutionError,
20
+ } from '@jackwener/opencli/errors';
21
+
22
+ export const UPWORK_ORIGIN = 'https://www.upwork.com';
23
+
24
+ const CIPHERTEXT_PATTERN = /^~0[12]\d{15,21}$/;
25
+
26
+ const FEED_TABS = {
27
+ 'best-matches': { path: '/nx/find-work/best-matches', state: 'feedBestMatch' },
28
+ 'most-recent': { path: '/nx/find-work/most-recent', state: 'feedMostRecent' },
29
+ };
30
+
31
+ const SORT_VALUES = new Set(['recency', 'relevance', 'client_total_charge', 'client_total_reviews']);
32
+
33
+ export function unwrapBrowserResult(value) {
34
+ if (value && typeof value === 'object' && !Array.isArray(value) && 'session' in value && 'data' in value) {
35
+ return value.data;
36
+ }
37
+ return value;
38
+ }
39
+
40
+ export function isPlainObject(value) {
41
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
42
+ }
43
+
44
+ function coerceInt(value) {
45
+ if (value === undefined || value === null || value === '') return NaN;
46
+ const n = typeof value === 'number' ? value : Number(value);
47
+ return Number.isFinite(n) && Number.isInteger(n) ? n : NaN;
48
+ }
49
+
50
+ export function requireQuery(value, label = 'query') {
51
+ const q = String(value ?? '').trim();
52
+ if (!q) throw new ArgumentError(`upwork ${label} cannot be empty`);
53
+ return q;
54
+ }
55
+
56
+ export function requirePositiveInt(value, defaultValue, label) {
57
+ const raw = value ?? defaultValue;
58
+ const n = coerceInt(raw);
59
+ if (!Number.isInteger(n) || n <= 0) {
60
+ throw new ArgumentError(`upwork ${label} must be a positive integer`);
61
+ }
62
+ return n;
63
+ }
64
+
65
+ export function requireBoundedInt(value, defaultValue, min, max, label) {
66
+ const n = requirePositiveInt(value, defaultValue, label);
67
+ if (n < min) throw new ArgumentError(`upwork ${label} must be >= ${min}`);
68
+ if (n > max) throw new ArgumentError(`upwork ${label} must be <= ${max}`);
69
+ return n;
70
+ }
71
+
72
+ /**
73
+ * Upwork job ids are the ciphertext form starting with `~01` or `~02`
74
+ * (the encoded uid surfaced everywhere in URLs and search results).
75
+ * Accepts a bare ciphertext or a full `/jobs/~02…` URL.
76
+ */
77
+ export function requireCiphertext(value) {
78
+ let id = String(value ?? '').trim();
79
+ if (!id) throw new ArgumentError('upwork job id is required');
80
+ const urlMatch = id.match(/~0[12]\d+/);
81
+ if (urlMatch) id = urlMatch[0];
82
+ if (!CIPHERTEXT_PATTERN.test(id)) {
83
+ throw new ArgumentError(`upwork job id "${value}" is not a valid ciphertext (expected ~01… or ~02… followed by digits)`);
84
+ }
85
+ return id;
86
+ }
87
+
88
+ export function requireFeedTab(value, defaultValue = 'best-matches') {
89
+ const v = String(value ?? defaultValue).trim().toLowerCase();
90
+ if (!FEED_TABS[v]) {
91
+ throw new ArgumentError(`upwork tab must be one of ${Object.keys(FEED_TABS).join(' / ')}, got "${value}"`);
92
+ }
93
+ return v;
94
+ }
95
+
96
+ export function requireSort(value, defaultValue = 'recency') {
97
+ const v = String(value ?? defaultValue).trim().toLowerCase();
98
+ if (!SORT_VALUES.has(v)) {
99
+ throw new ArgumentError(`upwork sort must be one of ${Array.from(SORT_VALUES).join(' / ')}, got "${value}"`);
100
+ }
101
+ return v;
102
+ }
103
+
104
+ /**
105
+ * Build the Upwork search URL. Only forwards filters the user actually
106
+ * supplied so the URL stays canonical and round-trippable.
107
+ */
108
+ export function buildSearchUrl({ query, location, category, sort, page, perPage }) {
109
+ const params = new URLSearchParams();
110
+ params.set('q', query);
111
+ if (location) params.set('location', location);
112
+ if (category) params.set('category2_uid', category);
113
+ if (sort && sort !== 'recency') params.set('sort', sort);
114
+ if (perPage && perPage !== 10) params.set('per_page', String(perPage));
115
+ if (page && page > 1) params.set('page', String(page));
116
+ return `${UPWORK_ORIGIN}/nx/search/jobs/?${params.toString()}`;
117
+ }
118
+
119
+ export function buildFeedUrl(tab) {
120
+ const t = FEED_TABS[tab];
121
+ if (!t) throw new ArgumentError(`unknown feed tab "${tab}"`);
122
+ return `${UPWORK_ORIGIN}${t.path}`;
123
+ }
124
+
125
+ export function feedStateKey(tab) {
126
+ const t = FEED_TABS[tab];
127
+ if (!t) throw new ArgumentError(`unknown feed tab "${tab}"`);
128
+ return t.state;
129
+ }
130
+
131
+ export function buildJobUrl(ciphertext) {
132
+ return `${UPWORK_ORIGIN}/jobs/${ciphertext}`;
133
+ }
134
+
135
+ export function isValidCiphertext(value) {
136
+ return CIPHERTEXT_PATTERN.test(String(value ?? '').trim());
137
+ }
138
+
139
+ /**
140
+ * Strip Upwork's `<span class="highlight">…</span>` markup that wraps
141
+ * matched query terms in search results, then collapse whitespace.
142
+ * Empty / null returns ''.
143
+ */
144
+ export function stripHighlight(text) {
145
+ if (text == null) return '';
146
+ return String(text)
147
+ .replace(/<span class="highlight">/g, '')
148
+ .replace(/<\/span>/g, '')
149
+ .replace(/\s+/g, ' ')
150
+ .trim();
151
+ }
152
+
153
+ /**
154
+ * Decode `tierText` codes into stable lowercase labels.
155
+ * - search rows use the i18n-keyed form: `jsn_Entry_205` / `_Intermediate_206` / `_Expert_207`
156
+ * - feed rows already pass through the rendered label: `Entry level` / `Intermediate` / `Expert`
157
+ * - detail uses a numeric `contractorTier`: 1 / 2 / 3
158
+ * Returns 'entry' | 'intermediate' | 'expert' | '' (unknown).
159
+ */
160
+ export function decodeExperienceLevel(value) {
161
+ if (value == null || value === '') return '';
162
+ if (typeof value === 'number') {
163
+ if (value === 1) return 'entry';
164
+ if (value === 2) return 'intermediate';
165
+ if (value === 3) return 'expert';
166
+ return '';
167
+ }
168
+ const v = String(value).toLowerCase();
169
+ if (v.includes('entry')) return 'entry';
170
+ if (v.includes('intermediate')) return 'intermediate';
171
+ if (v.includes('expert')) return 'expert';
172
+ return '';
173
+ }
174
+
175
+ /**
176
+ * Decode the `engagement` workload code. Search rows ship it as
177
+ * `usnuxt_Engagement_421.fullTime` / `.partTime`; detail surfaces it
178
+ * pre-rendered as `More than 30 hrs/week`. Returns 'full-time' /
179
+ * 'part-time' / '' or passes the rendered string through.
180
+ */
181
+ export function decodeWorkload(value) {
182
+ if (value == null || value === '') return '';
183
+ const v = String(value);
184
+ const suffix = v.includes('.') ? v.split('.').pop() : v;
185
+ const lower = suffix.toLowerCase();
186
+ if (lower === 'fulltime' || lower.includes('full')) return 'full-time';
187
+ if (lower === 'parttime' || lower.includes('part')) return 'part-time';
188
+ if (lower.includes('hrs/week') || lower.includes('hours')) return suffix.trim();
189
+ return '';
190
+ }
191
+
192
+ /**
193
+ * Decode `proposalsTier` to a compact bucket label. Search ships it as
194
+ * `usnuxt_JobProposalTier_418.lessThan5` etc; feed ships it pre-rendered
195
+ * as `15 to 20` / `5 to 10`. Returns the bucket like '<5' / '5-10' /
196
+ * '20-50' / '50+', or '' if unrecognized.
197
+ */
198
+ export function decodeProposalsTier(value) {
199
+ if (value == null || value === '') return '';
200
+ const v = String(value);
201
+ const suffix = v.includes('.') ? v.split('.').pop() : v;
202
+ const s = suffix.trim();
203
+ if (/^lessThan(\d+)$/i.test(s)) return `<${s.match(/\d+/)[0]}`;
204
+ if (/^(\d+)plus$/i.test(s)) return `${s.match(/\d+/)[0]}+`;
205
+ const range = s.match(/^(\d+)\s*(?:to|-|–)\s*(\d+)$/i);
206
+ if (range) return `${range[1]}-${range[2]}`;
207
+ return s;
208
+ }
209
+
210
+ /**
211
+ * Format the budget into a single human-readable column.
212
+ * - hourly (type 2): "$40-$70/hr" or "$30/hr" or "" (no budget set)
213
+ * - fixed (type 1): "$200" or "" (no amount)
214
+ * Used by search/feed; detail uses formatBudgetFromDetail (different shape).
215
+ */
216
+ export function formatBudget(job) {
217
+ const type = job?.type;
218
+ const min = Number(job?.hourlyBudget?.min) || 0;
219
+ const max = Number(job?.hourlyBudget?.max) || 0;
220
+ const amount = Number(job?.amount?.amount) || 0;
221
+ if (type === 2) {
222
+ if (min > 0 && max > 0 && max !== min) return `$${min}-$${max}/hr`;
223
+ if (max > 0) return `$${max}/hr`;
224
+ if (min > 0) return `$${min}/hr`;
225
+ return '';
226
+ }
227
+ if (type === 1) return amount > 0 ? `$${amount}` : '';
228
+ return '';
229
+ }
230
+
231
+ /** Detail page uses `extendedBudgetInfo.{hourlyBudgetMin,Max}` + `budget.amount`. */
232
+ export function formatBudgetFromDetail(job) {
233
+ const type = job?.type;
234
+ const min = Number(job?.extendedBudgetInfo?.hourlyBudgetMin) || 0;
235
+ const max = Number(job?.extendedBudgetInfo?.hourlyBudgetMax) || 0;
236
+ const amount = Number(job?.budget?.amount) || 0;
237
+ if (type === 2) {
238
+ if (min > 0 && max > 0 && max !== min) return `$${min}-$${max}/hr`;
239
+ if (max > 0) return `$${max}/hr`;
240
+ if (min > 0) return `$${min}/hr`;
241
+ return '';
242
+ }
243
+ if (type === 1) return amount > 0 ? `$${amount}` : '';
244
+ return '';
245
+ }
246
+
247
+ export function jobType(type) {
248
+ if (type === 1) return 'fixed';
249
+ if (type === 2) return 'hourly';
250
+ return '';
251
+ }
252
+
253
+ /**
254
+ * Join skills/attrs into a comma-separated string. Search rows have
255
+ * `attrs[].prettyName`; feed rows additionally have `skills[].prefLabel`;
256
+ * detail has neither (skills live elsewhere). The function picks
257
+ * whichever array is populated and dedupes.
258
+ */
259
+ export function formatSkills(job) {
260
+ const candidates = [];
261
+ const arrs = [job?.attrs, job?.skills, job?.ontologySkills];
262
+ for (const arr of arrs) {
263
+ if (!Array.isArray(arr)) continue;
264
+ for (const s of arr) {
265
+ const name = (s?.prettyName ?? s?.prefLabel ?? s?.name ?? '').trim();
266
+ if (name && !candidates.includes(name)) candidates.push(name);
267
+ }
268
+ }
269
+ return candidates.join(', ');
270
+ }
271
+
272
+ /**
273
+ * Normalize a search/feed job entry into the shared LIST_COLUMNS row shape.
274
+ * Returns null when the row lacks a round-trippable ciphertext identity.
275
+ */
276
+ export function jobToListRow(job, rank) {
277
+ const id = String(job?.ciphertext ?? '').trim();
278
+ if (!isValidCiphertext(id)) return null;
279
+ const client = job?.client || {};
280
+ const country = client?.location?.country || '';
281
+ const rating = Number(client?.totalFeedback);
282
+ return {
283
+ rank,
284
+ id,
285
+ title: stripHighlight(job?.title),
286
+ type: jobType(job?.type),
287
+ budget: formatBudget(job),
288
+ experienceLevel: decodeExperienceLevel(job?.tierText ?? job?.tier),
289
+ proposalsTier: decodeProposalsTier(job?.proposalsTier),
290
+ skills: formatSkills(job),
291
+ clientCountry: country,
292
+ clientRating: Number.isFinite(rating) && rating > 0 ? rating : null,
293
+ publishedOn: job?.publishedOn || job?.createdOn || '',
294
+ url: buildJobUrl(id),
295
+ };
296
+ }
297
+
298
+ export function jobsToListRows(jobs, { offset = 0, limit } = {}) {
299
+ const rows = [];
300
+ const source = limit ? jobs.slice(0, limit) : jobs;
301
+ for (const [index, job] of source.entries()) {
302
+ const rank = offset + index + 1;
303
+ const row = jobToListRow(job, rank);
304
+ if (!row) {
305
+ throw new CommandExecutionError(`Upwork result at rank ${rank} did not include a valid ciphertext id; cannot produce round-trippable detail rows.`);
306
+ }
307
+ rows.push(row);
308
+ }
309
+ return rows;
310
+ }
311
+
312
+ export const LIST_COLUMNS = [
313
+ 'rank', 'id', 'title', 'type', 'budget',
314
+ 'experienceLevel', 'proposalsTier', 'skills',
315
+ 'clientCountry', 'clientRating', 'publishedOn', 'url',
316
+ ];
317
+
318
+ export const DETAIL_COLUMNS = [
319
+ 'id', 'title', 'type', 'budget', 'experienceLevel', 'workload',
320
+ 'category', 'skills', 'description',
321
+ 'clientCountry', 'clientSpent', 'clientHires', 'clientRating',
322
+ 'proposalsCount', 'publishedOn', 'url',
323
+ ];
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Weibo delete — remove a single post owned by the logged-in user.
3
+ */
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
6
+ import { requireObjectEvaluateResult, unwrapEvaluateResult } from './utils.js';
7
+
8
+ const WEIBO_HOST_RE = /(^|\.)weibo\.(com|cn)$/i;
9
+ const POST_ID_RE = /^[A-Za-z0-9]{4,32}$/;
10
+
11
+ function normalizePostId(raw) {
12
+ const input = String(raw ?? '').trim();
13
+ if (!input) {
14
+ throw new ArgumentError('weibo delete: id cannot be empty');
15
+ }
16
+
17
+ let candidate = input;
18
+ try {
19
+ const url = new URL(input);
20
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
21
+ throw new ArgumentError('weibo delete: URL must use http or https');
22
+ }
23
+ if (!WEIBO_HOST_RE.test(url.hostname)) {
24
+ throw new ArgumentError('weibo delete: URL must be a weibo.com or weibo.cn post URL');
25
+ }
26
+ const parts = url.pathname.split('/').filter(Boolean);
27
+ if (url.hostname.toLowerCase().endsWith('weibo.cn') && parts[0] === 'status') {
28
+ candidate = parts[1] ?? '';
29
+ } else {
30
+ candidate = parts.at(-1) ?? '';
31
+ }
32
+ } catch (error) {
33
+ if (error instanceof ArgumentError) throw error;
34
+ }
35
+
36
+ candidate = String(candidate ?? '').trim();
37
+ if (!POST_ID_RE.test(candidate)) {
38
+ throw new ArgumentError('weibo delete: id must be a numeric idstr, mblogid, or Weibo post URL');
39
+ }
40
+ return candidate;
41
+ }
42
+
43
+ cli({
44
+ site: 'weibo',
45
+ name: 'delete',
46
+ access: 'write',
47
+ description: 'Delete one of my Weibo posts by id',
48
+ domain: 'weibo.com',
49
+ strategy: Strategy.COOKIE,
50
+ args: [
51
+ {
52
+ name: 'id',
53
+ required: true,
54
+ positional: true,
55
+ help: 'Post ID (numeric idstr or mblogid from URL / weibo me / weibo post output)',
56
+ },
57
+ ],
58
+ columns: ['status', 'id', 'mblogid'],
59
+ func: async (page, kwargs) => {
60
+ if (!page) {
61
+ throw new CommandExecutionError('Browser session required for weibo delete');
62
+ }
63
+ const raw = String(kwargs.id ?? '').trim();
64
+ const id = normalizePostId(raw);
65
+ await page.goto('https://weibo.com');
66
+ await page.wait(2);
67
+ const result = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
68
+ (async () => {
69
+ const input = ${JSON.stringify(id)};
70
+ const readCookie = (name) => {
71
+ const pair = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith(name + '='));
72
+ return pair ? decodeURIComponent(pair.slice(name.length + 1)) : '';
73
+ };
74
+ // Step 1: resolve mblogid / idstr to canonical idstr via /show.
75
+ const showResp = await fetch('/ajax/statuses/show?id=' + encodeURIComponent(input), { credentials: 'include' });
76
+ if (showResp.status === 401 || showResp.status === 403) {
77
+ return { ok: false, error: 'auth', status: showResp.status };
78
+ }
79
+ // 404 from /show means the post does not exist (deleted, wrong id, or
80
+ // not owned by the logged-in user); map to the same path as a 2xx
81
+ // response with no idstr so the caller throws EmptyResultError
82
+ // instead of a generic CommandExecutionError("HTTP 404").
83
+ if (showResp.status === 404) {
84
+ return { ok: false, error: 'not_found', input };
85
+ }
86
+ if (!showResp.ok) {
87
+ return { ok: false, error: 'show_http', status: showResp.status };
88
+ }
89
+ const showBody = await showResp.json();
90
+ if (!showBody || !showBody.idstr) {
91
+ return { ok: false, error: 'not_found', input };
92
+ }
93
+ const idstr = String(showBody.idstr);
94
+ const mblogid = showBody.mblogid || '';
95
+ // Step 2: destroy. Weibo requires X-Xsrf-Token (double-submit CSRF token).
96
+ const token = readCookie('XSRF-TOKEN');
97
+ const destroyResp = await fetch('/ajax/statuses/destroy', {
98
+ method: 'POST',
99
+ credentials: 'include',
100
+ headers: {
101
+ 'Content-Type': 'application/x-www-form-urlencoded',
102
+ 'X-Xsrf-Token': token,
103
+ },
104
+ body: 'id=' + encodeURIComponent(idstr),
105
+ });
106
+ if (destroyResp.status === 401 || destroyResp.status === 403) {
107
+ return { ok: false, error: 'auth', status: destroyResp.status };
108
+ }
109
+ if (!destroyResp.ok) {
110
+ return { ok: false, error: 'destroy_http', status: destroyResp.status };
111
+ }
112
+ const destroyBody = await destroyResp.json();
113
+ // Require an explicit success signal from the API: { ok: 1 }. A
114
+ // missing / falsy body must not be silently treated as success.
115
+ if (!destroyBody || typeof destroyBody !== 'object') {
116
+ return { ok: false, error: 'api', msg: 'destroy returned malformed response', id: idstr };
117
+ }
118
+ if (destroyBody.ok !== 1) {
119
+ return { ok: false, error: 'api', msg: destroyBody.msg || destroyBody.message || 'destroy returned non-ok', id: idstr };
120
+ }
121
+ // Step 3: postcondition evidence. A write command cannot report success
122
+ // until the target no longer resolves after the delete API returns ok.
123
+ const verifyResp = await fetch('/ajax/statuses/show?id=' + encodeURIComponent(idstr), { credentials: 'include' });
124
+ if (verifyResp.status === 401 || verifyResp.status === 403) {
125
+ return { ok: false, error: 'auth', status: verifyResp.status };
126
+ }
127
+ if (verifyResp.status === 404) {
128
+ return { ok: true, id: idstr, mblogid };
129
+ }
130
+ if (!verifyResp.ok) {
131
+ return { ok: false, error: 'verify_http', status: verifyResp.status, id: idstr };
132
+ }
133
+ let verifyBody = null;
134
+ try {
135
+ verifyBody = await verifyResp.json();
136
+ } catch {
137
+ return { ok: false, error: 'verify_malformed', msg: 'verify returned non-JSON response', id: idstr };
138
+ }
139
+ if (!verifyBody || typeof verifyBody !== 'object') {
140
+ return { ok: false, error: 'verify_malformed', msg: 'verify returned malformed response', id: idstr };
141
+ }
142
+ if (String(verifyBody.idstr || '') === idstr) {
143
+ return { ok: false, error: 'still_exists', id: idstr, mblogid: verifyBody.mblogid || mblogid };
144
+ }
145
+ if (!verifyBody.idstr || verifyBody.ok === 0) {
146
+ return { ok: true, id: idstr, mblogid };
147
+ }
148
+ return { ok: false, error: 'verify_mismatch', msg: 'verify returned a different post id', id: idstr };
149
+ })()
150
+ `)), 'weibo delete');
151
+ if (result.error === 'auth') {
152
+ throw new AuthRequiredError('weibo.com', 'Cookie 已过期!请在当前 Chrome 浏览器中重新登录 Weibo。');
153
+ }
154
+ if (result.error === 'not_found') {
155
+ throw new EmptyResultError('weibo delete', `Post not found for id "${String(result.input ?? raw)}". Verify the post still exists and belongs to the logged-in account.`);
156
+ }
157
+ if (result.error === 'show_http' || result.error === 'destroy_http' || result.error === 'verify_http') {
158
+ throw new CommandExecutionError(`weibo delete: HTTP ${result.status}`);
159
+ }
160
+ if (result.error === 'api' || result.error === 'verify_malformed' || result.error === 'verify_mismatch' || result.error === 'still_exists') {
161
+ throw new CommandExecutionError(`weibo delete: ${String(result.msg ?? result.error)}`);
162
+ }
163
+ if (!result.ok) {
164
+ throw new CommandExecutionError('weibo delete returned an unexpected response');
165
+ }
166
+ return [{ status: 'deleted', id: String(result.id ?? ''), mblogid: String(result.mblogid ?? '') }];
167
+ },
168
+ });
169
+
170
+ export const __test__ = {
171
+ normalizePostId,
172
+ };
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+
5
+ import './delete.js';
6
+
7
+ function makePage(evaluateResult) {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ wait: vi.fn().mockResolvedValue(undefined),
11
+ evaluate: vi.fn().mockResolvedValue(evaluateResult),
12
+ };
13
+ }
14
+
15
+ describe('weibo delete command', () => {
16
+ const getCommand = () => getRegistry().get('weibo/delete');
17
+
18
+ it('returns deleted status when the API reports success', async () => {
19
+ const page = makePage({ ok: true, id: '5197123456789012', mblogid: 'Px2yQfXYZ' });
20
+ const result = await getCommand().func(page, { id: 'Px2yQfXYZ' });
21
+ expect(result).toEqual([
22
+ { status: 'deleted', id: '5197123456789012', mblogid: 'Px2yQfXYZ' },
23
+ ]);
24
+ expect(page.goto).toHaveBeenCalledWith('https://weibo.com');
25
+ const script = page.evaluate.mock.calls[0][0];
26
+ expect(script.match(/\/ajax\/statuses\/show/g)).toHaveLength(2);
27
+ expect(script).toContain('/ajax/statuses/destroy');
28
+ expect(script).toContain('still_exists');
29
+ });
30
+
31
+ it('normalizes supported Weibo post URLs before evaluating delete flow', async () => {
32
+ const page = makePage({ ok: true, id: '5197123456789012', mblogid: 'Px2yQfXYZ' });
33
+ const result = await getCommand().func(page, { id: 'https://weibo.com/1234567890/Px2yQfXYZ?refer_flag=1001030103_' });
34
+
35
+ expect(result).toEqual([
36
+ { status: 'deleted', id: '5197123456789012', mblogid: 'Px2yQfXYZ' },
37
+ ]);
38
+ expect(page.evaluate.mock.calls[0][0]).toContain('const input = "Px2yQfXYZ"');
39
+ });
40
+
41
+ it('throws ArgumentError when id is empty or whitespace', async () => {
42
+ const page = makePage({ ok: true, id: '0' });
43
+ await expect(getCommand().func(page, { id: ' ' })).rejects.toBeInstanceOf(ArgumentError);
44
+ await expect(getCommand().func(page, { id: '' })).rejects.toBeInstanceOf(ArgumentError);
45
+ await expect(getCommand().func(page, { id: 'https://example.com/123/Px2yQfXYZ' })).rejects.toBeInstanceOf(ArgumentError);
46
+ await expect(getCommand().func(page, { id: 'javascript:alert(1)' })).rejects.toBeInstanceOf(ArgumentError);
47
+ await expect(getCommand().func(page, { id: '../not-a-post' })).rejects.toBeInstanceOf(ArgumentError);
48
+ expect(page.goto).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it('maps 401 / 403 from the show endpoint to AuthRequiredError', async () => {
52
+ const page = makePage({ error: 'auth', status: 401 });
53
+ await expect(getCommand().func(page, { id: 'Px2yQfXYZ' })).rejects.toBeInstanceOf(AuthRequiredError);
54
+ });
55
+
56
+ it('throws EmptyResultError when the post cannot be resolved', async () => {
57
+ const page = makePage({ error: 'not_found', input: 'Px2yQfXYZ' });
58
+ await expect(getCommand().func(page, { id: 'Px2yQfXYZ' })).rejects.toBeInstanceOf(EmptyResultError);
59
+ });
60
+
61
+ it('throws CommandExecutionError on non-2xx show response', async () => {
62
+ const page = makePage({ error: 'show_http', status: 500 });
63
+ await expect(getCommand().func(page, { id: '5197123456789012' })).rejects.toBeInstanceOf(CommandExecutionError);
64
+ });
65
+
66
+ it('throws CommandExecutionError on non-2xx destroy response', async () => {
67
+ const page = makePage({ error: 'destroy_http', status: 502 });
68
+ await expect(getCommand().func(page, { id: '5197123456789012' })).rejects.toBeInstanceOf(CommandExecutionError);
69
+ });
70
+
71
+ it('surfaces API-level errors from destroy as CommandExecutionError with msg', async () => {
72
+ const page = makePage({ error: 'api', msg: '无权限删除', id: '5197123456789012' });
73
+ await expect(getCommand().func(page, { id: '5197123456789012' })).rejects.toThrowError(/无权限删除/);
74
+ });
75
+
76
+ it('throws CommandExecutionError when postcondition verification still sees the target', async () => {
77
+ const page = makePage({ error: 'still_exists', id: '5197123456789012', mblogid: 'Px2yQfXYZ' });
78
+ await expect(getCommand().func(page, { id: '5197123456789012' })).rejects.toBeInstanceOf(CommandExecutionError);
79
+ });
80
+
81
+ it('throws CommandExecutionError when postcondition verification is malformed', async () => {
82
+ const page = makePage({ error: 'verify_malformed', msg: 'verify returned malformed response', id: '5197123456789012' });
83
+ await expect(getCommand().func(page, { id: '5197123456789012' })).rejects.toThrowError(/verify returned malformed response/);
84
+ });
85
+
86
+ it('unwraps the browser-bridge { session, data } envelope', async () => {
87
+ const page = makePage({
88
+ session: 'site:weibo:abc',
89
+ data: { ok: true, id: '42', mblogid: 'M420' },
90
+ });
91
+ const result = await getCommand().func(page, { id: 'M420' });
92
+ expect(result).toEqual([{ status: 'deleted', id: '42', mblogid: 'M420' }]);
93
+ });
94
+ });