@jackwener/opencli 1.7.18 → 1.7.19

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 (95) hide show
  1. package/README.md +7 -8
  2. package/README.zh-CN.md +7 -8
  3. package/cli-manifest.json +305 -9
  4. package/clis/ctrip/ctrip.test.js +486 -1
  5. package/clis/ctrip/flight.js +136 -0
  6. package/clis/ctrip/hotel-search.js +132 -0
  7. package/clis/ctrip/utils.js +298 -0
  8. package/clis/google/search.js +16 -6
  9. package/clis/google-scholar/search.js +20 -5
  10. package/clis/google-scholar/search.test.js +35 -2
  11. package/clis/reddit/home.js +117 -0
  12. package/clis/reddit/home.test.js +127 -0
  13. package/clis/reddit/read.js +400 -54
  14. package/clis/reddit/read.test.js +315 -12
  15. package/clis/reddit/subreddit-info.js +117 -0
  16. package/clis/reddit/subreddit-info.test.js +163 -0
  17. package/clis/reddit/whoami.js +84 -0
  18. package/clis/reddit/whoami.test.js +105 -0
  19. package/clis/rednote/search.js +6 -2
  20. package/clis/twitter/bookmark-folder.js +3 -1
  21. package/clis/twitter/bookmarks.js +3 -1
  22. package/clis/twitter/followers.js +20 -5
  23. package/clis/twitter/followers.test.js +44 -0
  24. package/clis/twitter/following.js +36 -20
  25. package/clis/twitter/following.test.js +60 -8
  26. package/clis/twitter/likes.js +28 -13
  27. package/clis/twitter/likes.test.js +111 -1
  28. package/clis/twitter/list-add.js +128 -204
  29. package/clis/twitter/list-add.test.js +97 -1
  30. package/clis/twitter/list-tweets.js +13 -4
  31. package/clis/twitter/list-tweets.test.js +48 -0
  32. package/clis/twitter/lists.js +5 -2
  33. package/clis/twitter/post.js +23 -4
  34. package/clis/twitter/post.test.js +30 -0
  35. package/clis/twitter/profile.js +16 -8
  36. package/clis/twitter/profile.test.js +39 -0
  37. package/clis/twitter/reply.js +133 -10
  38. package/clis/twitter/reply.test.js +55 -0
  39. package/clis/twitter/search.js +188 -170
  40. package/clis/twitter/search.test.js +96 -258
  41. package/clis/twitter/shared.js +167 -16
  42. package/clis/twitter/shared.test.js +102 -1
  43. package/clis/twitter/timeline.js +3 -1
  44. package/clis/twitter/tweets.js +147 -51
  45. package/clis/twitter/tweets.test.js +238 -1
  46. package/clis/xiaohongshu/comments.js +23 -2
  47. package/clis/xiaohongshu/comments.test.js +63 -1
  48. package/clis/xiaohongshu/search.js +168 -13
  49. package/clis/xiaohongshu/search.test.js +82 -8
  50. package/clis/xueqiu/earnings-date.js +2 -2
  51. package/clis/xueqiu/kline.js +2 -2
  52. package/clis/xueqiu/utils.js +19 -0
  53. package/clis/xueqiu/utils.test.js +26 -0
  54. package/clis/zhihu/answer-detail.js +233 -0
  55. package/clis/zhihu/answer-detail.test.js +330 -0
  56. package/clis/zhihu/question.js +44 -10
  57. package/clis/zhihu/question.test.js +78 -1
  58. package/clis/zhihu/recommend.js +103 -0
  59. package/clis/zhihu/recommend.test.js +143 -0
  60. package/dist/src/browser/base-page.d.ts +3 -2
  61. package/dist/src/browser/base-page.test.js +2 -2
  62. package/dist/src/browser/cdp.js +3 -3
  63. package/dist/src/browser/page.d.ts +3 -2
  64. package/dist/src/browser/page.js +4 -4
  65. package/dist/src/browser/page.test.js +31 -0
  66. package/dist/src/browser/utils.d.ts +10 -0
  67. package/dist/src/browser/utils.js +37 -0
  68. package/dist/src/browser/utils.test.d.ts +1 -0
  69. package/dist/src/browser/utils.test.js +29 -0
  70. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  71. package/dist/src/cli-argv-preprocess.js +131 -0
  72. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  73. package/dist/src/cli-argv-preprocess.test.js +130 -0
  74. package/dist/src/cli.js +123 -86
  75. package/dist/src/cli.test.js +33 -28
  76. package/dist/src/commands/daemon.js +6 -7
  77. package/dist/src/doctor.js +15 -16
  78. package/dist/src/download/progress.js +15 -11
  79. package/dist/src/download/progress.test.d.ts +1 -0
  80. package/dist/src/download/progress.test.js +25 -0
  81. package/dist/src/execution.js +1 -3
  82. package/dist/src/execution.test.js +4 -16
  83. package/dist/src/help.d.ts +11 -0
  84. package/dist/src/help.js +46 -5
  85. package/dist/src/logger.js +8 -9
  86. package/dist/src/main.js +16 -0
  87. package/dist/src/output.js +4 -5
  88. package/dist/src/runtime-detect.d.ts +1 -1
  89. package/dist/src/runtime-detect.js +1 -1
  90. package/dist/src/runtime-detect.test.js +3 -2
  91. package/dist/src/tui.d.ts +0 -1
  92. package/dist/src/tui.js +9 -22
  93. package/dist/src/types.d.ts +3 -1
  94. package/dist/src/update-check.js +4 -5
  95. package/package.json +5 -4
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
- import './list-add.js';
3
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import { buildListAddMemberRow } from './list-add.js';
4
5
 
5
6
  describe('twitter list-add registration', () => {
6
7
  it('registers the list-add command with the expected shape', () => {
@@ -34,4 +35,99 @@ describe('twitter list-add registration', () => {
34
35
  expect(page.wait).toHaveBeenCalledWith(3);
35
36
  expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
36
37
  });
38
+
39
+ it('rejects invalid user input before navigation', async () => {
40
+ const cmd = getRegistry().get('twitter/list-add');
41
+ const page = {
42
+ goto: vi.fn(),
43
+ wait: vi.fn(),
44
+ getCookies: vi.fn(),
45
+ evaluate: vi.fn(),
46
+ };
47
+
48
+ await expect(cmd.func(page, { listId: 'abc', username: 'alice' })).rejects.toBeInstanceOf(ArgumentError);
49
+ await expect(cmd.func(page, { listId: '123', username: '' })).rejects.toBeInstanceOf(ArgumentError);
50
+ expect(page.goto).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('builds success rows when member_count increases despite non-fatal decode errors', () => {
54
+ const row = buildListAddMemberRow({
55
+ addResult: {
56
+ httpOk: true,
57
+ status: 200,
58
+ mc: 11,
59
+ isMember: true,
60
+ errors: [{ path: ['data', 'list', 'default_banner_media_results'], message: 'decode failed' }],
61
+ },
62
+ memberCountBefore: 10,
63
+ listId: '123',
64
+ username: 'alice',
65
+ userId: '42',
66
+ });
67
+
68
+ expect(row).toMatchObject({
69
+ listId: '123',
70
+ username: 'alice',
71
+ userId: '42',
72
+ status: 'success',
73
+ });
74
+ expect(row.message).toContain('member_count 10 → 11');
75
+ });
76
+
77
+ it('treats unchanged member_count as noop only when membership is confirmed', () => {
78
+ const row = buildListAddMemberRow({
79
+ addResult: { httpOk: true, status: 200, mc: 10, isMember: true, errors: null },
80
+ memberCountBefore: 10,
81
+ listId: '123',
82
+ username: 'alice',
83
+ userId: '42',
84
+ });
85
+
86
+ expect(row.status).toBe('noop');
87
+ expect(row.message).toBe('@alice is already a member of list 123');
88
+ });
89
+
90
+ it('fails typed when unchanged member_count does not confirm membership', () => {
91
+ expect(() => buildListAddMemberRow({
92
+ addResult: { httpOk: true, status: 200, mc: 10, isMember: false, errors: null },
93
+ memberCountBefore: 10,
94
+ listId: '123',
95
+ username: 'alice',
96
+ userId: '42',
97
+ })).toThrow(CommandExecutionError);
98
+ });
99
+
100
+ it('fails typed when member_count decreases unexpectedly', () => {
101
+ expect(() => buildListAddMemberRow({
102
+ addResult: { httpOk: true, status: 200, mc: 9, isMember: true, errors: null },
103
+ memberCountBefore: 10,
104
+ listId: '123',
105
+ username: 'alice',
106
+ userId: '42',
107
+ })).toThrow(/decreased unexpectedly/);
108
+ });
109
+
110
+ it('fails typed when GraphQL response has no usable member_count', () => {
111
+ expect(() => buildListAddMemberRow({
112
+ addResult: {
113
+ httpOk: true,
114
+ status: 200,
115
+ mc: undefined,
116
+ isMember: null,
117
+ errors: [{ message: 'List is unavailable', path: ['data', 'list'] }],
118
+ },
119
+ memberCountBefore: 10,
120
+ listId: '123',
121
+ username: 'alice',
122
+ userId: '42',
123
+ })).toThrow(/List is unavailable/);
124
+
125
+ expect(() => buildListAddMemberRow({
126
+ addResult: { httpOk: true, status: 200, mc: null, isMember: null, errors: { message: 'not an array' } },
127
+ memberCountBefore: 10,
128
+ listId: '123',
129
+ username: 'alice',
130
+ userId: '42',
131
+ })).toThrow(/no member_count/);
132
+ });
37
133
  });
@@ -1,9 +1,11 @@
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
4
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
4
5
 
5
6
  const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
6
7
  const OPERATION_NAME = 'ListLatestTweetsTimeline';
8
+ const MAX_PAGINATION_PAGES = 100;
7
9
 
8
10
  const FEATURES = {
9
11
  rweb_video_screen_enabled: false,
@@ -70,6 +72,7 @@ export function extractTimelineTweet(result, seen) {
70
72
  replies: legacy.reply_count || 0,
71
73
  created_at: legacy.created_at || '',
72
74
  url: `https://x.com/${screenName}/status/${tw.rest_id}`,
75
+ ...extractMedia(legacy),
73
76
  };
74
77
  }
75
78
 
@@ -118,7 +121,7 @@ cli({
118
121
  { name: 'limit', type: 'int', default: 50 },
119
122
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the list 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 the list\'s native (recency) ordering.' },
120
123
  ],
121
- columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url'],
124
+ columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url', 'has_media', 'media_urls'],
122
125
  func: async (page, kwargs) => {
123
126
  const listId = String(kwargs.listId || '').trim();
124
127
  if (!listId || !/^\d+$/.test(listId)) {
@@ -129,7 +132,11 @@ cli({
129
132
  const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
130
133
  if (!ct0)
131
134
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
132
- const queryId = await page.evaluate(`async () => {
135
+ // opencli >=1.7.x wraps primitive page.evaluate returns as { session, data: <value> }.
136
+ // Without unwrap, the string queryId becomes "[object Object]" when interpolated into the URL,
137
+ // causing HTTP 400 "queryId may have expired".
138
+ const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v);
139
+ const queryIdRaw = await page.evaluate(`async () => {
133
140
  try {
134
141
  const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
135
142
  if (ghResp.ok) {
@@ -152,7 +159,8 @@ cli({
152
159
  }
153
160
  } catch {}
154
161
  return null;
155
- }`) || LIST_TWEETS_QUERY_ID;
162
+ }`);
163
+ const queryId = unwrap(queryIdRaw) || LIST_TWEETS_QUERY_ID;
156
164
  const headers = JSON.stringify({
157
165
  'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
158
166
  'X-Csrf-Token': ct0,
@@ -162,7 +170,8 @@ cli({
162
170
  const allTweets = [];
163
171
  const seen = new Set();
164
172
  let cursor = null;
165
- for (let i = 0; i < 10 && allTweets.length < limit; i++) {
173
+ // Runaway guard only; --limit and cursor exhaustion control normal pagination.
174
+ for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
166
175
  const fetchCount = Math.min(100, limit - allTweets.length + 10);
167
176
  const apiUrl = buildUrl(queryId, listId, fetchCount, cursor);
168
177
  const data = await page.evaluate(`async () => {
@@ -30,9 +30,57 @@ describe('twitter list-tweets parser', () => {
30
30
  replies: 2,
31
31
  created_at: 'Wed Apr 16 10:00:00 +0000 2026',
32
32
  url: 'https://x.com/bob/status/99',
33
+ has_media: false,
34
+ media_urls: [],
33
35
  });
34
36
  });
35
37
 
38
+ it('includes photo media URLs from extended_entities', () => {
39
+ const tweet = extractTimelineTweet({
40
+ rest_id: '101',
41
+ legacy: {
42
+ full_text: 'pic post',
43
+ extended_entities: {
44
+ media: [
45
+ { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/abc.jpg' },
46
+ { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/def.jpg' },
47
+ ],
48
+ },
49
+ },
50
+ core: { user_results: { result: { legacy: { screen_name: 'dave' } } } },
51
+ }, new Set());
52
+ expect(tweet?.has_media).toBe(true);
53
+ expect(tweet?.media_urls).toEqual([
54
+ 'https://pbs.twimg.com/media/abc.jpg',
55
+ 'https://pbs.twimg.com/media/def.jpg',
56
+ ]);
57
+ });
58
+
59
+ it('extracts mp4 variant URL for video media', () => {
60
+ const tweet = extractTimelineTweet({
61
+ rest_id: '102',
62
+ legacy: {
63
+ full_text: 'video post',
64
+ extended_entities: {
65
+ media: [{
66
+ type: 'video',
67
+ media_url_https: 'https://pbs.twimg.com/amplify_video_thumb/thumb.jpg',
68
+ video_info: {
69
+ variants: [
70
+ { content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/playlist.m3u8' },
71
+ { content_type: 'video/mp4', bitrate: 832000, url: 'https://video.twimg.com/low.mp4' },
72
+ { content_type: 'video/mp4', bitrate: 2176000, url: 'https://video.twimg.com/high.mp4' },
73
+ ],
74
+ },
75
+ }],
76
+ },
77
+ },
78
+ core: { user_results: { result: { legacy: { screen_name: 'erin' } } } },
79
+ }, new Set());
80
+ expect(tweet?.has_media).toBe(true);
81
+ expect(tweet?.media_urls?.[0]).toMatch(/\.mp4$/);
82
+ });
83
+
36
84
  it('prefers long-form note_tweet text over truncated legacy full_text', () => {
37
85
  const tweet = extractTimelineTweet({
38
86
  rest_id: '100',
@@ -103,7 +103,9 @@ export const command = cli({
103
103
  const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
104
104
  if (!ct0)
105
105
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
106
- const queryId = await page.evaluate(`async () => {
106
+ // opencli >=1.7.x wraps primitive page.evaluate returns as { session, data: <value> }.
107
+ const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v);
108
+ const queryIdRaw = await page.evaluate(`async () => {
107
109
  try {
108
110
  const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
109
111
  if (ghResp.ok) {
@@ -126,7 +128,8 @@ export const command = cli({
126
128
  }
127
129
  } catch {}
128
130
  return null;
129
- }`) || LISTS_QUERY_ID;
131
+ }`);
132
+ const queryId = unwrap(queryIdRaw) || LISTS_QUERY_ID;
130
133
  const headers = JSON.stringify({
131
134
  'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
132
135
  'X-Csrf-Token': ct0,
@@ -161,12 +161,25 @@ async function submitTweet(page, text) {
161
161
  const normalize = s => String(s || '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
162
162
  const expectedText = normalize(expected);
163
163
  const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
164
+ const statusUrl = (root = document) => {
165
+ const links = Array.from(root.querySelectorAll('a[href*="/status/"]'));
166
+ for (const link of links) {
167
+ const href = link.href || link.getAttribute('href') || '';
168
+ if (!href) continue;
169
+ try {
170
+ const url = new URL(href, window.location.origin);
171
+ const match = url.pathname.match(/^\\/(?:[^/]+|i)\\/status\\/(\\d+)/);
172
+ if (match) return { url: url.href, id: match[1] };
173
+ } catch {}
174
+ }
175
+ return {};
176
+ };
164
177
  for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
165
178
  await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)}));
166
179
  const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
167
180
  .filter((el) => visible(el));
168
181
  const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
169
- if (successToast) return { ok: true, message: 'Tweet posted successfully.' };
182
+ if (successToast) return { ok: true, message: 'Tweet posted successfully.', ...statusUrl(successToast) };
170
183
  const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
171
184
  if (alert) return { ok: false, message: (alert.textContent || 'Tweet failed to post.').trim() };
172
185
 
@@ -175,7 +188,7 @@ async function submitTweet(page, text) {
175
188
  const hasMedia = !!document.querySelector('[data-testid="attachments"], [data-testid="tweetPhoto"]')
176
189
  || document.querySelectorAll('img[src^="blob:"], video[src^="blob:"]').length > 0;
177
190
  if (!composerStillHasText && !hasMedia) {
178
- return { ok: true, message: 'Tweet posted successfully.' };
191
+ return { ok: true, message: 'Tweet posted successfully.', ...statusUrl() };
179
192
  }
180
193
  }
181
194
  return { ok: false, message: 'Tweet submission did not complete before timeout.' };
@@ -194,7 +207,7 @@ cli({
194
207
  { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of the tweet' },
195
208
  { name: 'images', type: 'string', required: false, help: 'Image paths, comma-separated, max 4 (jpg/png/gif/webp)' },
196
209
  ],
197
- columns: ['status', 'message', 'text'],
210
+ columns: ['status', 'message', 'text', 'id', 'url'],
198
211
  func: async (page, kwargs) => {
199
212
  if (!page)
200
213
  throw new CommandExecutionError('Browser session required for twitter post');
@@ -231,6 +244,12 @@ cli({
231
244
 
232
245
  await page.wait(1);
233
246
  const result = await submitTweet(page, text);
234
- return [{ status: result?.ok ? 'success' : 'failed', message: result?.message ?? 'Tweet failed to post.', text }];
247
+ return [{
248
+ status: result?.ok ? 'success' : 'failed',
249
+ message: result?.message ?? 'Tweet failed to post.',
250
+ text,
251
+ ...(result?.id ? { id: result.id } : {}),
252
+ ...(result?.url ? { url: result.url } : {}),
253
+ }];
235
254
  }
236
255
  });
@@ -46,6 +46,11 @@ function makePage(evaluateResults = [], overrides = {}) {
46
46
  describe('twitter post command', () => {
47
47
  const getCommand = () => getRegistry().get('twitter/post');
48
48
 
49
+ it('registers created tweet id/url columns', () => {
50
+ const command = getCommand();
51
+ expect(command?.columns).toEqual(['status', 'message', 'text', 'id', 'url']);
52
+ });
53
+
49
54
  it('posts text-only tweet successfully through the current compose route', async () => {
50
55
  const command = getCommand();
51
56
  const page = makePage([
@@ -63,6 +68,31 @@ describe('twitter post command', () => {
63
68
  expect(page.insertText).toHaveBeenCalledWith('hello world');
64
69
  });
65
70
 
71
+ it('returns the created tweet URL from the success toast when available', async () => {
72
+ const command = getCommand();
73
+ const page = makePage([
74
+ { ok: true },
75
+ { ok: true },
76
+ { ok: true },
77
+ {
78
+ ok: true,
79
+ message: 'Tweet posted successfully.',
80
+ id: '2054239044884693381',
81
+ url: 'https://x.com/darthjajaj6z/status/2054239044884693381',
82
+ },
83
+ ]);
84
+
85
+ const result = await command.func(page, { text: 'with url' });
86
+
87
+ expect(result).toEqual([{
88
+ status: 'success',
89
+ message: 'Tweet posted successfully.',
90
+ text: 'with url',
91
+ id: '2054239044884693381',
92
+ url: 'https://x.com/darthjajaj6z/status/2054239044884693381',
93
+ }]);
94
+ });
95
+
66
96
  it('returns failed when text area not found', async () => {
67
97
  const command = getCommand();
68
98
  const page = makePage([
@@ -1,6 +1,6 @@
1
- import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
- import { resolveTwitterQueryId } from './shared.js';
3
+ import { normalizeTwitterScreenName, resolveTwitterQueryId, unwrapBrowserResult } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN } from './utils.js';
5
5
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
6
6
  cli({
@@ -17,18 +17,26 @@ cli({
17
17
  ],
18
18
  columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'],
19
19
  func: async (page, kwargs) => {
20
- let username = (kwargs.username || '').replace(/^@/, '');
21
- // If no username, detect the logged-in user
20
+ const rawUsername = String(kwargs.username ?? '').trim();
21
+ let username = normalizeTwitterScreenName(rawUsername);
22
+ if (rawUsername && !username) {
23
+ throw new ArgumentError('twitter profile username must be a valid Twitter/X handle', 'Example: opencli twitter profile @jack');
24
+ }
25
+ // If no username, detect the logged-in user.
26
+ // Bridge wraps primitive page.evaluate returns as { session, data:<value> };
27
+ // unwrap so the href string is usable downstream.
22
28
  if (!username) {
23
29
  await page.goto('https://x.com/home');
24
30
  await page.wait({ selector: '[data-testid="primaryColumn"]' });
25
- const href = await page.evaluate(`() => {
31
+ const href = unwrapBrowserResult(await page.evaluate(`() => {
26
32
  const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
27
33
  return link ? link.getAttribute('href') : null;
28
- }`);
29
- if (!href)
34
+ }`));
35
+ if (!href || typeof href !== 'string')
36
+ throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
37
+ username = normalizeTwitterScreenName(href);
38
+ if (!username)
30
39
  throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
31
- username = href.replace('/', '');
32
40
  }
33
41
  // Navigate directly to the user's profile page (gives us cookie context)
34
42
  await page.goto(`https://x.com/${username}`);
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
4
+ import './profile.js';
5
+
6
+ describe('twitter profile command', () => {
7
+ it('rejects invalid explicit usernames before navigation', async () => {
8
+ const command = getRegistry().get('twitter/profile');
9
+ const page = {
10
+ goto: vi.fn(),
11
+ wait: vi.fn(),
12
+ getCookies: vi.fn(),
13
+ evaluate: vi.fn(),
14
+ };
15
+
16
+ await expect(command.func(page, { username: 'viewer/extra' })).rejects.toBeInstanceOf(ArgumentError);
17
+ expect(page.goto).not.toHaveBeenCalled();
18
+ expect(page.getCookies).not.toHaveBeenCalled();
19
+ expect(page.evaluate).not.toHaveBeenCalled();
20
+ });
21
+
22
+ it('rejects route-like AppTabBar hrefs instead of navigating to that route profile', async () => {
23
+ const command = getRegistry().get('twitter/profile');
24
+ const page = {
25
+ goto: vi.fn().mockResolvedValue(undefined),
26
+ wait: vi.fn().mockResolvedValue(undefined),
27
+ getCookies: vi.fn(),
28
+ evaluate: vi.fn(async (script) => {
29
+ if (String(script).includes('AppTabBar_Profile_Link')) return '/home';
30
+ throw new Error(`Unexpected evaluate: ${String(script).slice(0, 80)}`);
31
+ }),
32
+ };
33
+
34
+ await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
35
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
36
+ expect(page.goto).toHaveBeenCalledTimes(1);
37
+ expect(page.getCookies).not.toHaveBeenCalled();
38
+ });
39
+ });
@@ -10,6 +10,10 @@ import {
10
10
  resolveImagePath,
11
11
  } from './utils.js';
12
12
 
13
+ const COMPOSER_SELECTOR = '[data-testid="tweetTextarea_0"]';
14
+ const SUBMIT_POLL_MS = 500;
15
+ const SUBMIT_TIMEOUT_MS = 15_000;
16
+
13
17
  function buildReplyComposerUrl(rawUrl) {
14
18
  // Replaces the legacy local extractTweetId which used `/\/status\/(\d+)/`
15
19
  // (silent: matched `/status/1234567` on substring `/status/123` and
@@ -19,7 +23,36 @@ function buildReplyComposerUrl(rawUrl) {
19
23
  return `https://x.com/compose/post?in_reply_to=${target.id}`;
20
24
  }
21
25
 
22
- async function submitReply(page, text) {
26
+ function isPromiseCollectedError(err) {
27
+ const msg = err instanceof Error ? err.message : String(err);
28
+ return msg.includes('Promise was collected');
29
+ }
30
+
31
+ async function openReplyComposer(page, rawUrl) {
32
+ await page.goto(buildReplyComposerUrl(rawUrl), { waitUntil: 'load', settleMs: 2500 });
33
+ try {
34
+ await page.wait({ selector: COMPOSER_SELECTOR, timeout: 15 });
35
+ return { ok: true };
36
+ } catch {
37
+ // X sometimes leaves /compose/post?in_reply_to=<id> on the Home
38
+ // timeline behind a loading dialog. Fall back to the canonical tweet
39
+ // page and click the visible Reply action there.
40
+ await page.goto(rawUrl, { waitUntil: 'load', settleMs: 2500 });
41
+ const clicked = await page.evaluate(`(() => {
42
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
43
+ const buttons = Array.from(document.querySelectorAll('[data-testid="reply"]'));
44
+ const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
45
+ if (!btn) return { ok: false, message: 'Could not find the reply button on the target tweet.' };
46
+ btn.click();
47
+ return { ok: true };
48
+ })()`);
49
+ if (!clicked?.ok) return clicked;
50
+ await page.wait({ selector: COMPOSER_SELECTOR, timeout: 15 });
51
+ return { ok: true };
52
+ }
53
+ }
54
+
55
+ async function insertReplyText(page, text) {
23
56
  return page.evaluate(`(async () => {
24
57
  try {
25
58
  const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
@@ -44,23 +77,109 @@ async function submitReply(page, text) {
44
77
  }
45
78
 
46
79
  await new Promise(r => setTimeout(r, 1000));
80
+ const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
81
+ const actual = box.innerText || box.textContent || '';
82
+ if (!normalize(actual).includes(normalize(textToInsert))) {
83
+ return { ok: false, message: 'Could not verify reply text in the composer after typing.', actualText: actual };
84
+ }
85
+ return { ok: true };
86
+ } catch (e) {
87
+ return { ok: false, message: e.toString() };
88
+ }
89
+ })()`);
90
+ }
47
91
 
92
+ async function clickReplyButton(page) {
93
+ return page.evaluate(`(() => {
94
+ try {
95
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
48
96
  const buttons = Array.from(
49
97
  document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
50
98
  );
51
- const btn = buttons.find((el) => visible(el) && !el.disabled);
99
+ const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
52
100
  if (!btn) {
53
101
  return { ok: false, message: 'Reply button is disabled or not found.' };
54
102
  }
55
103
 
56
104
  btn.click();
57
- return { ok: true, message: 'Reply posted successfully.' };
105
+ return { ok: true };
58
106
  } catch (e) {
59
107
  return { ok: false, message: e.toString() };
60
108
  }
61
109
  })()`);
62
110
  }
63
111
 
112
+ async function detectReplySent(page) {
113
+ return page.evaluate(`(() => {
114
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
115
+ const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
116
+ .filter((el) => visible(el));
117
+ const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
118
+ if (!successToast) return { ok: false };
119
+ const link = successToast.querySelector('a[href*="/status/"]');
120
+ return {
121
+ ok: true,
122
+ message: 'Reply posted successfully.',
123
+ url: link?.href || link?.getAttribute('href') || undefined
124
+ };
125
+ })()`);
126
+ }
127
+
128
+ async function waitForReplySent(page, text) {
129
+ const iterations = Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS);
130
+ try {
131
+ return await page.evaluate(`(async () => {
132
+ const expected = ${JSON.stringify(text)};
133
+ const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
134
+ const expectedText = normalize(expected);
135
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
136
+ for (let i = 0; i < ${JSON.stringify(iterations)}; i++) {
137
+ await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)}));
138
+ const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
139
+ .filter((el) => visible(el));
140
+ const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
141
+ if (successToast) {
142
+ const link = successToast.querySelector('a[href*="/status/"]');
143
+ return {
144
+ ok: true,
145
+ message: 'Reply posted successfully.',
146
+ url: link?.href || link?.getAttribute('href') || undefined
147
+ };
148
+ }
149
+ const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
150
+ if (alert) return { ok: false, message: (alert.textContent || 'Reply failed to post.').trim() };
151
+
152
+ const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible);
153
+ const composerStillHasText = boxes.some((box) => normalize(box.innerText || box.textContent || '').includes(expectedText));
154
+ if (!composerStillHasText) return { ok: true, message: 'Reply posted successfully.' };
155
+ }
156
+ return { ok: false, message: 'Reply submission did not complete before timeout.' };
157
+ })()`);
158
+ } catch (err) {
159
+ // X may route the SPA immediately after click, making CDP collect the
160
+ // polling promise even though the reply was submitted. If the page now
161
+ // shows the success toast, report success instead of a false negative.
162
+ if (!isPromiseCollectedError(err)) throw err;
163
+ await page.wait(2);
164
+ const recovered = await detectReplySent(page);
165
+ if (recovered?.ok) return recovered;
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ async function submitReply(page, text) {
171
+ const typed = await insertReplyText(page, text);
172
+ if (!typed?.ok) return typed;
173
+ let clicked;
174
+ try {
175
+ clicked = await clickReplyButton(page);
176
+ } catch (err) {
177
+ if (!isPromiseCollectedError(err)) throw err;
178
+ }
179
+ if (clicked && !clicked.ok) return clicked;
180
+ return waitForReplySent(page, text);
181
+ }
182
+
64
183
  cli({
65
184
  site: 'twitter',
66
185
  name: 'reply',
@@ -75,7 +194,7 @@ cli({
75
194
  { name: 'image', help: 'Optional local image path to attach to the reply' },
76
195
  { name: 'image-url', help: 'Optional remote image URL to download and attach to the reply' },
77
196
  ],
78
- columns: ['status', 'message', 'text'],
197
+ columns: ['status', 'message', 'text', 'url'],
79
198
  func: async (page, kwargs) => {
80
199
  if (!page)
81
200
  throw new CommandExecutionError('Browser session required for twitter reply');
@@ -92,21 +211,24 @@ cli({
92
211
  localImagePath = downloaded.absPath;
93
212
  cleanupDir = downloaded.cleanupDir;
94
213
  }
95
- // Dedicated composer is more reliable than the inline tweet page reply box.
96
- await page.goto(buildReplyComposerUrl(kwargs.url), { waitUntil: 'load', settleMs: 2500 });
97
- await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
214
+ // Dedicated composer is normally more reliable than the inline
215
+ // tweet page reply box, but X occasionally leaves that route on the
216
+ // Home timeline behind a loading dialog. openReplyComposer falls
217
+ // back to the target tweet's visible Reply action.
218
+ const composer = await openReplyComposer(page, kwargs.url);
219
+ if (!composer?.ok) {
220
+ return [{ status: 'failed', message: composer?.message ?? 'Could not open the reply composer.', text: kwargs.text }];
221
+ }
98
222
  if (localImagePath) {
99
223
  await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 });
100
224
  await attachComposerImage(page, localImagePath);
101
225
  }
102
226
  const result = await submitReply(page, kwargs.text);
103
- if (result.ok) {
104
- await page.wait(3); // Wait for network submission to complete
105
- }
106
227
  return [{
107
228
  status: result.ok ? 'success' : 'failed',
108
229
  message: result.message,
109
230
  text: kwargs.text,
231
+ ...(result.url ? { url: result.url } : {}),
110
232
  ...(kwargs.image ? { image: kwargs.image } : {}),
111
233
  ...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
112
234
  }];
@@ -119,4 +241,5 @@ cli({
119
241
  });
120
242
  export const __test__ = {
121
243
  buildReplyComposerUrl,
244
+ isPromiseCollectedError,
122
245
  };