@jackwener/opencli 1.7.22 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +30 -148
  2. package/README.zh-CN.md +37 -211
  3. package/cli-manifest.json +6423 -4260
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/chatgpt/envelope.test.js +108 -0
  24. package/clis/chatgpt/image.js +2 -2
  25. package/clis/chatgpt/image.test.js +6 -0
  26. package/clis/chatgpt/utils.js +148 -41
  27. package/clis/chatgpt/utils.test.js +92 -2
  28. package/clis/douyin/_shared/browser-fetch.js +44 -20
  29. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  30. package/clis/douyin/_shared/evaluate-result.js +16 -0
  31. package/clis/douyin/_shared/tos-upload.js +105 -69
  32. package/clis/douyin/_shared/vod-upload.js +212 -0
  33. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  34. package/clis/douyin/delete.js +137 -4
  35. package/clis/douyin/delete.test.js +90 -1
  36. package/clis/douyin/publish-upload-id.test.js +170 -0
  37. package/clis/douyin/publish.js +88 -42
  38. package/clis/douyin/user-videos.js +9 -2
  39. package/clis/douyin/user-videos.test.js +43 -0
  40. package/clis/flomo/memos.js +228 -0
  41. package/clis/flomo/memos.test.js +144 -0
  42. package/clis/gitee/search.js +2 -2
  43. package/clis/gitee/search.test.js +65 -0
  44. package/clis/jike/post.js +27 -17
  45. package/clis/jike/read.test.js +86 -0
  46. package/clis/jike/topic.js +32 -19
  47. package/clis/jike/user.js +33 -20
  48. package/clis/lesswrong/comments.js +1 -1
  49. package/clis/lesswrong/curated.js +1 -1
  50. package/clis/lesswrong/frontpage.js +1 -1
  51. package/clis/lesswrong/frontpage.test.js +37 -0
  52. package/clis/lesswrong/new.js +1 -1
  53. package/clis/lesswrong/read.js +1 -1
  54. package/clis/lesswrong/sequences.js +1 -1
  55. package/clis/lesswrong/shortform.js +1 -1
  56. package/clis/lesswrong/tag.js +1 -1
  57. package/clis/lesswrong/top-month.js +1 -1
  58. package/clis/lesswrong/top-week.js +1 -1
  59. package/clis/lesswrong/top-year.js +1 -1
  60. package/clis/lesswrong/top.js +1 -1
  61. package/clis/linkedin/connect.js +401 -0
  62. package/clis/linkedin/connect.test.js +213 -0
  63. package/clis/linkedin/inbox.js +234 -0
  64. package/clis/linkedin/inbox.test.js +152 -0
  65. package/clis/linkedin/people-search.js +262 -0
  66. package/clis/linkedin/people-search.test.js +216 -0
  67. package/clis/linkedin/safe-send.js +357 -0
  68. package/clis/linkedin/safe-send.test.js +204 -0
  69. package/clis/linkedin/salesnav-inbox.js +210 -0
  70. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  71. package/clis/linkedin/salesnav-message.js +360 -0
  72. package/clis/linkedin/salesnav-message.test.js +172 -0
  73. package/clis/linkedin/salesnav-search.js +186 -0
  74. package/clis/linkedin/salesnav-search.test.js +76 -0
  75. package/clis/linkedin/salesnav-thread.js +212 -0
  76. package/clis/linkedin/salesnav-thread.test.js +79 -0
  77. package/clis/linkedin/sent-invitations.js +92 -0
  78. package/clis/linkedin/sent-invitations.test.js +62 -0
  79. package/clis/linkedin/thread-snapshot.js +214 -0
  80. package/clis/linkedin/thread-snapshot.test.js +89 -0
  81. package/clis/linkedin-learning/course.js +138 -0
  82. package/clis/linkedin-learning/course.test.js +114 -0
  83. package/clis/linkedin-learning/search.js +155 -0
  84. package/clis/linkedin-learning/search.test.js +144 -0
  85. package/clis/linkedin-learning/trending.js +133 -0
  86. package/clis/linkedin-learning/trending.test.js +123 -0
  87. package/clis/powerchina/search.js +3 -3
  88. package/clis/powerchina/search.test.js +27 -1
  89. package/clis/reddit/extract-media.test.js +149 -0
  90. package/clis/reddit/frontpage.js +47 -9
  91. package/clis/reddit/frontpage.test.js +34 -0
  92. package/clis/reddit/home.js +31 -1
  93. package/clis/reddit/home.test.js +46 -3
  94. package/clis/reddit/hot.js +32 -1
  95. package/clis/reddit/hot.test.js +15 -1
  96. package/clis/reddit/popular.js +39 -1
  97. package/clis/reddit/popular.test.js +26 -0
  98. package/clis/reddit/saved.js +1 -1
  99. package/clis/reddit/search.js +38 -1
  100. package/clis/reddit/search.test.js +26 -0
  101. package/clis/reddit/subreddit.js +52 -7
  102. package/clis/reddit/subreddit.test.js +31 -0
  103. package/clis/reddit/subscribed.js +165 -0
  104. package/clis/reddit/subscribed.test.js +168 -0
  105. package/clis/reddit/upvoted.js +1 -1
  106. package/clis/suno/commands.test.js +188 -0
  107. package/clis/suno/download.js +140 -0
  108. package/clis/suno/download.test.js +151 -0
  109. package/clis/suno/generate.js +226 -0
  110. package/clis/suno/generate.test.js +243 -0
  111. package/clis/suno/list.js +79 -0
  112. package/clis/suno/status.js +62 -0
  113. package/clis/suno/utils.js +540 -0
  114. package/clis/suno/utils.test.js +223 -0
  115. package/clis/twitter/device-follow.js +193 -0
  116. package/clis/twitter/device-follow.test.js +287 -0
  117. package/clis/twitter/download.js +443 -73
  118. package/clis/twitter/download.test.js +457 -0
  119. package/clis/twitter/list-create.js +155 -0
  120. package/clis/twitter/list-create.test.js +169 -0
  121. package/clis/twitter/list-remove.js +12 -5
  122. package/clis/twitter/list-remove.test.js +74 -0
  123. package/clis/twitter/list-tweets.js +6 -2
  124. package/clis/twitter/list-tweets.test.js +41 -1
  125. package/clis/twitter/lists.js +31 -4
  126. package/clis/twitter/lists.test.js +152 -16
  127. package/clis/twitter/search.js +6 -2
  128. package/clis/twitter/search.test.js +6 -0
  129. package/clis/twitter/shared.js +144 -0
  130. package/clis/twitter/shared.test.js +429 -1
  131. package/clis/twitter/thread.js +10 -2
  132. package/clis/twitter/thread.test.js +58 -0
  133. package/clis/twitter/timeline.js +6 -2
  134. package/clis/twitter/timeline.test.js +2 -0
  135. package/clis/twitter/tweets.js +3 -2
  136. package/clis/twitter/tweets.test.js +1 -1
  137. package/clis/weibo/delete.js +172 -0
  138. package/clis/weibo/delete.test.js +94 -0
  139. package/clis/weibo/publish.js +37 -14
  140. package/clis/weibo/publish.test.js +14 -5
  141. package/clis/weibo/user-posts.js +234 -0
  142. package/clis/weibo/user-posts.test.js +92 -0
  143. package/clis/weread/search-regression.test.js +18 -11
  144. package/clis/weread/search.js +15 -7
  145. package/clis/weread-official/book.js +135 -0
  146. package/clis/weread-official/commands.test.js +385 -0
  147. package/clis/weread-official/discover.js +107 -0
  148. package/clis/weread-official/list-apis.js +95 -0
  149. package/clis/weread-official/notes.js +171 -0
  150. package/clis/weread-official/readdata.js +158 -0
  151. package/clis/weread-official/review.js +93 -0
  152. package/clis/weread-official/search.js +106 -0
  153. package/clis/weread-official/shelf.js +97 -0
  154. package/clis/weread-official/utils.js +293 -0
  155. package/clis/weread-official/utils.test.js +242 -0
  156. package/clis/wikipedia/trending.js +7 -3
  157. package/clis/wikipedia/trending.test.js +57 -0
  158. package/clis/xianyu/chat.js +24 -109
  159. package/clis/xianyu/chat.test.js +5 -0
  160. package/clis/xianyu/im.js +322 -0
  161. package/clis/xianyu/im.test.js +253 -0
  162. package/clis/xianyu/inbox.js +96 -0
  163. package/clis/xianyu/messages.js +91 -0
  164. package/clis/xianyu/reply.js +82 -0
  165. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  166. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  167. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  168. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  169. package/clis/xiaohongshu/creator-notes.js +2 -1
  170. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  171. package/clis/xiaohongshu/creator-stats.js +2 -1
  172. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  173. package/clis/xiaohongshu/delete-note.js +260 -0
  174. package/clis/xiaohongshu/delete-note.test.js +172 -0
  175. package/clis/xiaohongshu/publish.js +48 -8
  176. package/clis/xiaohongshu/publish.test.js +65 -10
  177. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  178. package/clis/xiaohongshu/user.js +27 -4
  179. package/clis/xiaoyuzhou/download.js +1 -1
  180. package/clis/xiaoyuzhou/transcript.js +1 -1
  181. package/clis/youdao/note.js +258 -0
  182. package/clis/youdao/note.test.js +99 -0
  183. package/clis/youtube/transcript.js +397 -24
  184. package/clis/youtube/transcript.test.js +196 -6
  185. package/clis/zhihu/answer-comments.js +299 -0
  186. package/clis/zhihu/answer-comments.test.js +287 -0
  187. package/clis/zhihu/answer-detail.js +12 -0
  188. package/clis/zhihu/answer-detail.test.js +8 -0
  189. package/clis/zhihu/collection.js +15 -2
  190. package/clis/zhihu/collection.test.js +46 -0
  191. package/clis/zhihu/download.js +1 -1
  192. package/clis/zhihu/question.js +42 -9
  193. package/clis/zhihu/question.test.js +111 -9
  194. package/clis/zhihu/search.js +206 -43
  195. package/clis/zhihu/search.test.js +198 -0
  196. package/dist/src/browser/errors.js +4 -2
  197. package/dist/src/browser/errors.test.js +6 -0
  198. package/dist/src/browser/page.js +30 -4
  199. package/dist/src/browser/page.test.js +42 -0
  200. package/dist/src/browser/utils.d.ts +1 -1
  201. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  202. package/dist/src/cli-argv-preprocess.js +138 -0
  203. package/dist/src/cli-argv-preprocess.test.js +79 -0
  204. package/dist/src/convention-audit.js +15 -8
  205. package/dist/src/convention-audit.test.js +21 -0
  206. package/dist/src/download/media-download.js +15 -2
  207. package/dist/src/download/media-download.test.d.ts +1 -0
  208. package/dist/src/download/media-download.test.js +110 -0
  209. package/dist/src/electron-apps.js +1 -1
  210. package/dist/src/electron-apps.test.js +7 -2
  211. package/dist/src/errors.d.ts +17 -0
  212. package/dist/src/errors.js +22 -0
  213. package/dist/src/external-clis.yaml +8 -0
  214. package/dist/src/main.js +14 -2
  215. package/dist/src/utils.d.ts +43 -0
  216. package/dist/src/utils.js +97 -0
  217. package/dist/src/utils.test.d.ts +1 -0
  218. package/dist/src/utils.test.js +155 -0
  219. package/package.json +8 -2
  220. package/scripts/silent-column-drop-baseline.json +0 -52
  221. package/scripts/typed-error-lint-baseline.json +28 -380
  222. package/clis/slock/_utils.js +0 -12
@@ -1,6 +1,6 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
- import { extractMedia } from './shared.js';
3
+ import { extractMedia, extractCard, extractQuotedTweet } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
5
5
  // ── Twitter GraphQL constants ──────────────────────────────────────────
6
6
  const TWEET_DETAIL_QUERY_ID = 'nBS-WpgA6ZG0CyNHD517JQ';
@@ -46,9 +46,11 @@ function extractTweet(r, seen) {
46
46
  const u = tw.core?.user_results?.result;
47
47
  const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
48
48
  const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
49
+ const bio = u?.legacy?.description || '';
49
50
  return {
50
51
  id: tw.rest_id,
51
52
  author: screenName,
53
+ bio,
52
54
  text: noteText || l.full_text || '',
53
55
  likes: l.favorite_count || 0,
54
56
  retweets: l.retweet_count || 0,
@@ -56,6 +58,8 @@ function extractTweet(r, seen) {
56
58
  created_at: l.created_at,
57
59
  url: `https://x.com/${screenName}/status/${tw.rest_id}`,
58
60
  ...extractMedia(l),
61
+ card: extractCard(tw),
62
+ quoted_tweet: extractQuotedTweet(tw),
59
63
  };
60
64
  }
61
65
  function parseTweetDetail(data, seen) {
@@ -91,6 +95,10 @@ function parseTweetDetail(data, seen) {
91
95
  }
92
96
  return { tweets, nextCursor };
93
97
  }
98
+
99
+ export const __test__ = {
100
+ parseTweetDetail,
101
+ };
94
102
  // ── CLI definition ────────────────────────────────────────────────────
95
103
  cli({
96
104
  site: 'twitter',
@@ -105,7 +113,7 @@ cli({
105
113
  { name: 'limit', type: 'int', default: 50 },
106
114
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the thread by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the conversation\'s structural ordering.' },
107
115
  ],
108
- columns: ['id', 'author', 'text', 'likes', 'retweets', 'url', 'has_media', 'media_urls'],
116
+ columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'url', 'has_media', 'media_urls', 'card', 'quoted_tweet'],
109
117
  func: async (page, kwargs) => {
110
118
  let tweetId = kwargs['tweet-id'];
111
119
  const urlMatch = tweetId.match(/\/status\/(\d+)/);
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { __test__ } from './thread.js';
4
+
5
+ describe('twitter thread parser', () => {
6
+ it('extracts author bio from tweet user entity', () => {
7
+ const command = getRegistry().get('twitter/thread');
8
+ expect(command?.columns).toEqual(['id', 'author', 'bio', 'text', 'likes', 'retweets', 'url', 'has_media', 'media_urls', 'card', 'quoted_tweet']);
9
+ const result = __test__.parseTweetDetail({
10
+ data: {
11
+ threaded_conversation_with_injections_v2: {
12
+ instructions: [
13
+ {
14
+ entries: [
15
+ {
16
+ content: {
17
+ itemContent: {
18
+ tweet_results: {
19
+ result: {
20
+ rest_id: '1',
21
+ legacy: {
22
+ full_text: 'thread tweet',
23
+ favorite_count: 3,
24
+ retweet_count: 2,
25
+ },
26
+ core: {
27
+ user_results: {
28
+ result: {
29
+ legacy: {
30
+ screen_name: 'alice',
31
+ description: 'Thread author bio',
32
+ },
33
+ },
34
+ },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ },
40
+ },
41
+ ],
42
+ },
43
+ ],
44
+ },
45
+ },
46
+ }, new Set());
47
+ expect(result.tweets).toHaveLength(1);
48
+ expect(result.tweets[0]).toMatchObject({
49
+ id: '1',
50
+ author: 'alice',
51
+ bio: 'Thread author bio',
52
+ text: 'thread tweet',
53
+ likes: 3,
54
+ retweets: 2,
55
+ url: 'https://x.com/alice/status/1',
56
+ });
57
+ });
58
+ });
@@ -1,6 +1,6 @@
1
1
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
- import { resolveTwitterQueryId, extractMedia } from './shared.js';
3
+ import { resolveTwitterQueryId, extractMedia, extractCard, extractQuotedTweet } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
5
5
  // ── Twitter GraphQL constants ──────────────────────────────────────────
6
6
  const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
@@ -74,11 +74,13 @@ function extractTweet(result, seen) {
74
74
  seen.add(tw.rest_id);
75
75
  const u = tw.core?.user_results?.result;
76
76
  const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
77
+ const bio = u?.legacy?.description || '';
77
78
  const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
78
79
  const views = tw.views?.count ? parseInt(tw.views.count, 10) : 0;
79
80
  return {
80
81
  id: tw.rest_id,
81
82
  author: screenName,
83
+ bio,
82
84
  text: noteText || l.full_text || '',
83
85
  likes: l.favorite_count || 0,
84
86
  retweets: l.retweet_count || 0,
@@ -87,6 +89,8 @@ function extractTweet(result, seen) {
87
89
  created_at: l.created_at || '',
88
90
  url: `https://x.com/${screenName}/status/${tw.rest_id}`,
89
91
  ...extractMedia(l),
92
+ card: extractCard(tw),
93
+ quoted_tweet: extractQuotedTweet(tw),
90
94
  };
91
95
  }
92
96
  function parseHomeTimeline(data, seen) {
@@ -152,7 +156,7 @@ cli({
152
156
  { name: 'limit', type: 'int', default: 20, help: 'Maximum number of tweets to return (default 20).' },
153
157
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the timeline by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X\'s native ordering.' },
154
158
  ],
155
- columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url', 'has_media', 'media_urls'],
159
+ columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url', 'has_media', 'media_urls', 'card', 'quoted_tweet'],
156
160
  func: async (page, kwargs) => {
157
161
  const limit = kwargs.limit || 20;
158
162
  const timelineType = kwargs.type === 'following' ? 'following' : 'for-you';
@@ -57,6 +57,7 @@ describe('twitter timeline helpers', () => {
57
57
  result: {
58
58
  legacy: {
59
59
  screen_name: 'alice',
60
+ description: 'Timeline author bio',
60
61
  },
61
62
  },
62
63
  },
@@ -90,6 +91,7 @@ describe('twitter timeline helpers', () => {
90
91
  expect(result.tweets[0]).toMatchObject({
91
92
  id: '1',
92
93
  author: 'alice',
94
+ bio: 'Timeline author bio',
93
95
  text: 'hello',
94
96
  likes: 3,
95
97
  retweets: 2,
@@ -1,6 +1,6 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
- import { resolveTwitterOperationMetadata, sanitizeQueryId, extractMedia, normalizeTwitterGraphqlPayload, unwrapBrowserResult } from './shared.js';
3
+ import { resolveTwitterOperationMetadata, sanitizeQueryId, extractMedia, extractQuotedTweet, normalizeTwitterGraphqlPayload, unwrapBrowserResult } from './shared.js';
4
4
  import { normalizeTwitterScreenName } from './shared.js';
5
5
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
6
6
 
@@ -175,6 +175,7 @@ function extractTweet(result, seen) {
175
175
  created_at: legacy.created_at || '',
176
176
  url: `https://x.com/${screenName}/status/${tw.rest_id}`,
177
177
  ...extractMedia(legacy),
178
+ quoted_tweet: extractQuotedTweet(tw),
178
179
  };
179
180
  }
180
181
 
@@ -226,7 +227,7 @@ cli({
226
227
  { name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
227
228
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the tweets by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the chronological ordering.' },
228
229
  ],
229
- columns: ['id', 'author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls'],
230
+ columns: ['id', 'author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls', 'quoted_tweet'],
230
231
  func: async (page, kwargs) => {
231
232
  const limit = Math.max(1, Math.min(200, kwargs.limit || 20));
232
233
  const rawUsername = String(kwargs.username ?? '').trim();
@@ -6,7 +6,7 @@ import { __test__ } from './tweets.js';
6
6
  describe('twitter tweets helpers', () => {
7
7
  it('registers id and is_retweet in the default columns', () => {
8
8
  const cmd = getRegistry().get('twitter/tweets');
9
- expect(cmd?.columns).toEqual(['id', 'author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls']);
9
+ expect(cmd?.columns).toEqual(['id', 'author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls', 'quoted_tweet']);
10
10
  });
11
11
 
12
12
  it('makes the username argument optional so it can default to the logged-in user', () => {
@@ -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
+ });
@@ -30,8 +30,15 @@ const SUBMIT_POLL_MS = 500;
30
30
  const SUBMIT_TIMEOUT_MS = 20_000;
31
31
  const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
32
32
 
33
- // Weibo PC UI selectors
34
- const TEXTAREA_SELECTOR = 'textarea._input_13iqr_8';
33
+ // Weibo PC UI selectors. The CSS-module hash drifts on every frontend
34
+ // rebuild (#1602), so match on the stable placeholder text and keep the
35
+ // legacy hash as a last-resort fallback. Callers pick the LAST visible
36
+ // match because the compose modal renders on top of the home-feed strip.
37
+ const TEXTAREA_SELECTORS = [
38
+ 'textarea[placeholder*="有什么新鲜事"]',
39
+ 'textarea[placeholder*="新鲜事"]',
40
+ 'textarea._input_13iqr_8',
41
+ ];
35
42
  const FILE_INPUT_SELECTOR = 'input[type="file"][class*="_file_"]';
36
43
 
37
44
  function validateText(text) {
@@ -125,12 +132,19 @@ cli({
125
132
  let editorFound = false;
126
133
  for (let i = 0; i < Math.ceil(COMPOSE_TIMEOUT_MS / COMPOSE_POLL_MS); i++) {
127
134
  const result = await page.evaluate(`
128
- () => {
129
- const ta = document.querySelector('textarea._input_13iqr_8');
130
- if (!ta) return { found: false };
131
- const visible = ta.offsetParent !== null;
132
- return { found: true, visible, rectTop: visible ? ta.getBoundingClientRect().top : -1 };
133
- }
135
+ (selectors => {
136
+ // Pick the LAST visible match across all selectors so
137
+ // the modal (rendered on top of the home-feed strip)
138
+ // wins over earlier matches. See TEXTAREA_SELECTORS.
139
+ let last = null;
140
+ for (const sel of selectors) {
141
+ for (const t of document.querySelectorAll(sel)) {
142
+ if (t.offsetParent !== null) last = t;
143
+ }
144
+ }
145
+ if (!last) return { found: false };
146
+ return { found: true, visible: true, rectTop: last.getBoundingClientRect().top };
147
+ })(${JSON.stringify(TEXTAREA_SELECTORS)})
134
148
  `);
135
149
  if (result?.found && result.visible && result.rectTop >= 0) {
136
150
  editorFound = true;
@@ -187,9 +201,14 @@ cli({
187
201
  // IMPORTANT: Using nativeSetter preserves the textarea's reactive/internal state.
188
202
  // Direct ta.value= assignment bypasses Weibo's Vue reactivity and causes "undefined" content.
189
203
  const insertResult = await page.evaluateWithArgs(`
190
- (() => {
191
- const ta = document.querySelector('textarea._input_13iqr_8');
192
- if (!ta || ta.offsetParent === null) return { ok: false, message: 'textarea not visible' };
204
+ ((selectors) => {
205
+ let ta = null;
206
+ for (const sel of selectors) {
207
+ for (const t of document.querySelectorAll(sel)) {
208
+ if (t.offsetParent !== null) ta = t;
209
+ }
210
+ }
211
+ if (!ta) return { ok: false, message: 'textarea not visible' };
193
212
  ta.focus();
194
213
  const nativeSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
195
214
  if (nativeSetter) {
@@ -200,7 +219,7 @@ cli({
200
219
  ta.dispatchEvent(new Event('input', { bubbles: true }));
201
220
  ta.dispatchEvent(new Event('change', { bubbles: true }));
202
221
  return { ok: true, valueLength: ta.value.length };
203
- })()
222
+ })(${JSON.stringify(TEXTAREA_SELECTORS)})
204
223
  `, { textContent: text });
205
224
 
206
225
  if (!insertResult?.ok) {
@@ -233,10 +252,14 @@ cli({
233
252
  }
234
253
 
235
254
  // Step 8: Wait for success/failure result
255
+ // Use page.evaluate (not evaluateWithArgs): the IIFE doesn't reference
256
+ // any outer args, and evaluateWithArgs would re-declare its const
257
+ // bindings each loop iteration in the same page context, throwing
258
+ // "Identifier already declared" after the first iteration.
236
259
  let finalResult = null;
237
260
  for (let i = 0; i < Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS); i++) {
238
261
  await page.wait({ time: SUBMIT_POLL_MS / 1000 });
239
- finalResult = await page.evaluateWithArgs(`
262
+ finalResult = await page.evaluate(`
240
263
  (() => {
241
264
  const successMarkers = ['发布成功', '已发布', '发送成功'];
242
265
  const errorMarkers = ['发布失败', '发送失败', '内容违规', '请稍后再试', '频繁'];
@@ -257,7 +280,7 @@ cli({
257
280
  }
258
281
  return null;
259
282
  })()
260
- `, { maxIterations: Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS), currentIndex: i });
283
+ `);
261
284
  if (finalResult !== null) break;
262
285
  }
263
286
 
@@ -61,10 +61,10 @@ describe('weibo publish command', () => {
61
61
  { ok: true },
62
62
  { found: true, visible: true, rectTop: 100 },
63
63
  { ok: true, label: '发送' },
64
+ { ok: true, message: '发送成功' },
64
65
  ],
65
66
  evaluateWithArgsResults: [
66
67
  { ok: true, valueLength: 5 },
67
- { ok: true, message: '发送成功' },
68
68
  ],
69
69
  });
70
70
 
@@ -72,6 +72,9 @@ describe('weibo publish command', () => {
72
72
 
73
73
  expect(result).toEqual([{ status: 'success', message: '发送成功', text: 'hello' }]);
74
74
  expect(page.goto).toHaveBeenCalledWith('https://weibo.com', { waitUntil: 'load', settleMs: 2000 });
75
+ expect(page.evaluate.mock.calls[2][0]).toContain('有什么新鲜事');
76
+ expect(page.evaluate.mock.calls[2][0]).toContain('textarea._input_13iqr_8');
77
+ expect(page.evaluateWithArgs.mock.calls[0][0]).toContain('有什么新鲜事');
75
78
  });
76
79
 
77
80
  it('uploads up to nine images before publishing', async () => {
@@ -83,11 +86,11 @@ describe('weibo publish command', () => {
83
86
  { found: true, visible: true, rectTop: 100 },
84
87
  true,
85
88
  { ok: true, label: '发送' },
89
+ { ok: true, message: '发送成功' },
86
90
  ],
87
91
  evaluateWithArgsResults: [
88
92
  { ok: true, count: 2 },
89
93
  { ok: true, valueLength: 11 },
90
- { ok: true, message: '发送成功' },
91
94
  ],
92
95
  });
93
96
 
@@ -149,10 +152,10 @@ describe('weibo publish command', () => {
149
152
  { ok: true },
150
153
  { found: true, visible: true, rectTop: 100 },
151
154
  { ok: true, label: '发送' },
155
+ { ok: false, message: '内容违规' },
152
156
  ],
153
157
  evaluateWithArgsResults: [
154
158
  { ok: true, valueLength: 5 },
155
- { ok: false, message: '内容违规' },
156
159
  ],
157
160
  });
158
161
 
@@ -161,22 +164,28 @@ describe('weibo publish command', () => {
161
164
 
162
165
  it('does not treat editor close as positive publish proof', async () => {
163
166
  const command = getCommand();
167
+ // Step 8 polls up to SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS iterations
168
+ // (= 20000 / 500 = 40 in upstream). Derive the window programmatically
169
+ // so the test stays aligned with the implementation if the timeout
170
+ // changes, and override the makePage default { ok: true } fallback that
171
+ // would otherwise satisfy the success-marker break.
172
+ const SUBMIT_POLL_ITERATIONS = Math.ceil(20_000 / 500);
164
173
  const page = makePage({
165
174
  evaluateResults: [
166
175
  '123456',
167
176
  { ok: true },
168
177
  { found: true, visible: true, rectTop: 100 },
169
178
  { ok: true, label: '发送' },
179
+ ...Array(SUBMIT_POLL_ITERATIONS).fill(null),
170
180
  ],
171
181
  evaluateWithArgsResults: [
172
182
  { ok: true, valueLength: 5 },
173
- null,
174
183
  ],
175
184
  });
176
185
 
177
186
  await expect(command.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError);
178
187
 
179
- const submitScript = page.evaluateWithArgs.mock.calls.at(-1)[0];
188
+ const submitScript = page.evaluate.mock.calls.at(-1)[0];
180
189
  expect(submitScript).not.toContain('Editor closed after publish');
181
190
  expect(submitScript).toContain('发布成功');
182
191
  });