@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.
- package/README.md +7 -8
- package/README.zh-CN.md +7 -8
- package/cli-manifest.json +305 -9
- package/clis/ctrip/ctrip.test.js +486 -1
- package/clis/ctrip/flight.js +136 -0
- package/clis/ctrip/hotel-search.js +132 -0
- package/clis/ctrip/utils.js +298 -0
- package/clis/google/search.js +16 -6
- package/clis/google-scholar/search.js +20 -5
- package/clis/google-scholar/search.test.js +35 -2
- package/clis/reddit/home.js +117 -0
- package/clis/reddit/home.test.js +127 -0
- package/clis/reddit/read.js +400 -54
- package/clis/reddit/read.test.js +315 -12
- package/clis/reddit/subreddit-info.js +117 -0
- package/clis/reddit/subreddit-info.test.js +163 -0
- package/clis/reddit/whoami.js +84 -0
- package/clis/reddit/whoami.test.js +105 -0
- package/clis/rednote/search.js +6 -2
- package/clis/twitter/bookmark-folder.js +3 -1
- package/clis/twitter/bookmarks.js +3 -1
- package/clis/twitter/followers.js +20 -5
- package/clis/twitter/followers.test.js +44 -0
- package/clis/twitter/following.js +36 -20
- package/clis/twitter/following.test.js +60 -8
- package/clis/twitter/likes.js +28 -13
- package/clis/twitter/likes.test.js +111 -1
- package/clis/twitter/list-add.js +128 -204
- package/clis/twitter/list-add.test.js +97 -1
- package/clis/twitter/list-tweets.js +13 -4
- package/clis/twitter/list-tweets.test.js +48 -0
- package/clis/twitter/lists.js +5 -2
- package/clis/twitter/post.js +23 -4
- package/clis/twitter/post.test.js +30 -0
- package/clis/twitter/profile.js +16 -8
- package/clis/twitter/profile.test.js +39 -0
- package/clis/twitter/reply.js +133 -10
- package/clis/twitter/reply.test.js +55 -0
- package/clis/twitter/search.js +188 -170
- package/clis/twitter/search.test.js +96 -258
- package/clis/twitter/shared.js +167 -16
- package/clis/twitter/shared.test.js +102 -1
- package/clis/twitter/timeline.js +3 -1
- package/clis/twitter/tweets.js +147 -51
- package/clis/twitter/tweets.test.js +238 -1
- package/clis/xiaohongshu/comments.js +23 -2
- package/clis/xiaohongshu/comments.test.js +63 -1
- package/clis/xiaohongshu/search.js +168 -13
- package/clis/xiaohongshu/search.test.js +82 -8
- package/clis/xueqiu/earnings-date.js +2 -2
- package/clis/xueqiu/kline.js +2 -2
- package/clis/xueqiu/utils.js +19 -0
- package/clis/xueqiu/utils.test.js +26 -0
- package/clis/zhihu/answer-detail.js +233 -0
- package/clis/zhihu/answer-detail.test.js +330 -0
- package/clis/zhihu/question.js +44 -10
- package/clis/zhihu/question.test.js +78 -1
- package/clis/zhihu/recommend.js +103 -0
- package/clis/zhihu/recommend.test.js +143 -0
- package/dist/src/browser/base-page.d.ts +3 -2
- package/dist/src/browser/base-page.test.js +2 -2
- package/dist/src/browser/cdp.js +3 -3
- package/dist/src/browser/page.d.ts +3 -2
- package/dist/src/browser/page.js +4 -4
- package/dist/src/browser/page.test.js +31 -0
- package/dist/src/browser/utils.d.ts +10 -0
- package/dist/src/browser/utils.js +37 -0
- package/dist/src/browser/utils.test.d.ts +1 -0
- package/dist/src/browser/utils.test.js +29 -0
- package/dist/src/cli-argv-preprocess.d.ts +37 -0
- package/dist/src/cli-argv-preprocess.js +131 -0
- package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
- package/dist/src/cli-argv-preprocess.test.js +130 -0
- package/dist/src/cli.js +123 -86
- package/dist/src/cli.test.js +33 -28
- package/dist/src/commands/daemon.js +6 -7
- package/dist/src/doctor.js +15 -16
- package/dist/src/download/progress.js +15 -11
- package/dist/src/download/progress.test.d.ts +1 -0
- package/dist/src/download/progress.test.js +25 -0
- package/dist/src/execution.js +1 -3
- package/dist/src/execution.test.js +4 -16
- package/dist/src/help.d.ts +11 -0
- package/dist/src/help.js +46 -5
- package/dist/src/logger.js +8 -9
- package/dist/src/main.js +16 -0
- package/dist/src/output.js +4 -5
- package/dist/src/runtime-detect.d.ts +1 -1
- package/dist/src/runtime-detect.js +1 -1
- package/dist/src/runtime-detect.test.js +3 -2
- package/dist/src/tui.d.ts +0 -1
- package/dist/src/tui.js +9 -22
- package/dist/src/types.d.ts +3 -1
- package/dist/src/update-check.js +4 -5
- package/package.json +5 -4
package/clis/twitter/likes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/clis/twitter/list-add.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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
|
});
|