@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.
Files changed (118) hide show
  1. package/README.md +10 -8
  2. package/README.zh-CN.md +9 -8
  3. package/cli-manifest.json +585 -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/doubao/utils.js +17 -0
  9. package/clis/doubao/utils.test.js +61 -0
  10. package/clis/google/search.js +16 -6
  11. package/clis/google-scholar/search.js +20 -5
  12. package/clis/google-scholar/search.test.js +35 -2
  13. package/clis/reddit/home.js +117 -0
  14. package/clis/reddit/home.test.js +127 -0
  15. package/clis/reddit/read.js +400 -54
  16. package/clis/reddit/read.test.js +315 -12
  17. package/clis/reddit/reply.js +182 -0
  18. package/clis/reddit/reply.test.js +89 -0
  19. package/clis/reddit/subreddit-info.js +117 -0
  20. package/clis/reddit/subreddit-info.test.js +163 -0
  21. package/clis/reddit/whoami.js +84 -0
  22. package/clis/reddit/whoami.test.js +105 -0
  23. package/clis/rednote/comments.js +76 -0
  24. package/clis/rednote/download.js +59 -0
  25. package/clis/rednote/feed.js +95 -0
  26. package/clis/rednote/navigation.test.js +26 -0
  27. package/clis/rednote/note.js +68 -0
  28. package/clis/rednote/notifications.js +139 -0
  29. package/clis/rednote/rednote.test.js +157 -0
  30. package/clis/rednote/search.js +101 -0
  31. package/clis/rednote/user.js +55 -0
  32. package/clis/twitter/bookmark-folder.js +3 -1
  33. package/clis/twitter/bookmarks.js +3 -1
  34. package/clis/twitter/followers.js +20 -5
  35. package/clis/twitter/followers.test.js +44 -0
  36. package/clis/twitter/following.js +36 -20
  37. package/clis/twitter/following.test.js +60 -8
  38. package/clis/twitter/likes.js +28 -13
  39. package/clis/twitter/likes.test.js +111 -1
  40. package/clis/twitter/list-add.js +128 -204
  41. package/clis/twitter/list-add.test.js +97 -1
  42. package/clis/twitter/list-tweets.js +13 -4
  43. package/clis/twitter/list-tweets.test.js +48 -0
  44. package/clis/twitter/lists.js +5 -2
  45. package/clis/twitter/post.js +23 -4
  46. package/clis/twitter/post.test.js +30 -0
  47. package/clis/twitter/profile.js +16 -8
  48. package/clis/twitter/profile.test.js +39 -0
  49. package/clis/twitter/reply.js +133 -10
  50. package/clis/twitter/reply.test.js +55 -0
  51. package/clis/twitter/search.js +188 -170
  52. package/clis/twitter/search.test.js +96 -258
  53. package/clis/twitter/shared.js +167 -16
  54. package/clis/twitter/shared.test.js +102 -1
  55. package/clis/twitter/timeline.js +3 -1
  56. package/clis/twitter/tweets.js +147 -51
  57. package/clis/twitter/tweets.test.js +238 -1
  58. package/clis/xiaohongshu/comments.js +57 -26
  59. package/clis/xiaohongshu/comments.test.js +63 -1
  60. package/clis/xiaohongshu/download.js +32 -23
  61. package/clis/xiaohongshu/feed.js +23 -15
  62. package/clis/xiaohongshu/note-helpers.js +16 -6
  63. package/clis/xiaohongshu/note.js +26 -20
  64. package/clis/xiaohongshu/notifications.js +26 -19
  65. package/clis/xiaohongshu/search.js +201 -37
  66. package/clis/xiaohongshu/search.test.js +82 -8
  67. package/clis/xiaohongshu/user-helpers.js +13 -4
  68. package/clis/xiaohongshu/user-helpers.test.js +20 -0
  69. package/clis/xiaohongshu/user.js +9 -4
  70. package/clis/xueqiu/earnings-date.js +2 -2
  71. package/clis/xueqiu/kline.js +2 -2
  72. package/clis/xueqiu/utils.js +19 -0
  73. package/clis/xueqiu/utils.test.js +26 -0
  74. package/clis/youtube/transcript.js +28 -3
  75. package/clis/youtube/transcript.test.js +90 -1
  76. package/clis/zhihu/answer-detail.js +233 -0
  77. package/clis/zhihu/answer-detail.test.js +330 -0
  78. package/clis/zhihu/question.js +44 -10
  79. package/clis/zhihu/question.test.js +78 -1
  80. package/clis/zhihu/recommend.js +103 -0
  81. package/clis/zhihu/recommend.test.js +143 -0
  82. package/dist/src/browser/base-page.d.ts +3 -2
  83. package/dist/src/browser/base-page.test.js +2 -2
  84. package/dist/src/browser/cdp.js +3 -3
  85. package/dist/src/browser/page.d.ts +3 -2
  86. package/dist/src/browser/page.js +4 -4
  87. package/dist/src/browser/page.test.js +31 -0
  88. package/dist/src/browser/utils.d.ts +10 -0
  89. package/dist/src/browser/utils.js +37 -0
  90. package/dist/src/browser/utils.test.d.ts +1 -0
  91. package/dist/src/browser/utils.test.js +29 -0
  92. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  93. package/dist/src/cli-argv-preprocess.js +131 -0
  94. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  95. package/dist/src/cli-argv-preprocess.test.js +130 -0
  96. package/dist/src/cli.js +123 -86
  97. package/dist/src/cli.test.js +32 -22
  98. package/dist/src/commands/daemon.js +6 -7
  99. package/dist/src/doctor.js +21 -17
  100. package/dist/src/doctor.test.js +2 -0
  101. package/dist/src/download/progress.js +15 -11
  102. package/dist/src/download/progress.test.d.ts +1 -0
  103. package/dist/src/download/progress.test.js +25 -0
  104. package/dist/src/execution.js +1 -3
  105. package/dist/src/execution.test.js +4 -16
  106. package/dist/src/help.d.ts +11 -0
  107. package/dist/src/help.js +46 -5
  108. package/dist/src/logger.js +8 -9
  109. package/dist/src/main.js +16 -0
  110. package/dist/src/output.js +4 -5
  111. package/dist/src/runtime-detect.d.ts +1 -1
  112. package/dist/src/runtime-detect.js +1 -1
  113. package/dist/src/runtime-detect.test.js +3 -2
  114. package/dist/src/tui.d.ts +0 -1
  115. package/dist/src/tui.js +9 -22
  116. package/dist/src/types.d.ts +3 -1
  117. package/dist/src/update-check.js +4 -5
  118. package/package.json +5 -4
@@ -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
  });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
- import './list-add.js';
3
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import { buildListAddMemberRow } from './list-add.js';
4
5
 
5
6
  describe('twitter list-add registration', () => {
6
7
  it('registers the list-add command with the expected shape', () => {
@@ -34,4 +35,99 @@ describe('twitter list-add registration', () => {
34
35
  expect(page.wait).toHaveBeenCalledWith(3);
35
36
  expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
36
37
  });
38
+
39
+ it('rejects invalid user input before navigation', async () => {
40
+ const cmd = getRegistry().get('twitter/list-add');
41
+ const page = {
42
+ goto: vi.fn(),
43
+ wait: vi.fn(),
44
+ getCookies: vi.fn(),
45
+ evaluate: vi.fn(),
46
+ };
47
+
48
+ await expect(cmd.func(page, { listId: 'abc', username: 'alice' })).rejects.toBeInstanceOf(ArgumentError);
49
+ await expect(cmd.func(page, { listId: '123', username: '' })).rejects.toBeInstanceOf(ArgumentError);
50
+ expect(page.goto).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('builds success rows when member_count increases despite non-fatal decode errors', () => {
54
+ const row = buildListAddMemberRow({
55
+ addResult: {
56
+ httpOk: true,
57
+ status: 200,
58
+ mc: 11,
59
+ isMember: true,
60
+ errors: [{ path: ['data', 'list', 'default_banner_media_results'], message: 'decode failed' }],
61
+ },
62
+ memberCountBefore: 10,
63
+ listId: '123',
64
+ username: 'alice',
65
+ userId: '42',
66
+ });
67
+
68
+ expect(row).toMatchObject({
69
+ listId: '123',
70
+ username: 'alice',
71
+ userId: '42',
72
+ status: 'success',
73
+ });
74
+ expect(row.message).toContain('member_count 10 → 11');
75
+ });
76
+
77
+ it('treats unchanged member_count as noop only when membership is confirmed', () => {
78
+ const row = buildListAddMemberRow({
79
+ addResult: { httpOk: true, status: 200, mc: 10, isMember: true, errors: null },
80
+ memberCountBefore: 10,
81
+ listId: '123',
82
+ username: 'alice',
83
+ userId: '42',
84
+ });
85
+
86
+ expect(row.status).toBe('noop');
87
+ expect(row.message).toBe('@alice is already a member of list 123');
88
+ });
89
+
90
+ it('fails typed when unchanged member_count does not confirm membership', () => {
91
+ expect(() => buildListAddMemberRow({
92
+ addResult: { httpOk: true, status: 200, mc: 10, isMember: false, errors: null },
93
+ memberCountBefore: 10,
94
+ listId: '123',
95
+ username: 'alice',
96
+ userId: '42',
97
+ })).toThrow(CommandExecutionError);
98
+ });
99
+
100
+ it('fails typed when member_count decreases unexpectedly', () => {
101
+ expect(() => buildListAddMemberRow({
102
+ addResult: { httpOk: true, status: 200, mc: 9, isMember: true, errors: null },
103
+ memberCountBefore: 10,
104
+ listId: '123',
105
+ username: 'alice',
106
+ userId: '42',
107
+ })).toThrow(/decreased unexpectedly/);
108
+ });
109
+
110
+ it('fails typed when GraphQL response has no usable member_count', () => {
111
+ expect(() => buildListAddMemberRow({
112
+ addResult: {
113
+ httpOk: true,
114
+ status: 200,
115
+ mc: undefined,
116
+ isMember: null,
117
+ errors: [{ message: 'List is unavailable', path: ['data', 'list'] }],
118
+ },
119
+ memberCountBefore: 10,
120
+ listId: '123',
121
+ username: 'alice',
122
+ userId: '42',
123
+ })).toThrow(/List is unavailable/);
124
+
125
+ expect(() => buildListAddMemberRow({
126
+ addResult: { httpOk: true, status: 200, mc: null, isMember: null, errors: { message: 'not an array' } },
127
+ memberCountBefore: 10,
128
+ listId: '123',
129
+ username: 'alice',
130
+ userId: '42',
131
+ })).toThrow(/no member_count/);
132
+ });
37
133
  });
@@ -1,9 +1,11 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { extractMedia } from './shared.js';
3
4
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
4
5
 
5
6
  const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
6
7
  const OPERATION_NAME = 'ListLatestTweetsTimeline';
8
+ const MAX_PAGINATION_PAGES = 100;
7
9
 
8
10
  const FEATURES = {
9
11
  rweb_video_screen_enabled: false,
@@ -70,6 +72,7 @@ export function extractTimelineTweet(result, seen) {
70
72
  replies: legacy.reply_count || 0,
71
73
  created_at: legacy.created_at || '',
72
74
  url: `https://x.com/${screenName}/status/${tw.rest_id}`,
75
+ ...extractMedia(legacy),
73
76
  };
74
77
  }
75
78
 
@@ -118,7 +121,7 @@ cli({
118
121
  { name: 'limit', type: 'int', default: 50 },
119
122
  { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the list timeline by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the list\'s native (recency) ordering.' },
120
123
  ],
121
- columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url'],
124
+ columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url', 'has_media', 'media_urls'],
122
125
  func: async (page, kwargs) => {
123
126
  const listId = String(kwargs.listId || '').trim();
124
127
  if (!listId || !/^\d+$/.test(listId)) {
@@ -129,7 +132,11 @@ cli({
129
132
  const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
130
133
  if (!ct0)
131
134
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
132
- const queryId = await page.evaluate(`async () => {
135
+ // opencli >=1.7.x wraps primitive page.evaluate returns as { session, data: <value> }.
136
+ // Without unwrap, the string queryId becomes "[object Object]" when interpolated into the URL,
137
+ // causing HTTP 400 "queryId may have expired".
138
+ const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v);
139
+ const queryIdRaw = await page.evaluate(`async () => {
133
140
  try {
134
141
  const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
135
142
  if (ghResp.ok) {
@@ -152,7 +159,8 @@ cli({
152
159
  }
153
160
  } catch {}
154
161
  return null;
155
- }`) || LIST_TWEETS_QUERY_ID;
162
+ }`);
163
+ const queryId = unwrap(queryIdRaw) || LIST_TWEETS_QUERY_ID;
156
164
  const headers = JSON.stringify({
157
165
  'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
158
166
  'X-Csrf-Token': ct0,
@@ -162,7 +170,8 @@ cli({
162
170
  const allTweets = [];
163
171
  const seen = new Set();
164
172
  let cursor = null;
165
- for (let i = 0; i < 10 && allTweets.length < limit; i++) {
173
+ // Runaway guard only; --limit and cursor exhaustion control normal pagination.
174
+ for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
166
175
  const fetchCount = Math.min(100, limit - allTweets.length + 10);
167
176
  const apiUrl = buildUrl(queryId, listId, fetchCount, cursor);
168
177
  const data = await page.evaluate(`async () => {
@@ -30,9 +30,57 @@ describe('twitter list-tweets parser', () => {
30
30
  replies: 2,
31
31
  created_at: 'Wed Apr 16 10:00:00 +0000 2026',
32
32
  url: 'https://x.com/bob/status/99',
33
+ has_media: false,
34
+ media_urls: [],
33
35
  });
34
36
  });
35
37
 
38
+ it('includes photo media URLs from extended_entities', () => {
39
+ const tweet = extractTimelineTweet({
40
+ rest_id: '101',
41
+ legacy: {
42
+ full_text: 'pic post',
43
+ extended_entities: {
44
+ media: [
45
+ { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/abc.jpg' },
46
+ { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/def.jpg' },
47
+ ],
48
+ },
49
+ },
50
+ core: { user_results: { result: { legacy: { screen_name: 'dave' } } } },
51
+ }, new Set());
52
+ expect(tweet?.has_media).toBe(true);
53
+ expect(tweet?.media_urls).toEqual([
54
+ 'https://pbs.twimg.com/media/abc.jpg',
55
+ 'https://pbs.twimg.com/media/def.jpg',
56
+ ]);
57
+ });
58
+
59
+ it('extracts mp4 variant URL for video media', () => {
60
+ const tweet = extractTimelineTweet({
61
+ rest_id: '102',
62
+ legacy: {
63
+ full_text: 'video post',
64
+ extended_entities: {
65
+ media: [{
66
+ type: 'video',
67
+ media_url_https: 'https://pbs.twimg.com/amplify_video_thumb/thumb.jpg',
68
+ video_info: {
69
+ variants: [
70
+ { content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/playlist.m3u8' },
71
+ { content_type: 'video/mp4', bitrate: 832000, url: 'https://video.twimg.com/low.mp4' },
72
+ { content_type: 'video/mp4', bitrate: 2176000, url: 'https://video.twimg.com/high.mp4' },
73
+ ],
74
+ },
75
+ }],
76
+ },
77
+ },
78
+ core: { user_results: { result: { legacy: { screen_name: 'erin' } } } },
79
+ }, new Set());
80
+ expect(tweet?.has_media).toBe(true);
81
+ expect(tweet?.media_urls?.[0]).toMatch(/\.mp4$/);
82
+ });
83
+
36
84
  it('prefers long-form note_tweet text over truncated legacy full_text', () => {
37
85
  const tweet = extractTimelineTweet({
38
86
  rest_id: '100',
@@ -103,7 +103,9 @@ export const command = cli({
103
103
  const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
104
104
  if (!ct0)
105
105
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
106
- const queryId = await page.evaluate(`async () => {
106
+ // opencli >=1.7.x wraps primitive page.evaluate returns as { session, data: <value> }.
107
+ const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v);
108
+ const queryIdRaw = await page.evaluate(`async () => {
107
109
  try {
108
110
  const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
109
111
  if (ghResp.ok) {
@@ -126,7 +128,8 @@ export const command = cli({
126
128
  }
127
129
  } catch {}
128
130
  return null;
129
- }`) || LISTS_QUERY_ID;
131
+ }`);
132
+ const queryId = unwrap(queryIdRaw) || LISTS_QUERY_ID;
130
133
  const headers = JSON.stringify({
131
134
  'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
132
135
  'X-Csrf-Token': ct0,