@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,9 +1,10 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
- import { resolveTwitterQueryId, sanitizeQueryId, extractMedia } from './shared.js';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, extractMedia, unwrapBrowserResult } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
5
5
  const LIKES_QUERY_ID = 'RozQdCp4CilQzrcuU0NY5w';
6
6
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
7
+ const MAX_PAGINATION_PAGES = 100;
7
8
  const FEATURES = {
8
9
  rweb_video_screen_enabled: false,
9
10
  profile_label_improvements_pcf_label_in_post_enabled: true,
@@ -151,20 +152,33 @@ cli({
151
152
  columns: ['id', 'author', 'name', 'text', 'likes', 'retweets', 'created_at', 'url', 'has_media', 'media_urls'],
152
153
  func: async (page, kwargs) => {
153
154
  const limit = kwargs.limit || 20;
154
- let username = (kwargs.username || '').replace(/^@/, '');
155
+ const rawUsername = String(kwargs.username ?? '').trim();
156
+ let username = normalizeTwitterScreenName(rawUsername);
157
+ if (rawUsername && !username) {
158
+ throw new ArgumentError('twitter likes username must be a valid Twitter/X handle', 'Example: opencli twitter likes @jack --limit 20');
159
+ }
155
160
  const cookies = await page.getCookies({ url: 'https://x.com' });
156
161
  const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
157
162
  if (!ct0)
158
163
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
159
- // If no username provided, detect the logged-in user
164
+ // If no username provided, detect the logged-in user.
165
+ // Bridge wraps primitive page.evaluate returns as { session, data:<value> };
166
+ // unwrap so the href string is usable downstream.
160
167
  if (!username) {
161
- const href = await page.evaluate(`() => {
168
+ // Force a navigation to the home surface so the AppTabBar sidebar
169
+ // is rendered; the framework pre-nav lands on bare x.com which
170
+ // does not always expose AppTabBar_Profile_Link.
171
+ await page.goto('https://x.com/home');
172
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
173
+ const href = unwrapBrowserResult(await page.evaluate(`() => {
162
174
  const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
163
175
  return link ? link.getAttribute('href') : null;
164
- }`);
165
- if (!href)
176
+ }`));
177
+ if (!href || typeof href !== 'string')
178
+ throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
179
+ username = normalizeTwitterScreenName(href);
180
+ if (!username)
166
181
  throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
167
- username = href.replace('/', '');
168
182
  }
169
183
  const likesQueryId = await resolveTwitterQueryId(page, 'Likes', LIKES_QUERY_ID);
170
184
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
@@ -175,27 +189,28 @@ cli({
175
189
  'X-Twitter-Active-User': 'yes',
176
190
  });
177
191
  // Get userId from screen_name
178
- const userId = await page.evaluate(`async () => {
192
+ const userId = unwrapBrowserResult(await page.evaluate(`async () => {
179
193
  const screenName = ${JSON.stringify(username)};
180
194
  const url = ${JSON.stringify(buildUserByScreenNameUrl(userByScreenNameQueryId, username))};
181
195
  const resp = await fetch(url, { headers: ${headers}, credentials: 'include' });
182
196
  if (!resp.ok) return null;
183
197
  const d = await resp.json();
184
198
  return d.data?.user?.result?.rest_id || null;
185
- }`);
199
+ }`));
186
200
  if (!userId) {
187
201
  throw new CommandExecutionError(`Could not find user @${username}`);
188
202
  }
189
203
  const allTweets = [];
190
204
  const seen = new Set();
191
205
  let cursor = null;
192
- for (let i = 0; i < 5 && allTweets.length < limit; i++) {
206
+ // Runaway guard only; --limit and cursor exhaustion control normal pagination.
207
+ for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
193
208
  const fetchCount = Math.min(100, limit - allTweets.length + 10);
194
209
  const apiUrl = buildLikesUrl(likesQueryId, userId, fetchCount, cursor);
195
- const data = await page.evaluate(`async () => {
210
+ const data = unwrapBrowserResult(await page.evaluate(`async () => {
196
211
  const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
197
212
  return r.ok ? await r.json() : { error: r.status };
198
- }`);
213
+ }`));
199
214
  if (data?.error) {
200
215
  if (allTweets.length === 0)
201
216
  throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch likes. queryId may have expired.`);
@@ -1,5 +1,50 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
2
4
  import { __test__ } from './likes.js';
5
+
6
+ function likesPayload() {
7
+ return {
8
+ data: {
9
+ user: {
10
+ result: {
11
+ timeline_v2: {
12
+ timeline: {
13
+ instructions: [{
14
+ entries: [{
15
+ entryId: 'tweet-1',
16
+ content: {
17
+ itemContent: {
18
+ tweet_results: {
19
+ result: {
20
+ rest_id: '1',
21
+ legacy: {
22
+ full_text: 'liked post',
23
+ favorite_count: 7,
24
+ retweet_count: 2,
25
+ created_at: 'now',
26
+ },
27
+ core: {
28
+ user_results: {
29
+ result: {
30
+ legacy: { screen_name: 'alice', name: 'Alice' },
31
+ },
32
+ },
33
+ },
34
+ },
35
+ },
36
+ },
37
+ },
38
+ }],
39
+ }],
40
+ },
41
+ },
42
+ },
43
+ },
44
+ },
45
+ };
46
+ }
47
+
3
48
  describe('twitter likes helpers', () => {
4
49
  it('falls back when queryId contains unsafe characters', () => {
5
50
  expect(__test__.sanitizeQueryId('safe_Query-123', 'fallback')).toBe('safe_Query-123');
@@ -83,3 +128,68 @@ describe('twitter likes helpers', () => {
83
128
  });
84
129
  });
85
130
  });
131
+
132
+ describe('twitter likes command', () => {
133
+ it('rejects invalid explicit username before cookies or navigation', async () => {
134
+ const command = getRegistry().get('twitter/likes');
135
+ const page = {
136
+ goto: vi.fn(),
137
+ wait: vi.fn(),
138
+ getCookies: vi.fn(),
139
+ evaluate: vi.fn(),
140
+ };
141
+
142
+ await expect(command.func(page, { username: 'viewer/extra', limit: 10 })).rejects.toBeInstanceOf(ArgumentError);
143
+ expect(page.getCookies).not.toHaveBeenCalled();
144
+ expect(page.goto).not.toHaveBeenCalled();
145
+ expect(page.evaluate).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it('rejects route-like AppTabBar hrefs as AuthRequiredError', async () => {
149
+ const command = getRegistry().get('twitter/likes');
150
+ const page = {
151
+ goto: vi.fn().mockResolvedValue(undefined),
152
+ wait: vi.fn().mockResolvedValue(undefined),
153
+ getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]),
154
+ evaluate: vi.fn(async (script) => {
155
+ if (String(script).includes('AppTabBar_Profile_Link')) return '/home';
156
+ throw new Error(`Unexpected evaluate: ${String(script).slice(0, 80)}`);
157
+ }),
158
+ };
159
+
160
+ await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
161
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
162
+ expect(page.evaluate).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it('unwraps Browser Bridge envelopes for default-self user lookup and likes payload', async () => {
166
+ const command = getRegistry().get('twitter/likes');
167
+ const page = {
168
+ goto: vi.fn().mockResolvedValue(undefined),
169
+ wait: vi.fn().mockResolvedValue(undefined),
170
+ getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]),
171
+ evaluate: vi.fn(async (script) => {
172
+ const text = String(script);
173
+ if (text.includes('AppTabBar_Profile_Link')) {
174
+ return { session: 'site:twitter', data: '/viewer' };
175
+ }
176
+ if (text.includes('operationName')) return null;
177
+ if (text.includes('/UserByScreenName')) {
178
+ return { session: 'site:twitter', data: '42' };
179
+ }
180
+ if (text.includes('/Likes')) {
181
+ return { session: 'site:twitter', data: likesPayload() };
182
+ }
183
+ throw new Error(`Unexpected evaluate: ${text.slice(0, 80)}`);
184
+ }),
185
+ };
186
+
187
+ const rows = await command.func(page, { limit: 1 });
188
+
189
+ expect(rows).toHaveLength(1);
190
+ expect(rows[0]).toMatchObject({ id: '1', author: 'alice', text: 'liked post' });
191
+ const likesCall = page.evaluate.mock.calls.find(([script]) => String(script).includes('/Likes')) || [];
192
+ expect(decodeURIComponent(String(likesCall[0]))).toContain('"userId":"42"');
193
+ expect(decodeURIComponent(String(likesCall[0]))).not.toContain('[object Object]');
194
+ });
195
+ });
@@ -1,11 +1,14 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
3
  import { resolveTwitterQueryId } from './shared.js';
4
4
  import { parseListsManagement } from './lists.js';
5
5
  import { TWITTER_BEARER_TOKEN } from './utils.js';
6
6
 
7
7
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
8
8
  const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
9
+ // 2026-05 fallback — X rotates queryIds; resolveTwitterQueryId() does live lookup,
10
+ // this constant is just the default if live lookup fails.
11
+ const LIST_ADD_MEMBER_QUERY_ID = 'vWPi0CTMoPFsjsL6W4IynQ';
9
12
 
10
13
  const LISTS_MANAGEMENT_FEATURES = {
11
14
  rweb_video_screen_enabled: false,
@@ -62,6 +65,65 @@ function buildUserByScreenNameUrl(queryId, screenName) {
62
65
  + `&features=${encodeURIComponent(feats)}`;
63
66
  }
64
67
 
68
+ function fatalGraphqlErrors(errors) {
69
+ const list = Array.isArray(errors) ? errors : [];
70
+ return list.filter((e) =>
71
+ !(e?.path || []).join('.').includes('default_banner_media_results')
72
+ && !/decode/i.test(e?.message || '')
73
+ );
74
+ }
75
+
76
+ export function buildListAddMemberRow({ addResult, memberCountBefore, listId, username, userId }) {
77
+ if (!addResult?.httpOk) {
78
+ throw new CommandExecutionError(
79
+ `Failed to add @${username} to list ${listId}: HTTP ${addResult?.status ?? 0}${addResult?.fetchError ? ' (' + addResult.fetchError + ')' : ''}${addResult?.raw ? ' — ' + addResult.raw : ''}`
80
+ );
81
+ }
82
+
83
+ // X often returns a partial GraphQL error on `default_banner_media_results`
84
+ // even on successful mutations. Treat only missing main data or non-decode
85
+ // GraphQL errors as command failures.
86
+ const hasMemberCount = addResult.mc !== null && addResult.mc !== undefined;
87
+ const fatalErrors = fatalGraphqlErrors(addResult.errors);
88
+ if (!hasMemberCount && fatalErrors.length) {
89
+ const msg = fatalErrors.map((e) => e.message || JSON.stringify(e)).join('; ');
90
+ throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: ${msg.slice(0, 300)}`);
91
+ }
92
+ if (!hasMemberCount) {
93
+ throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: no member_count in response`);
94
+ }
95
+
96
+ const memberCountAfter = Number(addResult.mc);
97
+ if (!Number.isFinite(memberCountAfter)) {
98
+ throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: invalid member_count in response`);
99
+ }
100
+
101
+ if (memberCountAfter < memberCountBefore) {
102
+ throw new CommandExecutionError(
103
+ `Failed to add @${username} to list ${listId}: member_count decreased unexpectedly (${memberCountBefore} → ${memberCountAfter})`
104
+ );
105
+ }
106
+
107
+ const countIncreased = memberCountAfter > memberCountBefore;
108
+ if (!countIncreased && addResult.isMember !== true) {
109
+ throw new CommandExecutionError(
110
+ `Failed to add @${username} to list ${listId}: member_count unchanged (${memberCountBefore} → ${memberCountAfter}) and response did not confirm membership`
111
+ );
112
+ }
113
+
114
+ const noop = !countIncreased;
115
+ const verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`;
116
+ return {
117
+ listId,
118
+ username,
119
+ userId: String(userId),
120
+ status: noop ? 'noop' : 'success',
121
+ message: noop
122
+ ? `@${username} is already a member of list ${listId}`
123
+ : `Added @${username} to list ${listId} (verified via ${verifiedBy})`,
124
+ };
125
+ }
126
+
65
127
  cli({
66
128
  site: 'twitter',
67
129
  name: 'list-add',
@@ -79,10 +141,10 @@ cli({
79
141
  const listId = String(kwargs.listId || '').trim();
80
142
  const username = String(kwargs.username || '').replace(/^@/, '').trim();
81
143
  if (!listId || !/^\d+$/.test(listId)) {
82
- throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected numeric ID (see \`opencli twitter lists\`).`);
144
+ throw new ArgumentError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected numeric ID.`, 'Example: opencli twitter list-add 123456789 alice');
83
145
  }
84
146
  if (!username) {
85
- throw new CommandExecutionError('Username is required');
147
+ throw new ArgumentError('twitter list-add username is required', 'Example: opencli twitter list-add 123456789 alice');
86
148
  }
87
149
  // Strategy.UI does not get a domain URL pre-nav from the framework.
88
150
  // This page context is load-bearing for pre-target GraphQL calls below.
@@ -101,25 +163,33 @@ cli({
101
163
  'X-Twitter-Active-User': 'yes',
102
164
  });
103
165
 
166
+ // opencli >=1.7.x wraps page.evaluate return values as { session, data }.
167
+ // Unwrap before use so JSON.stringify of nested values doesn't become "[object Object]".
168
+ const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v);
169
+
104
170
  const userLookupUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username);
105
- const userId = await page.evaluate(`async () => {
171
+ const userIdRaw = await page.evaluate(`async () => {
106
172
  const resp = await fetch(${JSON.stringify(userLookupUrl)}, { headers: ${headers}, credentials: 'include' });
107
173
  if (!resp.ok) return null;
108
174
  const d = await resp.json();
109
175
  return d.data?.user?.result?.rest_id || null;
110
176
  }`);
177
+ const userId = unwrap(userIdRaw);
111
178
  if (!userId) {
112
179
  throw new CommandExecutionError(`Could not resolve user @${username}`);
113
180
  }
114
181
 
115
- // ListsManagementPageTimeline — used both for id→name resolution and post-op verification.
182
+ // ListsManagementPageTimeline — used for list existence check + before/after member_count.
116
183
  const listsQueryId = await resolveTwitterQueryId(page, 'ListsManagementPageTimeline', LISTS_MANAGEMENT_QUERY_ID);
117
184
  const listsUrl = `/i/api/graphql/${listsQueryId}/ListsManagementPageTimeline?features=${encodeURIComponent(JSON.stringify(LISTS_MANAGEMENT_FEATURES))}`;
118
- const listsData = await page.evaluate(`async () => {
185
+ const listsDataRaw = await page.evaluate(`async () => {
119
186
  const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
120
187
  if (!r.ok) return { __error: 'HTTP ' + r.status };
121
188
  return await r.json();
122
189
  }`);
190
+ // Don't unwrap listsData: opencli spreads GraphQL response to top-level + adds session;
191
+ // parseListsManagement reads `.data.viewer.*` from this shape directly.
192
+ const listsData = listsDataRaw;
123
193
  const parsedLists = listsData && !listsData.__error
124
194
  ? parseListsManagement(listsData, new Set())
125
195
  : [];
@@ -131,209 +201,63 @@ cli({
131
201
  throw new CommandExecutionError(`List ${listId} not found among your lists (${parsedLists.length} lists fetched).`);
132
202
  }
133
203
 
134
- // Use UI strategy — programmatically open "Add/Remove from Lists" dialog and toggle the target list.
135
- await page.goto(`https://x.com/${username}`);
136
- await page.wait({ selector: '[data-testid="primaryColumn"]' });
137
- const targetName = targetList.name;
138
- const uiResult = await page.evaluate(`(async () => {
139
- const sleep = (ms) => new Promise(r => setTimeout(r, ms));
140
- const findOne = (sel, root = document) => root.querySelector(sel);
141
- const waitFor = async (fn, { timeoutMs = 8000, intervalMs = 200 } = {}) => {
142
- const t0 = Date.now();
143
- while (Date.now() - t0 < timeoutMs) {
144
- const v = fn();
145
- if (v) return v;
146
- await sleep(intervalMs);
147
- }
148
- return null;
149
- };
204
+ // Direct GraphQL ListAddMember mutation.
205
+ //
206
+ // Previously this command opened the X profile, clicked "" → "Add/remove from Lists",
207
+ // navigated the dialog and used nativeClick on the Save button. In 2026-05 X replaced
208
+ // the dialog with a full-page route (/i/lists/add_member), breaking that UI flow.
209
+ //
210
+ // The mutation is the same one the UI fires under the hood; calling it directly is
211
+ // both more reliable and ~10x faster (no goto-profile + scroll-dialog roundtrip).
212
+ const memberCountBefore = Number(targetList.members) || 0;
213
+ const listAddMemberQueryId = await resolveTwitterQueryId(page, 'ListAddMember', LIST_ADD_MEMBER_QUERY_ID);
214
+ const addUrl = `/i/api/graphql/${listAddMemberQueryId}/ListAddMember`;
215
+ const addBody = JSON.stringify({
216
+ variables: { listId, userId: String(userId) },
217
+ queryId: listAddMemberQueryId,
218
+ });
219
+ const addResultJsonRaw = await page.evaluate(`async () => {
150
220
  try {
151
- // Install fetch + XHR interceptors to observe list-membership mutations.
152
- const MUTATION_RE = /ListAddMember|ListRemoveMember|lists\\/members\\/(create|destroy)|ListManagement.*Add|ListManagement.*Remove|\\/add_member|\\/remove_member|ListAddMembers|ListRemoveMembers|list.*member.*create|list.*member.*destroy/i;
153
- if (!window.__opencliListMutations) {
154
- window.__opencliListMutations = [];
155
- window.__opencliAllRequests = [];
156
- const origFetch = window.fetch.bind(window);
157
- window.fetch = async function(...args) {
158
- const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
159
- const method = (args[1] && args[1].method) || 'GET';
160
- let resp;
161
- try { resp = await origFetch(...args); }
162
- catch (err) {
163
- if (MUTATION_RE.test(url)) window.__opencliListMutations.push({ url, method, status: 0, error: String(err), ts: Date.now(), via: 'fetch' });
164
- throw err;
165
- }
166
- if (method !== 'GET' && method !== 'HEAD') {
167
- window.__opencliAllRequests.push({ url, method, status: resp.status, ts: Date.now(), via: 'fetch' });
168
- }
169
- if (MUTATION_RE.test(url)) {
170
- window.__opencliListMutations.push({ url, method, status: resp.status, ts: Date.now(), via: 'fetch' });
171
- }
172
- return resp;
173
- };
174
- // Also hook XMLHttpRequest
175
- const OrigXhrOpen = XMLHttpRequest.prototype.open;
176
- const OrigXhrSend = XMLHttpRequest.prototype.send;
177
- XMLHttpRequest.prototype.open = function(method, url, ...rest) {
178
- this.__opencliMethod = method;
179
- this.__opencliUrl = url;
180
- return OrigXhrOpen.call(this, method, url, ...rest);
181
- };
182
- XMLHttpRequest.prototype.send = function(...args) {
183
- const xhr = this;
184
- xhr.addEventListener('loadend', () => {
185
- const url = xhr.__opencliUrl || '';
186
- const method = xhr.__opencliMethod || 'GET';
187
- if (method !== 'GET' && method !== 'HEAD') {
188
- window.__opencliAllRequests.push({ url, method, status: xhr.status, ts: Date.now(), via: 'xhr' });
189
- }
190
- if (MUTATION_RE.test(url)) {
191
- window.__opencliListMutations.push({ url, method, status: xhr.status, ts: Date.now(), via: 'xhr' });
192
- }
193
- });
194
- return OrigXhrSend.apply(this, args);
195
- };
196
- }
197
- window.__opencliListMutations.length = 0;
198
- window.__opencliAllRequests.length = 0;
199
-
200
- const caret = await waitFor(() => findOne('[data-testid="userActions"]'));
201
- if (!caret) return { ok: false, message: 'Could not find user actions (…) button. Are you logged in?' };
202
- caret.click();
203
- await sleep(600);
204
- const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'));
205
- const addToListItem = menuItems.find(el => /add\\/remove|从列表|列表|add to list|add or remove/i.test(el.innerText));
206
- if (!addToListItem) {
207
- return { ok: false, message: 'Could not find "Add/remove from Lists" menu item' };
208
- }
209
- addToListItem.click();
210
- await sleep(1200);
211
- const dialog = await waitFor(() => findOne('[role="dialog"]'));
212
- if (!dialog) return { ok: false, message: 'List selection dialog did not open' };
213
-
214
- const targetName = ${JSON.stringify(targetName)};
215
- // Find the real scroll container (virtualized list). Try a few candidates.
216
- const scrollCandidates = [
217
- dialog.querySelector('[data-viewportview="true"]'),
218
- dialog.querySelector('[aria-label]')?.parentElement,
219
- ...Array.from(dialog.querySelectorAll('div')).filter(d => d.scrollHeight > d.clientHeight + 10),
220
- ].filter(Boolean);
221
- let row = null;
222
- let scrollEl = scrollCandidates[0] || dialog;
223
- for (const se of scrollCandidates) {
224
- if (se.scrollHeight > se.clientHeight + 10) { scrollEl = se; break; }
225
- }
226
- let lastScrollTop = -1;
227
- for (let i = 0; i < 12; i++) {
228
- const cells = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'));
229
- row = cells.find(c => (c.innerText || '').split('\\n')[0].trim() === targetName);
230
- if (row) break;
231
- // Incremental scroll within the container
232
- const prev = scrollEl.scrollTop;
233
- scrollEl.scrollTop = prev + Math.max(200, scrollEl.clientHeight - 100);
234
- if (scrollEl.scrollTop === prev) {
235
- // Couldn't scroll further. Give up.
236
- if (scrollEl.scrollTop === lastScrollTop) break;
237
- }
238
- lastScrollTop = scrollEl.scrollTop;
239
- await sleep(500);
240
- }
241
- if (!row) {
242
- const names = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'))
243
- .map(c => (c.innerText || '').split('\\n')[0].trim()).filter(Boolean);
244
- const dialogText = (dialog.innerText || '').slice(0, 500);
245
- return { ok: false, message: 'List "' + targetName + '" not found. Cells: [' + names.join(' | ') + ']. DialogText: ' + dialogText };
246
- }
247
- const listCell = row.querySelector('[data-testid="listCell"]') || row.querySelector('[role="checkbox"]') || row;
248
- const readChecked = () => {
249
- const v = listCell.getAttribute('aria-checked');
250
- return v === 'true' || v === 'false' ? v : null;
251
- };
252
- await sleep(600);
253
- let ariaChecked = readChecked();
254
- for (let i = 0; i < 8; i++) {
255
- await sleep(500);
256
- const next = readChecked();
257
- if (next && next === ariaChecked) break;
258
- ariaChecked = next || ariaChecked;
259
- }
260
- const isMember = ariaChecked === 'true';
261
- if (isMember) {
262
- const closeBtn = findOne('[data-testid="app-bar-close"]') || findOne('[aria-label="Close"]');
263
- if (closeBtn) closeBtn.click();
264
- return { ok: true, noop: true };
265
- }
266
- try { listCell.scrollIntoView({ block: 'center' }); } catch {}
267
- await sleep(400);
268
- const mutationsBefore = window.__opencliListMutations.length;
269
- const rowRect = listCell.getBoundingClientRect();
270
- // Find the Save button (top-right of dialog). Match by text "Save" / "Done" / CJK equivalents.
271
- const saveButton = Array.from(dialog.querySelectorAll('[role="button"], button')).find(b => {
272
- const txt = (b.innerText || '').trim();
273
- return /^(Save|Done|保存|完成|儲存)$/i.test(txt);
221
+ const r = await fetch(${JSON.stringify(addUrl)}, {
222
+ method: 'POST',
223
+ headers: Object.assign({}, ${headers}, { 'Content-Type': 'application/json' }),
224
+ credentials: 'include',
225
+ body: ${JSON.stringify(addBody)},
274
226
  });
275
- const saveRect = saveButton ? saveButton.getBoundingClientRect() : null;
276
- return {
277
- ok: true,
278
- needsNativeInteraction: true,
279
- rowClickX: Math.round(rowRect.left + rowRect.width / 2),
280
- rowClickY: Math.round(rowRect.top + rowRect.height / 2),
281
- saveClickX: saveRect ? Math.round(saveRect.left + saveRect.width / 2) : null,
282
- saveClickY: saveRect ? Math.round(saveRect.top + saveRect.height / 2) : null,
283
- saveText: saveButton ? (saveButton.innerText || '').trim() : null,
284
- mutationsBefore,
285
- ariaBefore: ariaChecked,
286
- };
227
+ const text = await r.text();
228
+ let body;
229
+ let raw = null;
230
+ try { body = JSON.parse(text); } catch { body = null; raw = text.slice(0, 300); }
231
+ const list = body && body.data && body.data.list ? body.data.list : null;
232
+ return JSON.stringify([
233
+ r.ok,
234
+ r.status,
235
+ list ? list.member_count : null,
236
+ list ? list.is_member : null,
237
+ body && body.errors ? body.errors : null,
238
+ raw,
239
+ null,
240
+ ]);
287
241
  } catch (e) {
288
- return { ok: false, message: 'UI error: ' + (e?.message || String(e)) };
289
- }
290
- })()`);
291
-
292
- if (!uiResult.ok) {
293
- throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: ${uiResult.message}`);
294
- }
295
-
296
- let verifiedBy = null;
297
- if (uiResult.needsNativeInteraction) {
298
- if (typeof page.nativeClick !== 'function' || typeof page.nativeKeyPress !== 'function') {
299
- throw new CommandExecutionError('Requires up-to-date Chrome extension (nativeClick + nativeKeyPress).');
300
- }
301
- if (!uiResult.saveClickX) {
302
- throw new CommandExecutionError(`Save button not found in dialog (X expected text Save/Done). Dialog structure may have changed.`);
303
- }
304
- const memberCountBefore = Number(targetList.members) || 0;
305
- // 1. Trusted click on row → aria flips false→true (optimistic UI)
306
- await page.nativeClick(uiResult.rowClickX, uiResult.rowClickY);
307
- await new Promise((r) => setTimeout(r, 800));
308
- // 2. Trusted click on Save button → X commits to server
309
- await page.nativeClick(uiResult.saveClickX, uiResult.saveClickY);
310
- await new Promise((r) => setTimeout(r, 3500));
311
- // Ground truth: re-fetch ListsManagementPageTimeline and compare member_count
312
- const listsAfter = await page.evaluate(`async () => {
313
- const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
314
- if (!r.ok) return { __error: 'HTTP ' + r.status };
315
- return await r.json();
316
- }`);
317
- const parsedAfter = listsAfter && !listsAfter.__error
318
- ? parseListsManagement(listsAfter, new Set())
319
- : [];
320
- const afterList = parsedAfter.find((l) => l.id === listId);
321
- const memberCountAfter = afterList ? Number(afterList.members) || 0 : -1;
322
- if (memberCountAfter > memberCountBefore) {
323
- verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`;
324
- } else {
325
- throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: member_count unchanged (${memberCountBefore} → ${memberCountAfter}). X's UI flipped but did not commit — try reloading page/extension.`);
242
+ return JSON.stringify([false, 0, null, null, null, null, String(e)]);
326
243
  }
244
+ }`);
245
+ const addResultJson = unwrap(addResultJsonRaw);
246
+ let addResultTuple;
247
+ try {
248
+ addResultTuple = JSON.parse(addResultJson);
249
+ } catch {
250
+ throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: malformed mutation response envelope`);
327
251
  }
252
+ const addResult = Object.create(null);
253
+ addResult.httpOk = Boolean(addResultTuple?.[0]);
254
+ addResult.status = Number(addResultTuple?.[1]) || 0;
255
+ addResult.mc = addResultTuple?.[2];
256
+ addResult.isMember = addResultTuple?.[3];
257
+ addResult.errors = addResultTuple?.[4];
258
+ addResult.raw = addResultTuple?.[5];
259
+ addResult.fetchError = addResultTuple?.[6];
328
260
 
329
- return [{
330
- listId,
331
- username,
332
- userId: String(userId),
333
- status: uiResult.noop ? 'noop' : 'success',
334
- message: uiResult.noop
335
- ? `@${username} is already a member of list ${listId}`
336
- : `Added @${username} to list ${listId} (verified via ${verifiedBy})`,
337
- }];
261
+ return [buildListAddMemberRow({ addResult, memberCountBefore, listId, username, userId })];
338
262
  },
339
263
  });