@jackwener/opencli 1.7.4 → 1.7.5
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 +71 -49
- package/README.zh-CN.md +73 -60
- package/cli-manifest.json +3261 -1758
- 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/deepseek/ask.js +74 -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 +208 -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/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/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/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 +1 -1
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.js +1 -1
- 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.js +7 -2
- 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/cli.js +227 -150
- package/dist/src/cli.test.js +167 -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.js +39 -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 +14 -5
- 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.d.ts +0 -1
- 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.d.ts +0 -1
- 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 → download/article-download.test.d.ts} +0 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
5
|
+
const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
|
|
6
|
+
const OPERATION_NAME = 'ListLatestTweetsTimeline';
|
|
7
|
+
|
|
8
|
+
const FEATURES = {
|
|
9
|
+
rweb_video_screen_enabled: false,
|
|
10
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
11
|
+
rweb_tipjar_consumption_enabled: true,
|
|
12
|
+
verified_phone_label_enabled: false,
|
|
13
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
14
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
15
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
16
|
+
premium_content_api_read_enabled: false,
|
|
17
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
18
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
19
|
+
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
20
|
+
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
21
|
+
responsive_web_jetfuel_frame: false,
|
|
22
|
+
responsive_web_grok_share_attachment_enabled: true,
|
|
23
|
+
articles_preview_enabled: true,
|
|
24
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
25
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
26
|
+
view_counts_everywhere_api_enabled: true,
|
|
27
|
+
longform_notetweets_consumption_enabled: true,
|
|
28
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
29
|
+
tweet_awards_web_tipping_enabled: false,
|
|
30
|
+
responsive_web_grok_show_grok_translated_post: false,
|
|
31
|
+
responsive_web_grok_analysis_button_from_backend: false,
|
|
32
|
+
creator_subscriptions_quote_tweet_preview_enabled: false,
|
|
33
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
34
|
+
standardized_nudges_misinfo: true,
|
|
35
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
36
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
37
|
+
longform_notetweets_inline_media_enabled: true,
|
|
38
|
+
responsive_web_grok_image_annotation_enabled: true,
|
|
39
|
+
responsive_web_enhance_cards_enabled: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function buildUrl(queryId, listId, count, cursor) {
|
|
43
|
+
const vars = { listId: String(listId), count };
|
|
44
|
+
if (cursor)
|
|
45
|
+
vars.cursor = cursor;
|
|
46
|
+
return `/i/api/graphql/${queryId}/${OPERATION_NAME}`
|
|
47
|
+
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
|
48
|
+
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function extractTimelineTweet(result, seen) {
|
|
52
|
+
if (!result)
|
|
53
|
+
return null;
|
|
54
|
+
const tw = result.tweet || result;
|
|
55
|
+
const legacy = tw.legacy || {};
|
|
56
|
+
if (!tw.rest_id || seen.has(tw.rest_id))
|
|
57
|
+
return null;
|
|
58
|
+
seen.add(tw.rest_id);
|
|
59
|
+
const user = tw.core?.user_results?.result;
|
|
60
|
+
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
|
|
61
|
+
const displayName = user?.legacy?.name || user?.core?.name || '';
|
|
62
|
+
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
63
|
+
return {
|
|
64
|
+
id: tw.rest_id,
|
|
65
|
+
author: screenName,
|
|
66
|
+
name: displayName,
|
|
67
|
+
text: noteText || legacy.full_text || '',
|
|
68
|
+
likes: legacy.favorite_count || 0,
|
|
69
|
+
retweets: legacy.retweet_count || 0,
|
|
70
|
+
replies: legacy.reply_count || 0,
|
|
71
|
+
created_at: legacy.created_at || '',
|
|
72
|
+
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseListTimeline(data, seen) {
|
|
77
|
+
const tweets = [];
|
|
78
|
+
let nextCursor = null;
|
|
79
|
+
const instructions = data?.data?.list?.tweets_timeline?.timeline?.instructions || [];
|
|
80
|
+
for (const inst of instructions) {
|
|
81
|
+
for (const entry of inst.entries || []) {
|
|
82
|
+
const content = entry.content;
|
|
83
|
+
if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') {
|
|
84
|
+
if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore')
|
|
85
|
+
nextCursor = content.value;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
|
|
89
|
+
nextCursor = content?.value || content?.itemContent?.value || nextCursor;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const direct = extractTimelineTweet(content?.itemContent?.tweet_results?.result, seen);
|
|
93
|
+
if (direct) {
|
|
94
|
+
tweets.push(direct);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (const item of content?.items || []) {
|
|
98
|
+
const nested = extractTimelineTweet(item.item?.itemContent?.tweet_results?.result, seen);
|
|
99
|
+
if (nested)
|
|
100
|
+
tweets.push(nested);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { tweets, nextCursor };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
cli({
|
|
108
|
+
site: 'twitter',
|
|
109
|
+
name: 'list-tweets',
|
|
110
|
+
description: 'Fetch tweets from a Twitter/X list timeline',
|
|
111
|
+
domain: 'x.com',
|
|
112
|
+
strategy: Strategy.COOKIE,
|
|
113
|
+
browser: true,
|
|
114
|
+
args: [
|
|
115
|
+
{ name: 'listId', positional: true, type: 'string', required: true },
|
|
116
|
+
{ name: 'limit', type: 'int', default: 50 },
|
|
117
|
+
],
|
|
118
|
+
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url'],
|
|
119
|
+
func: async (page, kwargs) => {
|
|
120
|
+
const listId = String(kwargs.listId || '').trim();
|
|
121
|
+
if (!listId || !/^\d+$/.test(listId)) {
|
|
122
|
+
throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected a numeric ID (see \`opencli twitter lists\`).`);
|
|
123
|
+
}
|
|
124
|
+
const limit = kwargs.limit || 50;
|
|
125
|
+
await page.goto('https://x.com');
|
|
126
|
+
await page.wait(3);
|
|
127
|
+
const ct0 = await page.evaluate(`() => {
|
|
128
|
+
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
129
|
+
}`);
|
|
130
|
+
if (!ct0)
|
|
131
|
+
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
132
|
+
const queryId = await page.evaluate(`async () => {
|
|
133
|
+
try {
|
|
134
|
+
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
135
|
+
if (ghResp.ok) {
|
|
136
|
+
const data = await ghResp.json();
|
|
137
|
+
const entry = data['${OPERATION_NAME}'];
|
|
138
|
+
if (entry && entry.queryId) return entry.queryId;
|
|
139
|
+
}
|
|
140
|
+
} catch {}
|
|
141
|
+
try {
|
|
142
|
+
const scripts = performance.getEntriesByType('resource')
|
|
143
|
+
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
|
144
|
+
.map(r => r.name);
|
|
145
|
+
for (const scriptUrl of scripts.slice(0, 15)) {
|
|
146
|
+
try {
|
|
147
|
+
const text = await (await fetch(scriptUrl)).text();
|
|
148
|
+
const re = /queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"${OPERATION_NAME}"/;
|
|
149
|
+
const m = text.match(re);
|
|
150
|
+
if (m) return m[1];
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
} catch {}
|
|
154
|
+
return null;
|
|
155
|
+
}`) || LIST_TWEETS_QUERY_ID;
|
|
156
|
+
const headers = JSON.stringify({
|
|
157
|
+
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
158
|
+
'X-Csrf-Token': ct0,
|
|
159
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
160
|
+
'X-Twitter-Active-User': 'yes',
|
|
161
|
+
});
|
|
162
|
+
const allTweets = [];
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
let cursor = null;
|
|
165
|
+
for (let i = 0; i < 10 && allTweets.length < limit; i++) {
|
|
166
|
+
const fetchCount = Math.min(100, limit - allTweets.length + 10);
|
|
167
|
+
const apiUrl = buildUrl(queryId, listId, fetchCount, cursor);
|
|
168
|
+
const data = await page.evaluate(`async () => {
|
|
169
|
+
const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' });
|
|
170
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
171
|
+
}`);
|
|
172
|
+
if (data?.error) {
|
|
173
|
+
if (allTweets.length === 0)
|
|
174
|
+
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch list timeline. queryId may have expired or list may be private.`);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
const { tweets, nextCursor } = parseListTimeline(data, seen);
|
|
178
|
+
allTweets.push(...tweets);
|
|
179
|
+
if (!nextCursor || nextCursor === cursor || tweets.length === 0)
|
|
180
|
+
break;
|
|
181
|
+
cursor = nextCursor;
|
|
182
|
+
}
|
|
183
|
+
return allTweets.slice(0, limit);
|
|
184
|
+
},
|
|
185
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { extractTimelineTweet, parseListTimeline } from './list-tweets.js';
|
|
3
|
+
|
|
4
|
+
describe('twitter list-tweets parser', () => {
|
|
5
|
+
it('extracts core tweet fields from a ListLatestTweetsTimeline result', () => {
|
|
6
|
+
const tweet = extractTimelineTweet({
|
|
7
|
+
rest_id: '99',
|
|
8
|
+
legacy: {
|
|
9
|
+
full_text: 'hello list',
|
|
10
|
+
favorite_count: 3,
|
|
11
|
+
retweet_count: 1,
|
|
12
|
+
reply_count: 2,
|
|
13
|
+
created_at: 'Wed Apr 16 10:00:00 +0000 2026',
|
|
14
|
+
},
|
|
15
|
+
core: {
|
|
16
|
+
user_results: {
|
|
17
|
+
result: {
|
|
18
|
+
legacy: { screen_name: 'bob', name: 'Bob' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}, new Set());
|
|
23
|
+
expect(tweet).toEqual({
|
|
24
|
+
id: '99',
|
|
25
|
+
author: 'bob',
|
|
26
|
+
name: 'Bob',
|
|
27
|
+
text: 'hello list',
|
|
28
|
+
likes: 3,
|
|
29
|
+
retweets: 1,
|
|
30
|
+
replies: 2,
|
|
31
|
+
created_at: 'Wed Apr 16 10:00:00 +0000 2026',
|
|
32
|
+
url: 'https://x.com/bob/status/99',
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('prefers long-form note_tweet text over truncated legacy full_text', () => {
|
|
37
|
+
const tweet = extractTimelineTweet({
|
|
38
|
+
rest_id: '100',
|
|
39
|
+
legacy: { full_text: 'short…' },
|
|
40
|
+
note_tweet: {
|
|
41
|
+
note_tweet_results: {
|
|
42
|
+
result: { text: 'the full long-form body' },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
core: { user_results: { result: { legacy: { screen_name: 'carol' } } } },
|
|
46
|
+
}, new Set());
|
|
47
|
+
expect(tweet?.text).toBe('the full long-form body');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('deduplicates on rest_id', () => {
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
const first = extractTimelineTweet({ rest_id: '1', legacy: {}, core: {} }, seen);
|
|
53
|
+
const second = extractTimelineTweet({ rest_id: '1', legacy: {}, core: {} }, seen);
|
|
54
|
+
expect(first).not.toBeNull();
|
|
55
|
+
expect(second).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('parses entries and bottom cursor from the list timeline payload', () => {
|
|
59
|
+
const payload = {
|
|
60
|
+
data: {
|
|
61
|
+
list: {
|
|
62
|
+
tweets_timeline: {
|
|
63
|
+
timeline: {
|
|
64
|
+
instructions: [
|
|
65
|
+
{
|
|
66
|
+
entries: [
|
|
67
|
+
{
|
|
68
|
+
entryId: 'tweet-1',
|
|
69
|
+
content: {
|
|
70
|
+
itemContent: {
|
|
71
|
+
tweet_results: {
|
|
72
|
+
result: {
|
|
73
|
+
rest_id: '1',
|
|
74
|
+
legacy: { full_text: 't1' },
|
|
75
|
+
core: { user_results: { result: { legacy: { screen_name: 'a' } } } },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
entryId: 'cursor-bottom-1',
|
|
83
|
+
content: {
|
|
84
|
+
entryType: 'TimelineTimelineCursor',
|
|
85
|
+
cursorType: 'Bottom',
|
|
86
|
+
value: 'cursor-next',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const result = parseListTimeline(payload, new Set());
|
|
98
|
+
expect(result.nextCursor).toBe('cursor-next');
|
|
99
|
+
expect(result.tweets).toHaveLength(1);
|
|
100
|
+
expect(result.tweets[0]).toMatchObject({ id: '1', author: 'a', text: 't1' });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns empty tweets and null cursor for malformed payload', () => {
|
|
104
|
+
const result = parseListTimeline({}, new Set());
|
|
105
|
+
expect(result.tweets).toEqual([]);
|
|
106
|
+
expect(result.nextCursor).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|