@jackwener/opencli 1.8.0 → 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 (153) hide show
  1. package/README.md +8 -49
  2. package/README.zh-CN.md +8 -52
  3. package/cli-manifest.json +1796 -191
  4. package/clis/_atlassian/shared.js +577 -0
  5. package/clis/_atlassian/shared.test.js +170 -0
  6. package/clis/bilibili/comment.js +125 -0
  7. package/clis/bilibili/comment.test.js +153 -0
  8. package/clis/bilibili/comments.js +116 -21
  9. package/clis/bilibili/comments.test.js +77 -18
  10. package/clis/bilibili/subtitle.js +76 -31
  11. package/clis/bilibili/subtitle.test.js +156 -9
  12. package/clis/bilibili/utils.js +63 -5
  13. package/clis/bilibili/utils.test.js +45 -1
  14. package/clis/chess/analyze.js +35 -0
  15. package/clis/chess/analyze.test.js +79 -0
  16. package/clis/chess/game.js +114 -0
  17. package/clis/chess/game.test.js +178 -0
  18. package/clis/chess/games.js +67 -0
  19. package/clis/chess/games.test.js +164 -0
  20. package/clis/chess/stats.js +32 -0
  21. package/clis/chess/stats.test.js +79 -0
  22. package/clis/chess/utils.js +170 -0
  23. package/clis/chess/utils.test.js +230 -0
  24. package/clis/confluence/commands.test.js +195 -0
  25. package/clis/confluence/create.js +39 -0
  26. package/clis/confluence/page.js +23 -0
  27. package/clis/confluence/search.js +34 -0
  28. package/clis/confluence/shared.js +173 -0
  29. package/clis/confluence/update.js +38 -0
  30. package/clis/douyin/hashtag.js +84 -23
  31. package/clis/douyin/hashtag.test.js +113 -0
  32. package/clis/geogebra/add-circle.js +46 -0
  33. package/clis/geogebra/add-line.js +35 -0
  34. package/clis/geogebra/add-point.js +27 -0
  35. package/clis/geogebra/add-polygon.js +25 -0
  36. package/clis/geogebra/eval.js +35 -0
  37. package/clis/geogebra/geogebra.test.js +175 -0
  38. package/clis/geogebra/hexagon.js +62 -0
  39. package/clis/geogebra/info.js +72 -0
  40. package/clis/geogebra/list.js +35 -0
  41. package/clis/geogebra/triangle.js +60 -0
  42. package/clis/geogebra/utils.js +271 -0
  43. package/clis/jira/attachments.js +28 -0
  44. package/clis/jira/commands.test.js +287 -0
  45. package/clis/jira/comments.js +28 -0
  46. package/clis/jira/issue.js +28 -0
  47. package/clis/jira/links.js +28 -0
  48. package/clis/jira/search.js +47 -0
  49. package/clis/jira/shared.js +256 -0
  50. package/clis/linkedin/job-detail.js +167 -0
  51. package/clis/linkedin/job-detail.test.js +38 -0
  52. package/clis/linkedin/jobs-preferences.js +113 -0
  53. package/clis/linkedin/jobs-preferences.test.js +43 -0
  54. package/clis/linkedin/post-analytics.js +74 -0
  55. package/clis/linkedin/post-analytics.test.js +40 -0
  56. package/clis/linkedin/posts-core.js +241 -0
  57. package/clis/linkedin/posts.js +22 -0
  58. package/clis/linkedin/posts.test.js +40 -0
  59. package/clis/linkedin/profile-analytics.js +104 -0
  60. package/clis/linkedin/profile-analytics.test.js +67 -0
  61. package/clis/linkedin/profile-experience.js +671 -0
  62. package/clis/linkedin/profile-experience.test.js +152 -0
  63. package/clis/linkedin/profile-projects.js +311 -0
  64. package/clis/linkedin/profile-projects.test.js +111 -0
  65. package/clis/linkedin/profile-read.js +148 -0
  66. package/clis/linkedin/profile-read.test.js +77 -0
  67. package/clis/linkedin/services-read.js +213 -0
  68. package/clis/linkedin/services-read.test.js +105 -0
  69. package/clis/linkedin/shared.js +124 -0
  70. package/clis/linkedin/timeline.js +14 -7
  71. package/clis/notebooklm/add-source.js +269 -0
  72. package/clis/notebooklm/add-source.test.js +97 -0
  73. package/clis/notebooklm/create.js +76 -0
  74. package/clis/notebooklm/create.test.js +58 -0
  75. package/clis/notebooklm/generate-audio.js +91 -0
  76. package/clis/notebooklm/generate-audio.test.js +63 -0
  77. package/clis/notebooklm/generate-slides.js +106 -0
  78. package/clis/notebooklm/generate-slides.test.js +75 -0
  79. package/clis/notebooklm/open.test.js +10 -10
  80. package/clis/notebooklm/rpc.js +20 -6
  81. package/clis/notebooklm/rpc.test.js +27 -1
  82. package/clis/notebooklm/utils.js +100 -24
  83. package/clis/notebooklm/utils.test.js +60 -1
  84. package/clis/notebooklm/write-note.js +103 -0
  85. package/clis/notebooklm/write-note.test.js +70 -0
  86. package/clis/pixiv/detail.js +41 -34
  87. package/clis/pixiv/detail.test.js +93 -0
  88. package/clis/pixiv/user.js +36 -31
  89. package/clis/pixiv/user.test.js +100 -0
  90. package/clis/pixiv/utils.js +56 -7
  91. package/clis/suno/generate.js +5 -0
  92. package/clis/suno/generate.test.js +9 -0
  93. package/clis/suno/status.js +3 -2
  94. package/clis/suno/utils.js +33 -24
  95. package/clis/suno/utils.test.js +106 -0
  96. package/clis/twitter/followers.js +6 -2
  97. package/clis/twitter/followers.test.js +19 -1
  98. package/clis/twitter/following.js +14 -5
  99. package/clis/twitter/following.test.js +29 -0
  100. package/clis/twitter/likes.js +12 -4
  101. package/clis/twitter/likes.test.js +26 -1
  102. package/clis/twitter/list-add.js +1 -1
  103. package/clis/twitter/list-remove.js +1 -1
  104. package/clis/twitter/notifications.js +4 -4
  105. package/clis/twitter/post.js +62 -4
  106. package/clis/twitter/post.test.js +35 -3
  107. package/clis/twitter/profile.js +81 -28
  108. package/clis/twitter/profile.test.js +113 -2
  109. package/clis/twitter/quote.js +9 -4
  110. package/clis/twitter/reply.js +13 -10
  111. package/clis/twitter/reply.test.js +41 -0
  112. package/clis/twitter/search.js +1 -1
  113. package/clis/twitter/search.test.js +35 -0
  114. package/clis/twitter/shared.js +11 -0
  115. package/clis/twitter/shared.test.js +37 -1
  116. package/clis/twitter/utils.js +53 -16
  117. package/clis/upwork/detail.js +132 -0
  118. package/clis/upwork/feed.js +109 -0
  119. package/clis/upwork/search.js +115 -0
  120. package/clis/upwork/upwork.test.js +566 -0
  121. package/clis/upwork/utils.js +323 -0
  122. package/clis/weread/book-search.js +438 -0
  123. package/clis/weread/book-search.test.js +242 -0
  124. package/clis/weread/search-regression.test.js +80 -0
  125. package/clis/weread/search.js +17 -2
  126. package/clis/xiaohongshu/creator-note-detail.js +165 -28
  127. package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
  128. package/clis/xiaohongshu/creator-notes.js +251 -2
  129. package/clis/xiaohongshu/creator-notes.test.js +79 -2
  130. package/clis/xiaohongshu/download.js +97 -39
  131. package/clis/xiaohongshu/download.test.js +201 -0
  132. package/clis/zhihu/answer-comments.js +2 -21
  133. package/clis/zhihu/answer-detail.js +2 -31
  134. package/clis/zhihu/collection.js +2 -14
  135. package/clis/zhihu/collection.test.js +4 -3
  136. package/clis/zhihu/question.js +1 -9
  137. package/clis/zhihu/question.test.js +2 -2
  138. package/clis/zhihu/search.js +1 -12
  139. package/clis/zhihu/search.test.js +2 -2
  140. package/clis/zhihu/text.js +29 -0
  141. package/clis/zhihu/text.test.js +24 -0
  142. package/dist/src/browser/network-cache.js +13 -1
  143. package/dist/src/browser/network-cache.test.js +17 -0
  144. package/dist/src/download/index.js +13 -1
  145. package/dist/src/download/index.test.js +23 -1
  146. package/dist/src/download/media-download.test.js +3 -1
  147. package/dist/src/download/progress.js +2 -2
  148. package/dist/src/download/progress.test.js +12 -1
  149. package/dist/src/output.js +11 -1
  150. package/dist/src/output.test.js +6 -0
  151. package/dist/src/registry.js +1 -0
  152. package/dist/src/registry.test.js +11 -0
  153. package/package.json +1 -1
@@ -1,10 +1,10 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
- import { normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, unwrapBrowserResult } from './shared.js';
3
+ import { looksLikePrivateTwitterTimeline, normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, unwrapBrowserResult } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN } from './utils.js';
5
5
 
6
- const FOLLOWING_QUERY_ID = 'zx6e-TLzRkeDO_a7p4b3JQ'; // Following fallback
7
- const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
6
+ const FOLLOWING_QUERY_ID = 'F42cDX8PDFxkbjjq6JrM2w';
7
+ const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
8
8
  const MAX_PAGINATION_PAGES = 100;
9
9
 
10
10
  const FEATURES = {
@@ -90,9 +90,13 @@ function extractUser(result) {
90
90
  return null;
91
91
  const core = result.core || {};
92
92
  const legacy = result.legacy || {};
93
+ const screenName = core.screen_name || legacy.screen_name || '';
94
+ if (!screenName) {
95
+ throw new CommandExecutionError('Malformed Twitter following user: missing screen_name');
96
+ }
93
97
  return {
94
- screen_name: core.screen_name || legacy.screen_name || 'unknown',
95
- name: core.name || legacy.name || 'unknown',
98
+ screen_name: screenName,
99
+ name: core.name || legacy.name || '',
96
100
  bio: legacy.description || result.profile_bio?.description || '',
97
101
  followers: legacy.followers_count || legacy.normal_followers_count || 0,
98
102
  };
@@ -221,6 +225,7 @@ cli({
221
225
  const allUsers = [];
222
226
  const seen = new Set();
223
227
  let cursor = null;
228
+ let lastRawResponse = null;
224
229
 
225
230
  // Runaway guard only; --limit and cursor exhaustion control normal pagination.
226
231
  for (let i = 0; i < MAX_PAGINATION_PAGES && allUsers.length < limit; i++) {
@@ -235,6 +240,7 @@ cli({
235
240
  throw new AuthRequiredError('x.com', `Twitter following request failed (HTTP ${data.error})`);
236
241
  throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch following list. queryId may have expired.`);
237
242
  }
243
+ lastRawResponse = data;
238
244
  const { users, nextCursor } = parseFollowing(data);
239
245
  for (const u of users) {
240
246
  if (!seen.has(u.screen_name)) {
@@ -248,6 +254,9 @@ cli({
248
254
  }
249
255
 
250
256
  if (allUsers.length === 0) {
257
+ if (looksLikePrivateTwitterTimeline(lastRawResponse)) {
258
+ throw new EmptyResultError('twitter following', `No following data returned for @${targetUser} (the target account may have set their following list to private)`);
259
+ }
251
260
  throw new EmptyResultError('twitter following', `No following accounts found for @${targetUser}`);
252
261
  }
253
262
 
@@ -53,6 +53,27 @@ describe('twitter following helpers', () => {
53
53
  expect(user?.screen_name).toBe('bob');
54
54
  });
55
55
 
56
+ it('typed-fails when upstream omits screen_name identity', () => {
57
+ expect(() => __test__.extractUser({
58
+ __typename: 'User',
59
+ legacy: { description: 'no names', followers_count: 7 },
60
+ })).toThrow(CommandExecutionError);
61
+ });
62
+
63
+ it('surfaces empty name display when upstream omits only name', () => {
64
+ const user = __test__.extractUser({
65
+ __typename: 'User',
66
+ core: { screen_name: 'alice' },
67
+ legacy: { description: 'no display name', followers_count: 7 },
68
+ });
69
+ expect(user).toMatchObject({
70
+ screen_name: 'alice',
71
+ name: '',
72
+ bio: 'no display name',
73
+ followers: 7,
74
+ });
75
+ });
76
+
56
77
  it('parses following timeline with users and cursor', () => {
57
78
  const payload = {
58
79
  data: {
@@ -327,4 +348,12 @@ describe('twitter following command', () => {
327
348
 
328
349
  await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
329
350
  });
351
+
352
+ it('surfaces the private-following privacy hint when result.timeline is empty', async () => {
353
+ const command = getRegistry().get('twitter/following');
354
+ const page = createFollowingPage([{ data: { user: { result: { __typename: 'User', timeline: {} } } } }]);
355
+
356
+ await expect(command.func(page, { user: 'simonw', limit: 10 }))
357
+ .rejects.toMatchObject({ hint: expect.stringContaining('following list to private') });
358
+ });
330
359
  });
@@ -1,9 +1,9 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
- import { normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, extractMedia, unwrapBrowserResult } from './shared.js';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { looksLikePrivateTwitterTimeline, normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, extractMedia, unwrapBrowserResult } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
5
- const LIKES_QUERY_ID = 'RozQdCp4CilQzrcuU0NY5w';
6
- const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
5
+ const LIKES_QUERY_ID = 'CDWHmpZeSdIJ3HGeRbNm0w';
6
+ const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
7
7
  const MAX_PAGINATION_PAGES = 100;
8
8
  const FEATURES = {
9
9
  rweb_video_screen_enabled: false,
@@ -202,6 +202,7 @@ cli({
202
202
  const allTweets = [];
203
203
  const seen = new Set();
204
204
  let cursor = null;
205
+ let lastRawResponse = null;
205
206
  // Runaway guard only; --limit and cursor exhaustion control normal pagination.
206
207
  for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
207
208
  const fetchCount = Math.min(100, limit - allTweets.length + 10);
@@ -215,12 +216,19 @@ cli({
215
216
  throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch likes. queryId may have expired.`);
216
217
  break;
217
218
  }
219
+ lastRawResponse = data;
218
220
  const { tweets, nextCursor } = parseLikes(data, seen);
219
221
  allTweets.push(...tweets);
220
222
  if (!nextCursor || nextCursor === cursor)
221
223
  break;
222
224
  cursor = nextCursor;
223
225
  }
226
+ if (allTweets.length === 0) {
227
+ if (looksLikePrivateTwitterTimeline(lastRawResponse)) {
228
+ throw new EmptyResultError('twitter likes', `No likes returned for @${username} (Likes are private by default on X; only the account owner can view their own likes)`);
229
+ }
230
+ throw new EmptyResultError('twitter likes', `No likes found for @${username}`);
231
+ }
224
232
  const trimmed = allTweets.slice(0, limit);
225
233
  return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
226
234
  },
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
- import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
3
+ import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
4
4
  import { __test__ } from './likes.js';
5
5
 
6
6
  function likesPayload() {
@@ -129,6 +129,31 @@ describe('twitter likes helpers', () => {
129
129
  });
130
130
  });
131
131
 
132
+ describe('twitter likes command', () => {
133
+ it('throws EmptyResultError with privacy message when API returns empty-timeline shape', async () => {
134
+ const command = getRegistry().get('twitter/likes');
135
+ const page = {
136
+ goto: vi.fn().mockResolvedValue(undefined),
137
+ wait: vi.fn().mockResolvedValue(undefined),
138
+ getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]),
139
+ evaluate: vi.fn(async (script) => {
140
+ const text = String(script);
141
+ if (text.includes('AppTabBar_Profile_Link')) return { session: 'site:twitter', data: '/viewer' };
142
+ if (text.includes('operationName')) return null;
143
+ if (text.includes('/UserByScreenName')) return { session: 'site:twitter', data: '42' };
144
+ if (text.includes('/Likes')) {
145
+ return { session: 'site:twitter', data: { data: { user: { result: { __typename: 'User', timeline: {} } } } } };
146
+ }
147
+ throw new Error(`Unexpected evaluate: ${text.slice(0, 80)}`);
148
+ }),
149
+ };
150
+ await expect(command.func(page, { username: 'simonw', limit: 5 }))
151
+ .rejects.toMatchObject({ hint: expect.stringContaining('Likes are private by default on X') });
152
+ await expect(command.func(page, { username: 'simonw', limit: 5 }))
153
+ .rejects.toBeInstanceOf(EmptyResultError);
154
+ });
155
+ });
156
+
132
157
  describe('twitter likes command', () => {
133
158
  it('rejects invalid explicit username before cookies or navigation', async () => {
134
159
  const command = getRegistry().get('twitter/likes');
@@ -4,7 +4,7 @@ import { resolveTwitterQueryId } from './shared.js';
4
4
  import { parseListsManagement } from './lists.js';
5
5
  import { TWITTER_BEARER_TOKEN } from './utils.js';
6
6
 
7
- const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
7
+ const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
8
8
  const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
9
9
  // 2026-05 fallback — X rotates queryIds; resolveTwitterQueryId() does live lookup,
10
10
  // this constant is just the default if live lookup fails.
@@ -4,7 +4,7 @@ import { resolveTwitterQueryId } from './shared.js';
4
4
  import { getListsManagementInstructions, parseListsManagement } from './lists.js';
5
5
  import { TWITTER_BEARER_TOKEN } from './utils.js';
6
6
 
7
- const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
7
+ const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
8
8
  const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
9
9
 
10
10
  const LISTS_MANAGEMENT_FEATURES = {
@@ -72,14 +72,14 @@ cli({
72
72
  return;
73
73
  let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent;
74
74
  let actionText = 'Notification';
75
- let author = 'unknown';
75
+ let author = '';
76
76
  let text = '';
77
77
  let urlStr = '';
78
78
  if (item.__typename === 'TimelineNotification') {
79
79
  text = item.rich_message?.text || item.message?.text || '';
80
80
  const fromUser = item.template?.from_users?.[0]?.user_results?.result;
81
81
  // Twitter moved screen_name from legacy to core
82
- author = fromUser?.core?.screen_name || fromUser?.legacy?.screen_name || 'unknown';
82
+ author = fromUser?.core?.screen_name || fromUser?.legacy?.screen_name || '';
83
83
  urlStr = item.notification_url?.url || '';
84
84
  actionText = item.notification_icon || 'Activity';
85
85
  const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
@@ -94,14 +94,14 @@ cli({
94
94
  else if (item.__typename === 'TweetNotification') {
95
95
  const tweet = item.tweet_result?.result;
96
96
  const tweetUser = tweet?.core?.user_results?.result;
97
- author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
97
+ author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '';
98
98
  text = tweet?.note_tweet?.note_tweet_results?.result?.text || tweet?.legacy?.full_text || item.message?.text || '';
99
99
  actionText = 'Mention/Reply';
100
100
  urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
101
101
  }
102
102
  else if (item.__typename === 'Tweet') {
103
103
  const tweetUser = item.core?.user_results?.result;
104
- author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
104
+ author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '';
105
105
  text = item.note_tweet?.note_tweet_results?.result?.text || item.legacy?.full_text || '';
106
106
  actionText = 'Mention';
107
107
  urlStr = `https://x.com/i/status/${item.rest_id}`;
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { cli, Strategy } from '@jackwener/opencli/registry';
4
4
  import { CommandExecutionError } from '@jackwener/opencli/errors';
5
+ import { isRecoverableFileInputError } from './utils.js';
5
6
 
6
7
  const MAX_IMAGES = 4;
7
8
  const UPLOAD_POLL_MS = 500;
@@ -142,6 +143,55 @@ async function waitForImageUpload(page, expectedCount) {
142
143
  })()`);
143
144
  }
144
145
 
146
+ async function attachImagesViaDataTransfer(page, absPaths) {
147
+ const files = absPaths.map((absPath) => {
148
+ const ext = path.extname(absPath).toLowerCase();
149
+ const mime = ext === '.png'
150
+ ? 'image/png'
151
+ : ext === '.gif'
152
+ ? 'image/gif'
153
+ : ext === '.webp'
154
+ ? 'image/webp'
155
+ : 'image/jpeg';
156
+ return {
157
+ name: path.basename(absPath),
158
+ mime,
159
+ base64: fs.readFileSync(absPath).toString('base64'),
160
+ };
161
+ });
162
+ const upload = await page.evaluate(`(() => {
163
+ const input = document.querySelector(${JSON.stringify(FILE_INPUT_SELECTOR)});
164
+ if (!input) return { ok: false, error: 'No file input found' };
165
+ const dt = new DataTransfer();
166
+ for (const file of ${JSON.stringify(files)}) {
167
+ const bin = atob(file.base64);
168
+ const bytes = new Uint8Array(bin.length);
169
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
170
+ dt.items.add(new File([bytes], file.name, { type: file.mime }));
171
+ }
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 >= ${JSON.stringify(absPaths.length)};
176
+ } catch(e) {
177
+ try {
178
+ const nativeInputFileSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'files');
179
+ if (nativeInputFileSetter && nativeInputFileSetter.set) {
180
+ nativeInputFileSetter.set.call(input, dt.files);
181
+ assigned = input.files && input.files.length >= ${JSON.stringify(absPaths.length)};
182
+ }
183
+ } catch(e2) { /* ignore */ }
184
+ }
185
+ if (!assigned) return { ok: false, error: 'Could not assign files to input' };
186
+ input.dispatchEvent(new Event('change', { bubbles: true }));
187
+ input.dispatchEvent(new Event('input', { bubbles: true }));
188
+ return { ok: true };
189
+ })()`);
190
+ if (!upload?.ok) {
191
+ throw new CommandExecutionError(`Image upload failed (base64 fallback): ${upload?.error ?? 'unknown error'}`);
192
+ }
193
+ }
194
+
145
195
  async function submitTweet(page, text) {
146
196
  const clickResult = await page.evaluate(`(async () => {
147
197
  try {
@@ -224,11 +274,19 @@ cli({
224
274
  // Attach media before inserting text. Uploading media after Draft.js has
225
275
  // text can re-render/reset the editor, causing image-only posts.
226
276
  if (absPaths.length > 0) {
227
- if (!page.setFileInput) {
228
- throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.');
229
- }
230
277
  await page.wait({ selector: FILE_INPUT_SELECTOR, timeout: 20 });
231
- await page.setFileInput(absPaths, FILE_INPUT_SELECTOR);
278
+ if (page.setFileInput) {
279
+ try {
280
+ await page.setFileInput(absPaths, FILE_INPUT_SELECTOR);
281
+ } catch (err) {
282
+ if (!isRecoverableFileInputError(err)) {
283
+ throw err;
284
+ }
285
+ await attachImagesViaDataTransfer(page, absPaths);
286
+ }
287
+ } else {
288
+ await attachImagesViaDataTransfer(page, absPaths);
289
+ }
232
290
  const uploadState = await waitForImageUpload(page, absPaths.length);
233
291
  if (!uploadState?.ok) {
234
292
  return [{ status: 'failed', message: uploadState?.message ?? `Image upload timed out (${UPLOAD_TIMEOUT_MS / 1000}s).`, text }];
@@ -11,6 +11,7 @@ vi.mock('node:fs', async (importOriginal) => {
11
11
  return undefined;
12
12
  return { isFile: () => true };
13
13
  }),
14
+ readFileSync: vi.fn(() => Buffer.from([0x89, 0x50, 0x4e, 0x47])),
14
15
  };
15
16
  });
16
17
 
@@ -123,10 +124,41 @@ describe('twitter post command', () => {
123
124
  await expect(command.func(page, { text: 'hi', images: 'photo.bmp' })).rejects.toThrow('Unsupported image format');
124
125
  });
125
126
 
126
- it('throws when page.setFileInput is not available', async () => {
127
+ it('falls back to DataTransfer upload when page.setFileInput is not available', async () => {
127
128
  const command = getCommand();
128
- const page = makePage([], { setFileInput: undefined });
129
- await expect(command.func(page, { text: 'hi', images: 'a.png' })).rejects.toThrow('Browser extension does not support file upload');
129
+ const page = makePage([
130
+ { ok: true }, // DataTransfer fallback
131
+ { ok: true, previewCount: 1 }, // upload polling
132
+ { ok: true }, // focus composer
133
+ { ok: true }, // verify native insertText
134
+ { ok: true }, // click post
135
+ { ok: true, message: 'Tweet posted successfully.' },
136
+ ], { setFileInput: undefined });
137
+
138
+ const result = await command.func(page, { text: 'hi', images: 'a.png' });
139
+
140
+ expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'hi' }]);
141
+ expect(page.evaluate.mock.calls[0][0]).toContain('new DataTransfer()');
142
+ expect(page.evaluate.mock.calls[0][0]).toContain('Could not assign files to input');
143
+ });
144
+
145
+ it('falls back to DataTransfer upload when CDP rejects file input as not allowed', async () => {
146
+ const command = getCommand();
147
+ const setFileInput = vi.fn().mockRejectedValue(new Error('NotAllowedError: Not allowed'));
148
+ const page = makePage([
149
+ { ok: true }, // DataTransfer fallback
150
+ { ok: true, previewCount: 1 }, // upload polling
151
+ { ok: true }, // focus composer
152
+ { ok: true }, // verify native insertText
153
+ { ok: true }, // click post
154
+ { ok: true, message: 'Tweet posted successfully.' },
155
+ ], { setFileInput });
156
+
157
+ const result = await command.func(page, { text: 'with fallback', images: 'a.png' });
158
+
159
+ expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'with fallback' }]);
160
+ expect(setFileInput).toHaveBeenCalledWith(['/abs/a.png'], 'input[type="file"][data-testid="fileInput"]');
161
+ expect(page.evaluate.mock.calls[0][0]).toContain('new DataTransfer()');
130
162
  });
131
163
 
132
164
  it('uploads images before inserting text so media re-renders cannot erase the tweet text', async () => {
@@ -1,8 +1,48 @@
1
- import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { normalizeTwitterScreenName, resolveTwitterQueryId, unwrapBrowserResult } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN } from './utils.js';
5
- const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
5
+ const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
6
+
7
+ function isPlainObject(value) {
8
+ return value != null && typeof value === 'object' && !Array.isArray(value);
9
+ }
10
+
11
+ function stringField(value) {
12
+ return typeof value === 'string' ? value : '';
13
+ }
14
+
15
+ export function mapTwitterProfileResult(result, screenName) {
16
+ if (!isPlainObject(result)) {
17
+ throw new CommandExecutionError(`Twitter profile response for @${screenName} is malformed`);
18
+ }
19
+ const hasLegacy = isPlainObject(result.legacy);
20
+ const hasCore = isPlainObject(result.core);
21
+ if (!hasLegacy && !hasCore) {
22
+ throw new CommandExecutionError(`Twitter profile response for @${screenName} is missing profile fields`);
23
+ }
24
+ const legacy = hasLegacy ? result.legacy : {};
25
+ const core = hasCore ? result.core : {};
26
+ if (!stringField(core.screen_name) && !stringField(legacy.screen_name) && !stringField(core.name) && !stringField(legacy.name) && !stringField(core.created_at) && !stringField(legacy.created_at)) {
27
+ throw new CommandExecutionError(`Twitter profile response for @${screenName} is missing profile identity fields`);
28
+ }
29
+ const location = isPlainObject(result.location) ? result.location : {};
30
+ const expandedUrl = legacy.entities?.url?.urls?.[0]?.expanded_url || '';
31
+ return [{
32
+ screen_name: stringField(core.screen_name) || stringField(legacy.screen_name) || screenName,
33
+ name: stringField(core.name) || stringField(legacy.name),
34
+ bio: stringField(legacy.description),
35
+ location: stringField(location.location) || stringField(legacy.location),
36
+ url: stringField(expandedUrl),
37
+ followers: legacy.followers_count || 0,
38
+ following: legacy.friends_count || 0,
39
+ tweets: legacy.statuses_count || 0,
40
+ likes: legacy.favourites_count || 0,
41
+ verified: Boolean(result.is_blue_verified || legacy.verified),
42
+ created_at: stringField(core.created_at) || stringField(legacy.created_at),
43
+ }];
44
+ }
45
+
6
46
  cli({
7
47
  site: 'twitter',
8
48
  name: 'profile',
@@ -46,7 +86,7 @@ cli({
46
86
  if (!ct0)
47
87
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
48
88
  const queryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
49
- const result = await page.evaluate(`
89
+ const rawResult = unwrapBrowserResult(await page.evaluate(`
50
90
  async () => {
51
91
  const screenName = "${username}";
52
92
  const ct0 = ${JSON.stringify(ct0)};
@@ -82,34 +122,47 @@ cli({
82
122
  + encodeURIComponent(variables)
83
123
  + '&features=' + encodeURIComponent(features);
84
124
 
85
- const resp = await fetch(url, {headers, credentials: 'include'});
86
- if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'User may not exist or queryId expired'};
87
- const d = await resp.json();
125
+ let resp;
126
+ try {
127
+ resp = await fetch(url, {headers, credentials: 'include'});
128
+ } catch (error) {
129
+ return {ok: false, error: 'Twitter profile request failed: ' + String(error && error.message || error)};
130
+ }
131
+ if (!resp.ok) {
132
+ return {
133
+ ok: false,
134
+ auth: resp.status === 401 || resp.status === 403,
135
+ error: 'HTTP ' + resp.status,
136
+ hint: 'User may not exist, auth may be required, or queryId expired'
137
+ };
138
+ }
139
+ let d;
140
+ try {
141
+ d = await resp.json();
142
+ } catch (error) {
143
+ return {ok: false, error: 'Twitter profile response was not JSON: ' + String(error && error.message || error)};
144
+ }
88
145
 
89
146
  const result = d.data?.user?.result;
90
- if (!result) return {error: 'User @' + screenName + ' not found'};
91
-
92
- const legacy = result.legacy || {};
93
- const expandedUrl = legacy.entities?.url?.urls?.[0]?.expanded_url || '';
94
-
95
- return [{
96
- screen_name: legacy.screen_name || screenName,
97
- name: legacy.name || '',
98
- bio: legacy.description || '',
99
- location: legacy.location || '',
100
- url: expandedUrl,
101
- followers: legacy.followers_count || 0,
102
- following: legacy.friends_count || 0,
103
- tweets: legacy.statuses_count || 0,
104
- likes: legacy.favourites_count || 0,
105
- verified: result.is_blue_verified || legacy.verified || false,
106
- created_at: legacy.created_at || '',
107
- }];
147
+ if (!result) return {ok: false, notFound: true, error: 'User @' + screenName + ' not found'};
148
+ return {ok: true, result};
108
149
  }
109
- `);
110
- if (result?.error) {
111
- throw new CommandExecutionError(result.error + (result.hint ? ` (${result.hint})` : ''));
150
+ `));
151
+ if (!isPlainObject(rawResult)) {
152
+ throw new CommandExecutionError('Twitter profile response payload is malformed');
153
+ }
154
+ if (!rawResult.ok) {
155
+ const message = rawResult.error + (rawResult.hint ? ` (${rawResult.hint})` : '');
156
+ if (rawResult.auth) {
157
+ throw new AuthRequiredError('x.com', message);
158
+ }
159
+ if (rawResult.notFound) {
160
+ throw new EmptyResultError('twitter profile', message);
161
+ }
162
+ throw new CommandExecutionError(message);
112
163
  }
113
- return result || [];
164
+ return mapTwitterProfileResult(rawResult.result, username);
114
165
  }
115
166
  });
167
+
168
+ export const __test__ = { mapTwitterProfileResult };
@@ -1,9 +1,73 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
- import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
4
- import './profile.js';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import { __test__ } from './profile.js';
5
5
 
6
6
  describe('twitter profile command', () => {
7
+ it('maps current result.core profile fields while preserving legacy fallback fields', () => {
8
+ const rows = __test__.mapTwitterProfileResult({
9
+ core: {
10
+ screen_name: 'AstroHanRay',
11
+ name: 'AstroHan',
12
+ created_at: 'Sun Mar 20 00:00:00 +0000 2011',
13
+ },
14
+ legacy: {
15
+ screen_name: null,
16
+ name: null,
17
+ description: 'bio text',
18
+ location: 'legacy location',
19
+ followers_count: 117,
20
+ friends_count: 12,
21
+ statuses_count: 30,
22
+ favourites_count: 4,
23
+ verified: false,
24
+ entities: { url: { urls: [{ expanded_url: 'https://example.com' }] } },
25
+ },
26
+ location: { location: 'core location' },
27
+ is_blue_verified: true,
28
+ }, 'fallback');
29
+
30
+ expect(rows).toEqual([{
31
+ screen_name: 'AstroHanRay',
32
+ name: 'AstroHan',
33
+ bio: 'bio text',
34
+ location: 'core location',
35
+ url: 'https://example.com',
36
+ followers: 117,
37
+ following: 12,
38
+ tweets: 30,
39
+ likes: 4,
40
+ verified: true,
41
+ created_at: 'Sun Mar 20 00:00:00 +0000 2011',
42
+ }]);
43
+ });
44
+
45
+ it('falls back to legacy profile fields for older UserByScreenName responses', () => {
46
+ const rows = __test__.mapTwitterProfileResult({
47
+ legacy: {
48
+ screen_name: 'legacy_user',
49
+ name: 'Legacy Name',
50
+ created_at: 'Wed Jan 01 00:00:00 +0000 2020',
51
+ location: 'legacy location',
52
+ },
53
+ }, 'fallback');
54
+
55
+ expect(rows[0]).toMatchObject({
56
+ screen_name: 'legacy_user',
57
+ name: 'Legacy Name',
58
+ created_at: 'Wed Jan 01 00:00:00 +0000 2020',
59
+ location: 'legacy location',
60
+ });
61
+ });
62
+
63
+ it('throws typed when the profile result is structurally malformed', () => {
64
+ expect(() => __test__.mapTwitterProfileResult(null, 'jack')).toThrow(CommandExecutionError);
65
+ expect(() => __test__.mapTwitterProfileResult([], 'jack')).toThrow(CommandExecutionError);
66
+ expect(() => __test__.mapTwitterProfileResult({}, 'jack')).toThrow(CommandExecutionError);
67
+ expect(() => __test__.mapTwitterProfileResult({ __typename: 'UserUnavailable' }, 'jack')).toThrow(CommandExecutionError);
68
+ expect(() => __test__.mapTwitterProfileResult({ legacy: {}, core: {} }, 'jack')).toThrow(CommandExecutionError);
69
+ });
70
+
7
71
  it('rejects invalid explicit usernames before navigation', async () => {
8
72
  const command = getRegistry().get('twitter/profile');
9
73
  const page = {
@@ -36,4 +100,51 @@ describe('twitter profile command', () => {
36
100
  expect(page.goto).toHaveBeenCalledTimes(1);
37
101
  expect(page.getCookies).not.toHaveBeenCalled();
38
102
  });
103
+
104
+ it('unwraps Browser Bridge envelopes around UserByScreenName payloads', async () => {
105
+ const command = getRegistry().get('twitter/profile');
106
+ const page = {
107
+ goto: vi.fn().mockResolvedValue(undefined),
108
+ wait: vi.fn().mockResolvedValue(undefined),
109
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf' }]),
110
+ evaluate: vi.fn()
111
+ .mockResolvedValueOnce(null)
112
+ .mockResolvedValueOnce({
113
+ session: 'site:twitter',
114
+ data: {
115
+ ok: true,
116
+ result: {
117
+ core: { screen_name: 'core_user', name: 'Core User', created_at: 'now' },
118
+ legacy: { description: 'bio' },
119
+ },
120
+ },
121
+ }),
122
+ };
123
+
124
+ await expect(command.func(page, { username: 'core_user' })).resolves.toEqual([
125
+ expect.objectContaining({
126
+ screen_name: 'core_user',
127
+ name: 'Core User',
128
+ bio: 'bio',
129
+ created_at: 'now',
130
+ }),
131
+ ]);
132
+ });
133
+
134
+ it('maps GraphQL auth and not-found envelopes to typed failures', async () => {
135
+ const command = getRegistry().get('twitter/profile');
136
+ const createPage = (payload) => ({
137
+ goto: vi.fn().mockResolvedValue(undefined),
138
+ wait: vi.fn().mockResolvedValue(undefined),
139
+ getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'csrf' }]),
140
+ evaluate: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(payload),
141
+ });
142
+
143
+ await expect(command.func(createPage({ ok: false, auth: true, error: 'HTTP 401' }), { username: 'jack' }))
144
+ .rejects.toBeInstanceOf(AuthRequiredError);
145
+ await expect(command.func(createPage({ ok: false, notFound: true, error: 'User @missing not found' }), { username: 'missing' }))
146
+ .rejects.toBeInstanceOf(EmptyResultError);
147
+ await expect(command.func(createPage({ session: 'site:twitter', data: [] }), { username: 'jack' }))
148
+ .rejects.toBeInstanceOf(CommandExecutionError);
149
+ });
39
150
  });
@@ -62,10 +62,15 @@ async function submitQuote(page, text, tweetId) {
62
62
  return { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' };
63
63
  }
64
64
 
65
- const buttons = Array.from(
66
- document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
67
- );
68
- const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
65
+ let btn = null;
66
+ for (let i = 0; i < 30; i++) {
67
+ const buttons = Array.from(
68
+ document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
69
+ );
70
+ btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
71
+ if (btn) break;
72
+ await new Promise(r => setTimeout(r, 500));
73
+ }
69
74
  if (!btn) {
70
75
  return { ok: false, message: 'Tweet button is disabled or not found.' };
71
76
  }