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 });