@jackwener/opencli 1.7.4 → 1.7.6
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 +76 -51
- package/README.zh-CN.md +78 -62
- package/cli-manifest.json +4558 -2979
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/bilibili/video.js +61 -0
- package/clis/bilibili/video.test.js +81 -0
- package/clis/deepseek/ask.js +94 -0
- package/clis/deepseek/ask.test.js +73 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +291 -0
- package/clis/deepseek/utils.test.js +37 -0
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/jianyu/search.js +139 -3
- package/clis/jianyu/search.test.js +25 -0
- package/clis/jianyu/shared/procurement-detail.js +15 -0
- package/clis/jianyu/shared/procurement-detail.test.js +12 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- package/clis/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- package/clis/twitter/shared.js +7 -2
- package/clis/twitter/tweets.js +218 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/download.js +8 -4
- package/clis/xiaoyuzhou/download.test.js +23 -13
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- package/clis/youtube/channel.js +35 -0
- package/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +14 -4
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +13 -4
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/daemon-client.d.ts +6 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +83 -5
- package/dist/src/browser/dom-snapshot.test.js +65 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +76 -0
- package/dist/src/browser/find.js +179 -0
- package/dist/src/browser/find.test.js +120 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/network-cache.d.ts +48 -0
- package/dist/src/browser/network-cache.js +66 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +58 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/page.d.ts +14 -4
- package/dist/src/browser/page.js +48 -7
- package/dist/src/browser/page.test.js +97 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/target-errors.d.ts +14 -1
- package/dist/src/browser/target-errors.js +13 -0
- package/dist/src/browser/target-errors.test.js +39 -6
- package/dist/src/browser/target-resolver.d.ts +57 -10
- package/dist/src/browser/target-resolver.js +195 -75
- package/dist/src/browser/target-resolver.test.js +80 -5
- package/dist/src/cli.js +849 -267
- package/dist/src/cli.test.js +961 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- package/dist/src/completion-shared.js +2 -5
- package/dist/src/daemon.js +8 -0
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.d.ts +1 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/main.js +16 -0
- package/dist/src/plugin.d.ts +1 -8
- package/dist/src/plugin.js +1 -27
- package/dist/src/plugin.test.js +1 -59
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +32 -8
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/compound.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
- /package/dist/src/{skill-generate.test.d.ts → browser/find.test.d.ts} +0 -0
|
@@ -0,0 +1,337 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './list-add.js';
|
|
4
|
+
|
|
5
|
+
describe('twitter list-add registration', () => {
|
|
6
|
+
it('registers the list-add command with the expected shape', () => {
|
|
7
|
+
const cmd = getRegistry().get('twitter/list-add');
|
|
8
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
9
|
+
expect(cmd?.columns).toEqual(['listId', 'username', 'userId', 'status', 'message']);
|
|
10
|
+
const listIdArg = cmd?.args?.find((a) => a.name === 'listId');
|
|
11
|
+
expect(listIdArg).toBeTruthy();
|
|
12
|
+
expect(listIdArg?.required).toBe(true);
|
|
13
|
+
expect(listIdArg?.positional).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
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
|
+
export function interpretRemoveResponse(status, json) {
|
|
66
|
+
if (status === 200 && json && (json.id_str || json.id || json.slug)) return { ok: true };
|
|
67
|
+
if (json && Array.isArray(json.errors) && json.errors.length > 0) {
|
|
68
|
+
const err = json.errors[0];
|
|
69
|
+
return { ok: false, error: `${err.code ? '[' + err.code + '] ' : ''}${err.message || 'Unknown error'}` };
|
|
70
|
+
}
|
|
71
|
+
return { ok: false, error: `HTTP ${status}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
cli({
|
|
75
|
+
site: 'twitter',
|
|
76
|
+
name: 'list-remove',
|
|
77
|
+
description: 'Remove a user from a Twitter/X list you own (toggles via UI; no-op if not currently a member)',
|
|
78
|
+
domain: 'x.com',
|
|
79
|
+
strategy: Strategy.UI,
|
|
80
|
+
browser: true,
|
|
81
|
+
args: [
|
|
82
|
+
{ name: 'listId', positional: true, type: 'string', required: true },
|
|
83
|
+
{ name: 'username', positional: true, type: 'string', required: true },
|
|
84
|
+
],
|
|
85
|
+
columns: ['listId', 'username', 'userId', 'status', 'message'],
|
|
86
|
+
func: async (page, kwargs) => {
|
|
87
|
+
const listId = String(kwargs.listId || '').trim();
|
|
88
|
+
const username = String(kwargs.username || '').replace(/^@/, '').trim();
|
|
89
|
+
if (!listId || !/^\d+$/.test(listId)) {
|
|
90
|
+
throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}`);
|
|
91
|
+
}
|
|
92
|
+
if (!username) throw new CommandExecutionError('Username is required');
|
|
93
|
+
|
|
94
|
+
await page.goto('https://x.com');
|
|
95
|
+
await page.wait(3);
|
|
96
|
+
const ct0 = await page.evaluate(`() => {
|
|
97
|
+
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
98
|
+
}`);
|
|
99
|
+
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
100
|
+
|
|
101
|
+
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
102
|
+
const headers = JSON.stringify({
|
|
103
|
+
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
104
|
+
'X-Csrf-Token': ct0,
|
|
105
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
106
|
+
'X-Twitter-Active-User': 'yes',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const userLookupUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username);
|
|
110
|
+
const userId = await page.evaluate(`async () => {
|
|
111
|
+
const resp = await fetch(${JSON.stringify(userLookupUrl)}, { headers: ${headers}, credentials: 'include' });
|
|
112
|
+
if (!resp.ok) return null;
|
|
113
|
+
const d = await resp.json();
|
|
114
|
+
return d.data?.user?.result?.rest_id || null;
|
|
115
|
+
}`);
|
|
116
|
+
if (!userId) throw new CommandExecutionError(`Could not resolve user @${username}`);
|
|
117
|
+
|
|
118
|
+
// Resolve listId → name so we can match the dialog row.
|
|
119
|
+
const listsQueryId = await resolveTwitterQueryId(page, 'ListsManagementPageTimeline', LISTS_MANAGEMENT_QUERY_ID);
|
|
120
|
+
const listsUrl = `/i/api/graphql/${listsQueryId}/ListsManagementPageTimeline?features=${encodeURIComponent(JSON.stringify(LISTS_MANAGEMENT_FEATURES))}`;
|
|
121
|
+
const listsData = await page.evaluate(`async () => {
|
|
122
|
+
const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
|
|
123
|
+
if (!r.ok) return { __error: 'HTTP ' + r.status };
|
|
124
|
+
return await r.json();
|
|
125
|
+
}`);
|
|
126
|
+
if (listsData && listsData.__error) {
|
|
127
|
+
throw new CommandExecutionError(`Could not fetch lists: ${listsData.__error}`);
|
|
128
|
+
}
|
|
129
|
+
const parsedLists = parseListsManagement(listsData, new Set());
|
|
130
|
+
const targetList = parsedLists.find((l) => l.id === listId);
|
|
131
|
+
if (!targetList) {
|
|
132
|
+
throw new CommandExecutionError(`List ${listId} not found among your lists.`);
|
|
133
|
+
}
|
|
134
|
+
const targetName = targetList.name;
|
|
135
|
+
|
|
136
|
+
await page.goto(`https://x.com/${username}`);
|
|
137
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
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) { const v = fn(); if (v) return v; await sleep(intervalMs); }
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
try {
|
|
147
|
+
if (!window.__opencliListMutations) {
|
|
148
|
+
window.__opencliListMutations = [];
|
|
149
|
+
const origFetch = window.fetch.bind(window);
|
|
150
|
+
window.fetch = async function(...args) {
|
|
151
|
+
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
|
|
152
|
+
const method = (args[1] && args[1].method) || 'GET';
|
|
153
|
+
let resp;
|
|
154
|
+
try { resp = await origFetch(...args); } catch (err) {
|
|
155
|
+
if (/ListAddMember|ListRemoveMember|lists\\/members\\/(create|destroy)/.test(url)) {
|
|
156
|
+
window.__opencliListMutations.push({ url, method, status: 0, error: String(err), ts: Date.now() });
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
if (/ListAddMember|ListRemoveMember|lists\\/members\\/(create|destroy)/.test(url)) {
|
|
161
|
+
window.__opencliListMutations.push({ url, method, status: resp.status, ts: Date.now() });
|
|
162
|
+
}
|
|
163
|
+
return resp;
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
window.__opencliListMutations.length = 0;
|
|
167
|
+
|
|
168
|
+
const caret = await waitFor(() => findOne('[data-testid="userActions"]'));
|
|
169
|
+
if (!caret) return { ok: false, message: 'Could not find user actions (…) button' };
|
|
170
|
+
caret.click();
|
|
171
|
+
await sleep(600);
|
|
172
|
+
const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'));
|
|
173
|
+
const addToListItem = menuItems.find(el => /add\\/remove|从列表|列表|add to list|add or remove/i.test(el.innerText));
|
|
174
|
+
if (!addToListItem) return { ok: false, message: 'Could not find "Add/remove from Lists" menu item' };
|
|
175
|
+
addToListItem.click();
|
|
176
|
+
await sleep(1200);
|
|
177
|
+
const dialog = await waitFor(() => findOne('[role="dialog"]'));
|
|
178
|
+
if (!dialog) return { ok: false, message: 'List selection dialog did not open' };
|
|
179
|
+
|
|
180
|
+
const targetName = ${JSON.stringify(targetName)};
|
|
181
|
+
const scrollCandidates = [
|
|
182
|
+
dialog.querySelector('[data-viewportview="true"]'),
|
|
183
|
+
...Array.from(dialog.querySelectorAll('div')).filter(d => d.scrollHeight > d.clientHeight + 10),
|
|
184
|
+
].filter(Boolean);
|
|
185
|
+
let scrollEl = scrollCandidates[0] || dialog;
|
|
186
|
+
for (const se of scrollCandidates) {
|
|
187
|
+
if (se.scrollHeight > se.clientHeight + 10) { scrollEl = se; break; }
|
|
188
|
+
}
|
|
189
|
+
let row = null;
|
|
190
|
+
let lastScrollTop = -1;
|
|
191
|
+
for (let i = 0; i < 12; i++) {
|
|
192
|
+
const cells = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'));
|
|
193
|
+
row = cells.find(c => (c.innerText || '').split('\\n')[0].trim() === targetName);
|
|
194
|
+
if (row) break;
|
|
195
|
+
const prev = scrollEl.scrollTop;
|
|
196
|
+
scrollEl.scrollTop = prev + Math.max(200, scrollEl.clientHeight - 100);
|
|
197
|
+
if (scrollEl.scrollTop === prev && scrollEl.scrollTop === lastScrollTop) break;
|
|
198
|
+
lastScrollTop = scrollEl.scrollTop;
|
|
199
|
+
await sleep(500);
|
|
200
|
+
}
|
|
201
|
+
if (!row) {
|
|
202
|
+
const names = Array.from(dialog.querySelectorAll('[data-testid="cellInnerDiv"]'))
|
|
203
|
+
.map(c => (c.innerText || '').split('\\n')[0].trim()).filter(Boolean);
|
|
204
|
+
return { ok: false, message: 'List "' + targetName + '" not found in dialog. Saw: ' + names.join(' | ') };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Determine current membership: row has a filled checkmark (svg inside a specific container) when member.
|
|
208
|
+
// Heuristic: look for an aria-checked attribute, or an svg with specific fill on the row's right side.
|
|
209
|
+
// The listCell itself carries aria-checked. Require a stable reading
|
|
210
|
+
// (same value twice ~500ms apart) to avoid the X dialog's occasional
|
|
211
|
+
// flash of stale state when re-opened shortly after a toggle.
|
|
212
|
+
const listCell = row.querySelector('[data-testid="listCell"]') || row.querySelector('[role="checkbox"]') || row;
|
|
213
|
+
const readChecked = () => {
|
|
214
|
+
const v = listCell.getAttribute('aria-checked');
|
|
215
|
+
return v === 'true' || v === 'false' ? v : null;
|
|
216
|
+
};
|
|
217
|
+
await sleep(600);
|
|
218
|
+
let ariaChecked = readChecked();
|
|
219
|
+
for (let i = 0; i < 8; i++) {
|
|
220
|
+
await sleep(500);
|
|
221
|
+
const next = readChecked();
|
|
222
|
+
if (next && next === ariaChecked) break;
|
|
223
|
+
ariaChecked = next || ariaChecked;
|
|
224
|
+
}
|
|
225
|
+
const isMember = ariaChecked === 'true';
|
|
226
|
+
if (!isMember) {
|
|
227
|
+
const closeBtn = findOne('[data-testid="app-bar-close"]') || findOne('[aria-label="Close"]');
|
|
228
|
+
if (closeBtn) closeBtn.click();
|
|
229
|
+
return { ok: true, noop: true };
|
|
230
|
+
}
|
|
231
|
+
try { listCell.scrollIntoView({ block: 'center' }); } catch {}
|
|
232
|
+
await sleep(400);
|
|
233
|
+
const rowRect = listCell.getBoundingClientRect();
|
|
234
|
+
const saveButton = Array.from(dialog.querySelectorAll('[role="button"], button')).find(b => {
|
|
235
|
+
const txt = (b.innerText || '').trim();
|
|
236
|
+
return /^(Save|Done|保存|完成|儲存)$/i.test(txt);
|
|
237
|
+
});
|
|
238
|
+
const saveRect = saveButton ? saveButton.getBoundingClientRect() : null;
|
|
239
|
+
return {
|
|
240
|
+
ok: true,
|
|
241
|
+
needsNativeInteraction: true,
|
|
242
|
+
rowClickX: Math.round(rowRect.left + rowRect.width / 2),
|
|
243
|
+
rowClickY: Math.round(rowRect.top + rowRect.height / 2),
|
|
244
|
+
saveClickX: saveRect ? Math.round(saveRect.left + saveRect.width / 2) : null,
|
|
245
|
+
saveClickY: saveRect ? Math.round(saveRect.top + saveRect.height / 2) : null,
|
|
246
|
+
mutationsBefore: window.__opencliListMutations.length,
|
|
247
|
+
};
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return { ok: false, message: 'UI error: ' + (e?.message || String(e)) };
|
|
250
|
+
}
|
|
251
|
+
})()`);
|
|
252
|
+
|
|
253
|
+
if (!uiResult.ok) {
|
|
254
|
+
throw new CommandExecutionError(`Failed to remove @${username} from list ${listId}: ${uiResult.message}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let verifiedBy = null;
|
|
258
|
+
if (uiResult.needsNativeInteraction) {
|
|
259
|
+
if (typeof page.nativeClick !== 'function') {
|
|
260
|
+
throw new CommandExecutionError('Requires up-to-date Chrome extension (nativeClick).');
|
|
261
|
+
}
|
|
262
|
+
if (!uiResult.saveClickX) {
|
|
263
|
+
throw new CommandExecutionError('Save button not found in dialog.');
|
|
264
|
+
}
|
|
265
|
+
const memberCountBefore = Number(targetList.members) || 0;
|
|
266
|
+
await page.nativeClick(uiResult.rowClickX, uiResult.rowClickY);
|
|
267
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
268
|
+
await page.nativeClick(uiResult.saveClickX, uiResult.saveClickY);
|
|
269
|
+
await new Promise((r) => setTimeout(r, 3500));
|
|
270
|
+
const listsAfter = await page.evaluate(`async () => {
|
|
271
|
+
const r = await fetch(${JSON.stringify(listsUrl)}, { headers: ${headers}, credentials: 'include' });
|
|
272
|
+
if (!r.ok) return { __error: 'HTTP ' + r.status };
|
|
273
|
+
return await r.json();
|
|
274
|
+
}`);
|
|
275
|
+
const parsedAfter = listsAfter && !listsAfter.__error
|
|
276
|
+
? parseListsManagement(listsAfter, new Set())
|
|
277
|
+
: [];
|
|
278
|
+
const afterList = parsedAfter.find((l) => l.id === listId);
|
|
279
|
+
const memberCountAfter = afterList ? Number(afterList.members) || 0 : -1;
|
|
280
|
+
if (memberCountAfter < memberCountBefore) {
|
|
281
|
+
verifiedBy = `member_count ${memberCountBefore} → ${memberCountAfter}`;
|
|
282
|
+
} else {
|
|
283
|
+
throw new CommandExecutionError(`Failed to remove @${username} from list ${listId}: member_count unchanged (${memberCountBefore} → ${memberCountAfter}).`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return [{
|
|
288
|
+
listId,
|
|
289
|
+
username,
|
|
290
|
+
userId: String(userId),
|
|
291
|
+
status: uiResult.noop ? 'noop' : 'success',
|
|
292
|
+
message: uiResult.noop
|
|
293
|
+
? `@${username} was not a member of list ${listId}`
|
|
294
|
+
: `Removed @${username} from list ${listId} (verified via ${verifiedBy})`,
|
|
295
|
+
}];
|
|
296
|
+
},
|
|
297
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './list-remove.js';
|
|
4
|
+
|
|
5
|
+
describe('twitter list-remove registration', () => {
|
|
6
|
+
it('registers the list-remove command with the expected shape', () => {
|
|
7
|
+
const cmd = getRegistry().get('twitter/list-remove');
|
|
8
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
9
|
+
expect(cmd?.columns).toEqual(['listId', 'username', 'userId', 'status', 'message']);
|
|
10
|
+
const listIdArg = cmd?.args?.find((a) => a.name === 'listId');
|
|
11
|
+
expect(listIdArg).toBeTruthy();
|
|
12
|
+
expect(listIdArg?.required).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
});
|