mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
ff7d741a4c
* perf(twitter): drop redundant goto+wait — framework auto pre-navs (PR C)
Twelve twitter read adapters did `await page.goto('https://x.com'); await
page.wait(2~3)` purely to establish cookie context for the subsequent
`document.cookie` read. After PR #1450 hoisted those reads to
`page.getCookies({url})` (which queries the CDP cookie store directly,
no navigation needed), the explicit goto+wait became dead.
The framework already pre-navigates to `https://${domain}` for any
adapter declaring `Strategy.COOKIE + domain` (`src/registry.ts:191`),
so the cookie store is populated before `func` runs. The 2-3s
`page.wait` was the slowest part of the redundant call.
Files (all read-only, all ct0/cookie-only):
- bookmark-folder / bookmark-folders / bookmarks
- following / likes / list-add / list-remove / list-tweets / lists
- thread / timeline / tweets
Out of scope (kept as-is): goto calls that navigate to a *specific*
URL needed for content/SPA shell — `trending` (`/explore/tabs/trending`),
`notifications` (`/home`), `article` (`/i/article/{id}`), `profile`
(`/${username}`), and `list-add` line 133 (`/${username}` for UI ops).
Verification:
- npx tsc --noEmit ✓
- npx vitest run clis/twitter → 216/216 ✓
- typed-error-lint 189/189, 0 new ✓
- silent-column-drop 103/103, 0 new ✓
* fix(twitter): keep list UI root navigation
340 lines
19 KiB
JavaScript
340 lines
19 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
import { resolveTwitterQueryId } from './shared.js';
|
|
import { parseListsManagement } from './lists.js';
|
|
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
|
|
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
|
|
|
|
const LISTS_MANAGEMENT_FEATURES = {
|
|
rweb_video_screen_enabled: false,
|
|
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
rweb_tipjar_consumption_enabled: true,
|
|
verified_phone_label_enabled: false,
|
|
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
premium_content_api_read_enabled: false,
|
|
communities_web_enable_tweet_community_results_fetch: true,
|
|
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
responsive_web_jetfuel_frame: false,
|
|
responsive_web_grok_share_attachment_enabled: true,
|
|
articles_preview_enabled: true,
|
|
responsive_web_edit_tweet_api_enabled: true,
|
|
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
view_counts_everywhere_api_enabled: true,
|
|
longform_notetweets_consumption_enabled: true,
|
|
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
tweet_awards_web_tipping_enabled: false,
|
|
responsive_web_grok_show_grok_translated_post: false,
|
|
responsive_web_grok_analysis_button_from_backend: false,
|
|
creator_subscriptions_quote_tweet_preview_enabled: false,
|
|
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
standardized_nudges_misinfo: true,
|
|
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
longform_notetweets_rich_text_read_enabled: true,
|
|
longform_notetweets_inline_media_enabled: true,
|
|
responsive_web_grok_image_annotation_enabled: true,
|
|
responsive_web_enhance_cards_enabled: false,
|
|
};
|
|
|
|
function buildUserByScreenNameUrl(queryId, screenName) {
|
|
const vars = JSON.stringify({ screen_name: screenName, withSafetyModeUserFields: true });
|
|
const feats = JSON.stringify({
|
|
hidden_profile_subscriptions_enabled: true,
|
|
rweb_tipjar_consumption_enabled: true,
|
|
responsive_web_graphql_exclude_directive_enabled: true,
|
|
verified_phone_label_enabled: false,
|
|
subscriptions_verification_info_is_identity_verified_enabled: true,
|
|
subscriptions_verification_info_verified_since_enabled: true,
|
|
highlights_tweets_tab_ui_enabled: true,
|
|
responsive_web_twitter_article_notes_tab_enabled: true,
|
|
subscriptions_feature_can_gift_premium: true,
|
|
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
});
|
|
return `/i/api/graphql/${queryId}/UserByScreenName`
|
|
+ `?variables=${encodeURIComponent(vars)}`
|
|
+ `&features=${encodeURIComponent(feats)}`;
|
|
}
|
|
|
|
cli({
|
|
site: 'twitter',
|
|
name: 'list-add',
|
|
access: 'write',
|
|
description: 'Add a user to a Twitter/X list you own (no-op if already a member)',
|
|
domain: 'x.com',
|
|
strategy: Strategy.UI,
|
|
browser: true,
|
|
args: [
|
|
{ name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of the list you own (e.g. from `opencli twitter lists`)' },
|
|
{ name: 'username', positional: true, type: 'string', required: true, help: 'Twitter/X handle to add (with or without @)' },
|
|
],
|
|
columns: ['listId', 'username', 'userId', 'status', 'message'],
|
|
func: async (page, kwargs) => {
|
|
const listId = String(kwargs.listId || '').trim();
|
|
const username = String(kwargs.username || '').replace(/^@/, '').trim();
|
|
if (!listId || !/^\d+$/.test(listId)) {
|
|
throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected numeric ID (see \`opencli twitter lists\`).`);
|
|
}
|
|
if (!username) {
|
|
throw new CommandExecutionError('Username is required');
|
|
}
|
|
// Strategy.UI does not get a domain URL pre-nav from the framework.
|
|
// This page context is load-bearing for pre-target GraphQL calls below.
|
|
await page.goto('https://x.com');
|
|
await page.wait(3);
|
|
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
|
|
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
|
|
const headers = JSON.stringify({
|
|
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
'X-Csrf-Token': ct0,
|
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
'X-Twitter-Active-User': 'yes',
|
|
});
|
|
|
|
const userLookupUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username);
|
|
const userId = await page.evaluate(`async () => {
|
|
const resp = await fetch(${JSON.stringify(userLookupUrl)}, { headers: ${headers}, credentials: 'include' });
|
|
if (!resp.ok) return null;
|
|
const d = await resp.json();
|
|
return d.data?.user?.result?.rest_id || null;
|
|
}`);
|
|
if (!userId) {
|
|
throw new CommandExecutionError(`Could not resolve user @${username}`);
|
|
}
|
|
|
|
// ListsManagementPageTimeline — used both for id→name resolution and post-op verification.
|
|
const listsQueryId = await resolveTwitterQueryId(page, 'ListsManagementPageTimeline', LISTS_MANAGEMENT_QUERY_ID);
|
|
const listsUrl = `/i/api/graphql/${listsQueryId}/ListsManagementPageTimeline?features=${encodeURIComponent(JSON.stringify(LISTS_MANAGEMENT_FEATURES))}`;
|
|
const listsData = await page.evaluate(`async () => {
|
|
const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
|
|
if (!r.ok) return { __error: 'HTTP ' + r.status };
|
|
return await r.json();
|
|
}`);
|
|
const parsedLists = listsData && !listsData.__error
|
|
? parseListsManagement(listsData, new Set())
|
|
: [];
|
|
if (listsData && listsData.__error) {
|
|
throw new CommandExecutionError(`Could not fetch lists: ${listsData.__error}`);
|
|
}
|
|
const targetList = parsedLists.find((l) => l.id === listId);
|
|
if (!targetList) {
|
|
throw new CommandExecutionError(`List ${listId} not found among your lists (${parsedLists.length} lists fetched).`);
|
|
}
|
|
|
|
// Use UI strategy — programmatically open "Add/Remove from Lists" dialog and toggle the target list.
|
|
await page.goto(`https://x.com/${username}`);
|
|
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
const targetName = targetList.name;
|
|
const uiResult = await page.evaluate(`(async () => {
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
const findOne = (sel, root = document) => root.querySelector(sel);
|
|
const waitFor = async (fn, { timeoutMs = 8000, intervalMs = 200 } = {}) => {
|
|
const t0 = Date.now();
|
|
while (Date.now() - t0 < timeoutMs) {
|
|
const v = fn();
|
|
if (v) return v;
|
|
await sleep(intervalMs);
|
|
}
|
|
return null;
|
|
};
|
|
try {
|
|
// Install fetch + XHR interceptors to observe list-membership mutations.
|
|
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;
|
|
if (!window.__opencliListMutations) {
|
|
window.__opencliListMutations = [];
|
|
window.__opencliAllRequests = [];
|
|
const origFetch = window.fetch.bind(window);
|
|
window.fetch = async function(...args) {
|
|
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
|
|
const method = (args[1] && args[1].method) || 'GET';
|
|
let resp;
|
|
try { resp = await origFetch(...args); }
|
|
catch (err) {
|
|
if (MUTATION_RE.test(url)) window.__opencliListMutations.push({ url, method, status: 0, error: String(err), ts: Date.now(), via: 'fetch' });
|
|
throw err;
|
|
}
|
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
window.__opencliAllRequests.push({ url, method, status: resp.status, ts: Date.now(), via: 'fetch' });
|
|
}
|
|
if (MUTATION_RE.test(url)) {
|
|
window.__opencliListMutations.push({ url, method, status: resp.status, ts: Date.now(), via: 'fetch' });
|
|
}
|
|
return resp;
|
|
};
|
|
// Also hook XMLHttpRequest
|
|
const OrigXhrOpen = XMLHttpRequest.prototype.open;
|
|
const OrigXhrSend = XMLHttpRequest.prototype.send;
|
|
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
|
this.__opencliMethod = method;
|
|
this.__opencliUrl = url;
|
|
return OrigXhrOpen.call(this, method, url, ...rest);
|
|
};
|
|
XMLHttpRequest.prototype.send = function(...args) {
|
|
const xhr = this;
|
|
xhr.addEventListener('loadend', () => {
|
|
const url = xhr.__opencliUrl || '';
|
|
const method = xhr.__opencliMethod || 'GET';
|
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
window.__opencliAllRequests.push({ url, method, status: xhr.status, ts: Date.now(), via: 'xhr' });
|
|
}
|
|
if (MUTATION_RE.test(url)) {
|
|
window.__opencliListMutations.push({ url, method, status: xhr.status, ts: Date.now(), via: 'xhr' });
|
|
}
|
|
});
|
|
return OrigXhrSend.apply(this, args);
|
|
};
|
|
}
|
|
window.__opencliListMutations.length = 0;
|
|
window.__opencliAllRequests.length = 0;
|
|
|
|
const caret = await waitFor(() => findOne('[data-testid="userActions"]'));
|
|
if (!caret) return { ok: false, message: 'Could not find user actions (…) button. Are you logged in?' };
|
|
caret.click();
|
|
await sleep(600);
|
|
const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'));
|
|
const addToListItem = menuItems.find(el => /add\\/remove|从列表|列表|add to list|add or remove/i.test(el.innerText));
|
|
if (!addToListItem) {
|
|
return { ok: false, message: 'Could not find "Add/remove from Lists" menu item' };
|
|
}
|
|
addToListItem.click();
|
|
await sleep(1200);
|
|
const dialog = await waitFor(() => findOne('[role="dialog"]'));
|
|
if (!dialog) return { ok: false, message: 'List selection dialog did not open' };
|
|
|
|
const targetName = ${JSON.stringify(targetName)};
|
|
// Find the real scroll container (virtualized list). Try a few candidates.
|
|
const scrollCandidates = [
|
|
dialog.querySelector('[data-viewportview="true"]'),
|
|
dialog.querySelector('[aria-label]')?.parentElement,
|
|
...Array.from(dialog.querySelectorAll('div')).filter(d => d.scrollHeight > d.clientHeight + 10),
|
|
].filter(Boolean);
|
|
let row = null;
|
|
let scrollEl = scrollCandidates[0] || dialog;
|
|
for (const se of scrollCandidates) {
|
|
if (se.scrollHeight > se.clientHeight + 10) { scrollEl = se; break; }
|
|
}
|
|
let lastScrollTop = -1;
|
|
for (let i = 0; i < 12; i++) {
|
|
const cells = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'));
|
|
row = cells.find(c => (c.innerText || '').split('\\n')[0].trim() === targetName);
|
|
if (row) break;
|
|
// Incremental scroll within the container
|
|
const prev = scrollEl.scrollTop;
|
|
scrollEl.scrollTop = prev + Math.max(200, scrollEl.clientHeight - 100);
|
|
if (scrollEl.scrollTop === prev) {
|
|
// Couldn't scroll further. Give up.
|
|
if (scrollEl.scrollTop === lastScrollTop) break;
|
|
}
|
|
lastScrollTop = scrollEl.scrollTop;
|
|
await sleep(500);
|
|
}
|
|
if (!row) {
|
|
const names = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'))
|
|
.map(c => (c.innerText || '').split('\\n')[0].trim()).filter(Boolean);
|
|
const dialogText = (dialog.innerText || '').slice(0, 500);
|
|
return { ok: false, message: 'List "' + targetName + '" not found. Cells: [' + names.join(' | ') + ']. DialogText: ' + dialogText };
|
|
}
|
|
const listCell = row.querySelector('[data-testid="listCell"]') || row.querySelector('[role="checkbox"]') || row;
|
|
const readChecked = () => {
|
|
const v = listCell.getAttribute('aria-checked');
|
|
return v === 'true' || v === 'false' ? v : null;
|
|
};
|
|
await sleep(600);
|
|
let ariaChecked = readChecked();
|
|
for (let i = 0; i < 8; i++) {
|
|
await sleep(500);
|
|
const next = readChecked();
|
|
if (next && next === ariaChecked) break;
|
|
ariaChecked = next || ariaChecked;
|
|
}
|
|
const isMember = ariaChecked === 'true';
|
|
if (isMember) {
|
|
const closeBtn = findOne('[data-testid="app-bar-close"]') || findOne('[aria-label="Close"]');
|
|
if (closeBtn) closeBtn.click();
|
|
return { ok: true, noop: true };
|
|
}
|
|
try { listCell.scrollIntoView({ block: 'center' }); } catch {}
|
|
await sleep(400);
|
|
const mutationsBefore = window.__opencliListMutations.length;
|
|
const rowRect = listCell.getBoundingClientRect();
|
|
// Find the Save button (top-right of dialog). Match by text "Save" / "Done" / CJK equivalents.
|
|
const saveButton = Array.from(dialog.querySelectorAll('[role="button"], button')).find(b => {
|
|
const txt = (b.innerText || '').trim();
|
|
return /^(Save|Done|保存|完成|儲存)$/i.test(txt);
|
|
});
|
|
const saveRect = saveButton ? saveButton.getBoundingClientRect() : null;
|
|
return {
|
|
ok: true,
|
|
needsNativeInteraction: true,
|
|
rowClickX: Math.round(rowRect.left + rowRect.width / 2),
|
|
rowClickY: Math.round(rowRect.top + rowRect.height / 2),
|
|
saveClickX: saveRect ? Math.round(saveRect.left + saveRect.width / 2) : null,
|
|
saveClickY: saveRect ? Math.round(saveRect.top + saveRect.height / 2) : null,
|
|
saveText: saveButton ? (saveButton.innerText || '').trim() : null,
|
|
mutationsBefore,
|
|
ariaBefore: ariaChecked,
|
|
};
|
|
} catch (e) {
|
|
return { ok: false, message: 'UI error: ' + (e?.message || String(e)) };
|
|
}
|
|
})()`);
|
|
|
|
if (!uiResult.ok) {
|
|
throw new CommandExecutionError(`Failed to add @${username} to list ${listId}: ${uiResult.message}`);
|
|
}
|
|
|
|
let verifiedBy = null;
|
|
if (uiResult.needsNativeInteraction) {
|
|
if (typeof page.nativeClick !== 'function' || typeof page.nativeKeyPress !== 'function') {
|
|
throw new CommandExecutionError('Requires up-to-date Chrome extension (nativeClick + nativeKeyPress).');
|
|
}
|
|
if (!uiResult.saveClickX) {
|
|
throw new CommandExecutionError(`Save button not found in dialog (X expected text Save/Done). Dialog structure may have changed.`);
|
|
}
|
|
const memberCountBefore = Number(targetList.members) || 0;
|
|
// 1. Trusted click on row → aria flips false→true (optimistic UI)
|
|
await page.nativeClick(uiResult.rowClickX, uiResult.rowClickY);
|
|
await new Promise((r) => setTimeout(r, 800));
|
|
// 2. Trusted click on Save button → X commits to server
|
|
await page.nativeClick(uiResult.saveClickX, uiResult.saveClickY);
|
|
await new Promise((r) => setTimeout(r, 3500));
|
|
// Ground truth: re-fetch ListsManagementPageTimeline and compare member_count
|
|
const listsAfter = await page.evaluate(`async () => {
|
|
const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
|
|
if (!r.ok) return { __error: 'HTTP ' + r.status };
|
|
return await r.json();
|
|
}`);
|
|
const parsedAfter = listsAfter && !listsAfter.__error
|
|
? parseListsManagement(listsAfter, new Set())
|
|
: [];
|
|
const afterList = parsedAfter.find((l) => l.id === listId);
|
|
const memberCountAfter = afterList ? Number(afterList.members) || 0 : -1;
|
|
if (memberCountAfter > memberCountBefore) {
|
|
verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`;
|
|
} else {
|
|
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.`);
|
|
}
|
|
}
|
|
|
|
return [{
|
|
listId,
|
|
username,
|
|
userId: String(userId),
|
|
status: uiResult.noop ? 'noop' : 'success',
|
|
message: uiResult.noop
|
|
? `@${username} is already a member of list ${listId}`
|
|
: `Added @${username} to list ${listId} (verified via ${verifiedBy})`,
|
|
}];
|
|
},
|
|
});
|