@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,25 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'nowcoder',
|
|
5
|
+
name: 'trending',
|
|
6
|
+
description: 'Trending posts',
|
|
7
|
+
domain: 'www.nowcoder.com',
|
|
8
|
+
strategy: Strategy.PUBLIC,
|
|
9
|
+
browser: false,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Number of items' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['rank', 'title', 'heat', 'id'],
|
|
14
|
+
pipeline: [
|
|
15
|
+
{ fetch: { url: 'https://gw-c.nowcoder.com/api/sparta/hot-search/top-hot-pc' } },
|
|
16
|
+
{ select: 'data.result' },
|
|
17
|
+
{ map: {
|
|
18
|
+
rank: '${{ index + 1 }}',
|
|
19
|
+
title: '${{ item.title }}',
|
|
20
|
+
heat: '${{ item.hotValueFromDolphin }}',
|
|
21
|
+
id: '${{ item.uuid || item.id || "" }}',
|
|
22
|
+
} },
|
|
23
|
+
{ limit: '${{ args.limit }}' },
|
|
24
|
+
],
|
|
25
|
+
});
|
|
@@ -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
|
+
});
|