/ clis / twitter / list-add.js
list-add.js
  1  import { cli, Strategy } from '@jackwener/opencli/registry';
  2  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
  3  import { resolveTwitterQueryId } from './shared.js';
  4  import { parseListsManagement } from './lists.js';
  5  
  6  const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
  7  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
  8  const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
  9  
 10  const LISTS_MANAGEMENT_FEATURES = {
 11      rweb_video_screen_enabled: false,
 12      profile_label_improvements_pcf_label_in_post_enabled: true,
 13      rweb_tipjar_consumption_enabled: true,
 14      verified_phone_label_enabled: false,
 15      creator_subscriptions_tweet_preview_api_enabled: true,
 16      responsive_web_graphql_timeline_navigation_enabled: true,
 17      responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
 18      premium_content_api_read_enabled: false,
 19      communities_web_enable_tweet_community_results_fetch: true,
 20      c9s_tweet_anatomy_moderator_badge_enabled: true,
 21      responsive_web_grok_analyze_button_fetch_trends_enabled: false,
 22      responsive_web_grok_analyze_post_followups_enabled: true,
 23      responsive_web_jetfuel_frame: false,
 24      responsive_web_grok_share_attachment_enabled: true,
 25      articles_preview_enabled: true,
 26      responsive_web_edit_tweet_api_enabled: true,
 27      graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
 28      view_counts_everywhere_api_enabled: true,
 29      longform_notetweets_consumption_enabled: true,
 30      responsive_web_twitter_article_tweet_consumption_enabled: true,
 31      tweet_awards_web_tipping_enabled: false,
 32      responsive_web_grok_show_grok_translated_post: false,
 33      responsive_web_grok_analysis_button_from_backend: false,
 34      creator_subscriptions_quote_tweet_preview_enabled: false,
 35      freedom_of_speech_not_reach_fetch_enabled: true,
 36      standardized_nudges_misinfo: true,
 37      tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
 38      longform_notetweets_rich_text_read_enabled: true,
 39      longform_notetweets_inline_media_enabled: true,
 40      responsive_web_grok_image_annotation_enabled: true,
 41      responsive_web_enhance_cards_enabled: false,
 42  };
 43  
 44  function buildUserByScreenNameUrl(queryId, screenName) {
 45      const vars = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true });
 46      const feats = JSON.stringify({
 47          hidden_profile_subscriptions_enabled: true,
 48          rweb_tipjar_consumption_enabled: true,
 49          responsive_web_graphql_exclude_directive_enabled: true,
 50          verified_phone_label_enabled: false,
 51          subscriptions_verification_info_is_identity_verified_enabled: true,
 52          subscriptions_verification_info_verified_since_enabled: true,
 53          highlights_tweets_tab_ui_enabled: true,
 54          responsive_web_twitter_article_notes_tab_enabled: true,
 55          subscriptions_feature_can_gift_premium: true,
 56          creator_subscriptions_tweet_preview_api_enabled: true,
 57          responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
 58          responsive_web_graphql_timeline_navigation_enabled: true,
 59      });
 60      return `/i/api/graphql/${queryId}/UserByScreenName`
 61          + `?variables=${encodeURIComponent(vars)}`
 62          + `&features=${encodeURIComponent(feats)}`;
 63  }
 64  
 65  cli({
 66      site: 'twitter',
 67      name: 'list-add',
 68      description: 'Add a user to a Twitter/X list you own (no-op if already a member)',
 69      domain: 'x.com',
 70      strategy: Strategy.UI,
 71      browser: true,
 72      args: [
 73          { name: 'listId', positional: true, type: 'string', required: true },
 74          { name: 'username', positional: true, type: 'string', required: true },
 75      ],
 76      columns: ['listId', 'username', 'userId', 'status', 'message'],
 77      func: async (page, kwargs) => {
 78          const listId = String(kwargs.listId || '').trim();
 79          const username = String(kwargs.username || '').replace(/^@/, '').trim();
 80          if (!listId || !/^\d+$/.test(listId)) {
 81              throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected numeric ID (see \`opencli twitter lists\`).`);
 82          }
 83          if (!username) {
 84              throw new CommandExecutionError('Username is required');
 85          }
 86          await page.goto('https://x.com');
 87          await page.wait(3);
 88          const ct0 = await page.evaluate(`() => {
 89              return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
 90          }`);
 91          if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
 92  
 93          const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
 94  
 95          const headers = JSON.stringify({
 96              'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
 97              'X-Csrf-Token': ct0,
 98              'X-Twitter-Auth-Type': 'OAuth2Session',
 99              'X-Twitter-Active-User': 'yes',
100          });
101  
102          const userLookupUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username);
103          const userId = await page.evaluate(`async () => {
104              const resp = await fetch(${JSON.stringify(userLookupUrl)}, { headers: ${headers}, credentials: 'include' });
105              if (!resp.ok) return null;
106              const d = await resp.json();
107              return d.data?.user?.result?.rest_id || null;
108          }`);
109          if (!userId) {
110              throw new CommandExecutionError(`Could not resolve user @${username}`);
111          }
112  
113          // ListsManagementPageTimeline — used both for id→name resolution and post-op verification.
114          const listsQueryId = await resolveTwitterQueryId(page, 'ListsManagementPageTimeline', LISTS_MANAGEMENT_QUERY_ID);
115          const listsUrl = `/i/api/graphql/${listsQueryId}/ListsManagementPageTimeline?features=${encodeURIComponent(JSON.stringify(LISTS_MANAGEMENT_FEATURES))}`;
116          const listsData = await page.evaluate(`async () => {
117              const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
118              if (!r.ok) return { __error: 'HTTP ' + r.status };
119              return await r.json();
120          }`);
121          const parsedLists = listsData && !listsData.__error
122              ? parseListsManagement(listsData, new Set())
123              : [];
124          if (listsData && listsData.__error) {
125              throw new CommandExecutionError(`Could not fetch lists: ${listsData.__error}`);
126          }
127          const targetList = parsedLists.find((l) => l.id === listId);
128          if (!targetList) {
129              throw new CommandExecutionError(`List ${listId} not found among your lists (${parsedLists.length} lists fetched).`);
130          }
131  
132          // Use UI strategy — programmatically open "Add/Remove from Lists" dialog and toggle the target list.
133          await page.goto(`https://x.com/${username}`);
134          await page.wait({ selector: '[data-testid="primaryColumn"]' });
135          const targetName = targetList.name;
136          const uiResult = await page.evaluate(`(async () => {
137              const sleep = (ms) => new Promise(r => setTimeout(r, ms));
138              const findOne = (sel, root = document) => root.querySelector(sel);
139              const waitFor = async (fn, { timeoutMs = 8000, intervalMs = 200 } = {}) => {
140                  const t0 = Date.now();
141                  while (Date.now() - t0 < timeoutMs) {
142                      const v = fn();
143                      if (v) return v;
144                      await sleep(intervalMs);
145                  }
146                  return null;
147              };
148              try {
149                  // Install fetch + XHR interceptors to observe list-membership mutations.
150                  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;
151                  if (!window.__opencliListMutations) {
152                      window.__opencliListMutations = [];
153                      window.__opencliAllRequests = [];
154                      const origFetch = window.fetch.bind(window);
155                      window.fetch = async function(...args) {
156                          const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
157                          const method = (args[1] && args[1].method) || 'GET';
158                          let resp;
159                          try { resp = await origFetch(...args); }
160                          catch (err) {
161                              if (MUTATION_RE.test(url)) window.__opencliListMutations.push({ url, method, status: 0, error: String(err), ts: Date.now(), via: 'fetch' });
162                              throw err;
163                          }
164                          if (method !== 'GET' && method !== 'HEAD') {
165                              window.__opencliAllRequests.push({ url, method, status: resp.status, ts: Date.now(), via: 'fetch' });
166                          }
167                          if (MUTATION_RE.test(url)) {
168                              window.__opencliListMutations.push({ url, method, status: resp.status, ts: Date.now(), via: 'fetch' });
169                          }
170                          return resp;
171                      };
172                      // Also hook XMLHttpRequest
173                      const OrigXhrOpen = XMLHttpRequest.prototype.open;
174                      const OrigXhrSend = XMLHttpRequest.prototype.send;
175                      XMLHttpRequest.prototype.open = function(method, url, ...rest) {
176                          this.__opencliMethod = method;
177                          this.__opencliUrl = url;
178                          return OrigXhrOpen.call(this, method, url, ...rest);
179                      };
180                      XMLHttpRequest.prototype.send = function(...args) {
181                          const xhr = this;
182                          xhr.addEventListener('loadend', () => {
183                              const url = xhr.__opencliUrl || '';
184                              const method = xhr.__opencliMethod || 'GET';
185                              if (method !== 'GET' && method !== 'HEAD') {
186                                  window.__opencliAllRequests.push({ url, method, status: xhr.status, ts: Date.now(), via: 'xhr' });
187                              }
188                              if (MUTATION_RE.test(url)) {
189                                  window.__opencliListMutations.push({ url, method, status: xhr.status, ts: Date.now(), via: 'xhr' });
190                              }
191                          });
192                          return OrigXhrSend.apply(this, args);
193                      };
194                  }
195                  window.__opencliListMutations.length = 0;
196                  window.__opencliAllRequests.length = 0;
197  
198                  const caret = await waitFor(() => findOne('[data-testid="userActions"]'));
199                  if (!caret) return { ok: false, message: 'Could not find user actions (…) button. Are you logged in?' };
200                  caret.click();
201                  await sleep(600);
202                  const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'));
203                  const addToListItem = menuItems.find(el => /add\\/remove|从列表|列表|add to list|add or remove/i.test(el.innerText));
204                  if (!addToListItem) {
205                      return { ok: false, message: 'Could not find "Add/remove from Lists" menu item' };
206                  }
207                  addToListItem.click();
208                  await sleep(1200);
209                  const dialog = await waitFor(() => findOne('[role="dialog"]'));
210                  if (!dialog) return { ok: false, message: 'List selection dialog did not open' };
211  
212                  const targetName = ${JSON.stringify(targetName)};
213                  // Find the real scroll container (virtualized list). Try a few candidates.
214                  const scrollCandidates = [
215                      dialog.querySelector('[data-viewportview="true"]'),
216                      dialog.querySelector('[aria-label]')?.parentElement,
217                      ...Array.from(dialog.querySelectorAll('div')).filter(d => d.scrollHeight > d.clientHeight + 10),
218                  ].filter(Boolean);
219                  let row = null;
220                  let scrollEl = scrollCandidates[0] || dialog;
221                  for (const se of scrollCandidates) {
222                      if (se.scrollHeight > se.clientHeight + 10) { scrollEl = se; break; }
223                  }
224                  let lastScrollTop = -1;
225                  for (let i = 0; i < 12; i++) {
226                      const cells = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'));
227                      row = cells.find(c => (c.innerText || '').split('\\n')[0].trim() === targetName);
228                      if (row) break;
229                      // Incremental scroll within the container
230                      const prev = scrollEl.scrollTop;
231                      scrollEl.scrollTop = prev + Math.max(200, scrollEl.clientHeight - 100);
232                      if (scrollEl.scrollTop === prev) {
233                          // Couldn't scroll further. Give up.
234                          if (scrollEl.scrollTop === lastScrollTop) break;
235                      }
236                      lastScrollTop = scrollEl.scrollTop;
237                      await sleep(500);
238                  }
239                  if (!row) {
240                      const names = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'))
241                          .map(c => (c.innerText || '').split('\\n')[0].trim()).filter(Boolean);
242                      const dialogText = (dialog.innerText || '').slice(0, 500);
243                      return { ok: false, message: 'List "' + targetName + '" not found. Cells: [' + names.join(' | ') + ']. DialogText: ' + dialogText };
244                  }
245                  const listCell = row.querySelector('[data-testid="listCell"]') || row.querySelector('[role="checkbox"]') || row;
246                  const readChecked = () => {
247                      const v = listCell.getAttribute('aria-checked');
248                      return v === 'true' || v === 'false' ? v : null;
249                  };
250                  await sleep(600);
251                  let ariaChecked = readChecked();
252                  for (let i = 0; i < 8; i++) {
253                      await sleep(500);
254                      const next = readChecked();
255                      if (next && next === ariaChecked) break;
256                      ariaChecked = next || ariaChecked;
257                  }
258                  const isMember = ariaChecked === 'true';
259                  if (isMember) {
260                      const closeBtn = findOne('[data-testid="app-bar-close"]') || findOne('[aria-label="Close"]');
261                      if (closeBtn) closeBtn.click();
262                      return { ok: true, noop: true };
263                  }
264                  try { listCell.scrollIntoView({ block: 'center' }); } catch {}
265                  await sleep(400);
266                  const mutationsBefore = window.__opencliListMutations.length;
267                  const rowRect = listCell.getBoundingClientRect();
268                  // Find the Save button (top-right of dialog). Match by text "Save" / "Done" / CJK equivalents.
269                  const saveButton = Array.from(dialog.querySelectorAll('[role="button"], button')).find(b => {
270                      const txt = (b.innerText || '').trim();
271                      return /^(Save|Done|保存|完成|儲存)$/i.test(txt);
272                  });
273                  const saveRect = saveButton ? saveButton.getBoundingClientRect() : null;
274                  return {
275                      ok: true,
276                      needsNativeInteraction: true,
277                      rowClickX: Math.round(rowRect.left + rowRect.width / 2),
278                      rowClickY: Math.round(rowRect.top + rowRect.height / 2),
279                      saveClickX: saveRect ? Math.round(saveRect.left + saveRect.width / 2) : null,
280                      saveClickY: saveRect ? Math.round(saveRect.top + saveRect.height / 2) : null,
281                      saveText: saveButton ? (saveButton.innerText || '').trim() : null,
282                      mutationsBefore,
283                      ariaBefore: ariaChecked,
284                  };
285              } catch (e) {
286                  return { ok: false, message: 'UI error: ' + (e?.message || String(e)) };
287              }
288          })()`);
289  
290          if (!uiResult.ok) {
291              throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: ${uiResult.message}`);
292          }
293  
294          let verifiedBy = null;
295          if (uiResult.needsNativeInteraction) {
296              if (typeof page.nativeClick !== 'function' || typeof page.nativeKeyPress !== 'function') {
297                  throw new CommandExecutionError('Requires up-to-date Chrome extension (nativeClick + nativeKeyPress).');
298              }
299              if (!uiResult.saveClickX) {
300                  throw new CommandExecutionError(`Save button not found in dialog (X expected text Save/Done). Dialog structure may have changed.`);
301              }
302              const memberCountBefore = Number(targetList.members) || 0;
303              // 1. Trusted click on row → aria flips false→true (optimistic UI)
304              await page.nativeClick(uiResult.rowClickX, uiResult.rowClickY);
305              await new Promise((r) => setTimeout(r, 800));
306              // 2. Trusted click on Save button → X commits to server
307              await page.nativeClick(uiResult.saveClickX, uiResult.saveClickY);
308              await new Promise((r) => setTimeout(r, 3500));
309              // Ground truth: re-fetch ListsManagementPageTimeline and compare member_count
310              const listsAfter = await page.evaluate(`async () => {
311                  const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
312                  if (!r.ok) return { __error: 'HTTP ' + r.status };
313                  return await r.json();
314              }`);
315              const parsedAfter = listsAfter && !listsAfter.__error
316                  ? parseListsManagement(listsAfter, new Set())
317                  : [];
318              const afterList = parsedAfter.find((l) => l.id === listId);
319              const memberCountAfter = afterList ? Number(afterList.members) || 0 : -1;
320              if (memberCountAfter > memberCountBefore) {
321                  verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`;
322              } else {
323                  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.`);
324              }
325          }
326  
327          return [{
328              listId,
329              username,
330              userId: String(userId),
331              status: uiResult.noop ? 'noop' : 'success',
332              message: uiResult.noop
333                  ? `@${username} is already a member of list ${listId}`
334                  : `Added @${username} to list ${listId} (verified via ${verifiedBy})`,
335          }];
336      },
337  });