@jackwener/opencli 1.7.17 → 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 +10 -8
- package/README.zh-CN.md +9 -8
- package/cli-manifest.json +585 -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/doubao/utils.js +17 -0
- package/clis/doubao/utils.test.js +61 -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/reply.js +182 -0
- package/clis/reddit/reply.test.js +89 -0
- 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/comments.js +76 -0
- package/clis/rednote/download.js +59 -0
- package/clis/rednote/feed.js +95 -0
- package/clis/rednote/navigation.test.js +26 -0
- package/clis/rednote/note.js +68 -0
- package/clis/rednote/notifications.js +139 -0
- package/clis/rednote/rednote.test.js +157 -0
- package/clis/rednote/search.js +101 -0
- package/clis/rednote/user.js +55 -0
- 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 +57 -26
- package/clis/xiaohongshu/comments.test.js +63 -1
- package/clis/xiaohongshu/download.js +32 -23
- package/clis/xiaohongshu/feed.js +23 -15
- package/clis/xiaohongshu/note-helpers.js +16 -6
- package/clis/xiaohongshu/note.js +26 -20
- package/clis/xiaohongshu/notifications.js +26 -19
- package/clis/xiaohongshu/search.js +201 -37
- package/clis/xiaohongshu/search.test.js +82 -8
- package/clis/xiaohongshu/user-helpers.js +13 -4
- package/clis/xiaohongshu/user-helpers.test.js +20 -0
- package/clis/xiaohongshu/user.js +9 -4
- 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/youtube/transcript.js +28 -3
- package/clis/youtube/transcript.test.js +90 -1
- 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 +32 -22
- package/dist/src/commands/daemon.js +6 -7
- package/dist/src/doctor.js +21 -17
- package/dist/src/doctor.test.js +2 -0
- 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/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
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
-
import '
|
|
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
|
-
|
|
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
|
-
}`)
|
|
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
|
-
|
|
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',
|
package/clis/twitter/lists.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}`)
|
|
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,
|