@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,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
|
+
});
|
package/clis/twitter/lists.js
CHANGED
|
@@ -1,62 +1,149 @@
|
|
|
1
|
-
import { AuthRequiredError, SelectorError } from '@jackwener/opencli/errors';
|
|
2
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
-
import {
|
|
2
|
+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
5
|
+
const LISTS_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
|
|
6
|
+
const OPERATION_NAME = 'ListsManagementPageTimeline';
|
|
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) {
|
|
43
|
+
return `/i/api/graphql/${queryId}/${OPERATION_NAME}`
|
|
44
|
+
+ `?features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function extractListEntry(entry, seen) {
|
|
48
|
+
const list = entry?.content?.itemContent?.list
|
|
49
|
+
|| entry?.content?.list
|
|
50
|
+
|| entry?.item?.itemContent?.list;
|
|
51
|
+
if (!list) return null;
|
|
52
|
+
const id = list.id_str || list.id || '';
|
|
53
|
+
if (!id || seen.has(id)) return null;
|
|
54
|
+
seen.add(id);
|
|
55
|
+
const mode = typeof list.mode === 'string' && /private/i.test(list.mode) ? 'private' : 'public';
|
|
56
|
+
return {
|
|
57
|
+
id: String(id),
|
|
58
|
+
name: list.name || '',
|
|
59
|
+
members: String(list.member_count ?? 0),
|
|
60
|
+
followers: String(list.subscriber_count ?? 0),
|
|
61
|
+
mode,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseListsManagement(data, seen) {
|
|
66
|
+
const lists = [];
|
|
67
|
+
const instructions = data?.data?.viewer?.list_management_timeline?.timeline?.instructions
|
|
68
|
+
|| data?.data?.viewer_v2?.user_results?.result?.list_management_timeline?.timeline?.instructions
|
|
69
|
+
|| data?.data?.list_management_timeline?.timeline?.instructions
|
|
70
|
+
|| [];
|
|
71
|
+
for (const inst of instructions) {
|
|
72
|
+
for (const entry of inst.entries || []) {
|
|
73
|
+
const direct = extractListEntry(entry, seen);
|
|
74
|
+
if (direct) {
|
|
75
|
+
lists.push(direct);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
for (const item of entry?.content?.items || []) {
|
|
79
|
+
const nested = extractListEntry(item, seen);
|
|
80
|
+
if (nested) lists.push(nested);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return lists;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const command = cli({
|
|
6
88
|
site: 'twitter',
|
|
7
89
|
name: 'lists',
|
|
8
|
-
description: 'Get Twitter/X lists for
|
|
90
|
+
description: 'Get Twitter/X lists for the logged-in user (owned + subscribed)',
|
|
9
91
|
domain: 'x.com',
|
|
10
92
|
strategy: Strategy.COOKIE,
|
|
11
93
|
browser: true,
|
|
12
94
|
args: [
|
|
13
|
-
{ name: 'user', positional: true, type: 'string', required: false },
|
|
14
95
|
{ name: 'limit', type: 'int', default: 50 },
|
|
15
96
|
],
|
|
16
|
-
columns: ['name', 'members', 'followers', 'mode'],
|
|
97
|
+
columns: ['id', 'name', 'members', 'followers', 'mode'],
|
|
17
98
|
func: async (page, kwargs) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
await page.goto('https://x.com/home');
|
|
21
|
-
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
22
|
-
const href = await page.evaluate(`() => {
|
|
23
|
-
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
24
|
-
return link ? link.getAttribute('href') : null;
|
|
25
|
-
}`);
|
|
26
|
-
if (!href) {
|
|
27
|
-
throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
|
|
28
|
-
}
|
|
29
|
-
targetUser = href.replace('/', '');
|
|
30
|
-
}
|
|
31
|
-
await page.goto(`https://x.com/${targetUser}/lists`);
|
|
99
|
+
const limit = kwargs.limit || 50;
|
|
100
|
+
await page.goto('https://x.com');
|
|
32
101
|
await page.wait(3);
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const seen = new Set();
|
|
36
|
-
for (const anchor of Array.from(document.querySelectorAll('a[href*="/i/lists/"]'))) {
|
|
37
|
-
const href = anchor.getAttribute('href') || '';
|
|
38
|
-
if (!/\\/i\\/lists\\/\\d+/.test(href) || seen.has(href)) continue;
|
|
39
|
-
seen.add(href);
|
|
40
|
-
const container = anchor.closest('[data-testid="cellInnerDiv"]') || anchor;
|
|
41
|
-
const text = (container.innerText || anchor.innerText || '').trim();
|
|
42
|
-
if (!text) continue;
|
|
43
|
-
cards.push({ href, text });
|
|
44
|
-
}
|
|
45
|
-
return {
|
|
46
|
-
cards,
|
|
47
|
-
pageText: document.body.innerText || '',
|
|
48
|
-
};
|
|
102
|
+
const ct0 = await page.evaluate(`() => {
|
|
103
|
+
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
49
104
|
}`);
|
|
50
|
-
if (!
|
|
51
|
-
throw new
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
105
|
+
if (!ct0)
|
|
106
|
+
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
107
|
+
const queryId = await page.evaluate(`async () => {
|
|
108
|
+
try {
|
|
109
|
+
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
110
|
+
if (ghResp.ok) {
|
|
111
|
+
const data = await ghResp.json();
|
|
112
|
+
const entry = data['${OPERATION_NAME}'];
|
|
113
|
+
if (entry && entry.queryId) return entry.queryId;
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
try {
|
|
117
|
+
const scripts = performance.getEntriesByType('resource')
|
|
118
|
+
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
|
119
|
+
.map(r => r.name);
|
|
120
|
+
for (const scriptUrl of scripts.slice(0, 15)) {
|
|
121
|
+
try {
|
|
122
|
+
const text = await (await fetch(scriptUrl)).text();
|
|
123
|
+
const re = /queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"${OPERATION_NAME}"/;
|
|
124
|
+
const m = text.match(re);
|
|
125
|
+
if (m) return m[1];
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
return null;
|
|
130
|
+
}`) || LISTS_QUERY_ID;
|
|
131
|
+
const headers = JSON.stringify({
|
|
132
|
+
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
133
|
+
'X-Csrf-Token': ct0,
|
|
134
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
135
|
+
'X-Twitter-Active-User': 'yes',
|
|
136
|
+
});
|
|
137
|
+
const apiUrl = buildUrl(queryId);
|
|
138
|
+
const data = await page.evaluate(`async () => {
|
|
139
|
+
const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' });
|
|
140
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
141
|
+
}`);
|
|
142
|
+
if (data?.error) {
|
|
143
|
+
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch lists. queryId may have expired.`);
|
|
59
144
|
}
|
|
60
|
-
|
|
61
|
-
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
const lists = parseListsManagement(data, seen);
|
|
147
|
+
return lists.slice(0, limit);
|
|
148
|
+
},
|
|
62
149
|
});
|
|
@@ -1,50 +1,117 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { extractListEntry, parseListsManagement } from './lists.js';
|
|
3
3
|
|
|
4
4
|
describe('twitter lists parser', () => {
|
|
5
|
-
it('
|
|
6
|
-
const
|
|
7
|
-
{
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
it('extracts a list entry with full metadata', () => {
|
|
6
|
+
const entry = {
|
|
7
|
+
content: {
|
|
8
|
+
itemContent: {
|
|
9
|
+
list: {
|
|
10
|
+
id_str: '1597593475389984769',
|
|
11
|
+
name: 'Crypto',
|
|
12
|
+
member_count: 44,
|
|
13
|
+
subscriber_count: 8747,
|
|
14
|
+
mode: 'Public',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
13
17
|
},
|
|
14
|
-
|
|
15
|
-
expect(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
]);
|
|
18
|
+
};
|
|
19
|
+
expect(extractListEntry(entry, new Set())).toEqual({
|
|
20
|
+
id: '1597593475389984769',
|
|
21
|
+
name: 'Crypto',
|
|
22
|
+
members: '44',
|
|
23
|
+
followers: '8747',
|
|
24
|
+
mode: 'public',
|
|
25
|
+
});
|
|
23
26
|
});
|
|
24
27
|
|
|
25
|
-
it('
|
|
26
|
-
const
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
it('maps Private mode to private', () => {
|
|
29
|
+
const entry = {
|
|
30
|
+
content: {
|
|
31
|
+
itemContent: {
|
|
32
|
+
list: {
|
|
33
|
+
id_str: '2044679538156912976',
|
|
34
|
+
name: 'AI & Agents',
|
|
35
|
+
member_count: 15,
|
|
36
|
+
subscriber_count: 0,
|
|
37
|
+
mode: 'Private',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
33
40
|
},
|
|
34
|
-
|
|
35
|
-
expect(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
};
|
|
42
|
+
expect(extractListEntry(entry, new Set())?.mode).toBe('private');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('deduplicates by list id', () => {
|
|
46
|
+
const entry = {
|
|
47
|
+
content: { itemContent: { list: { id_str: '1', name: 'X' } } },
|
|
48
|
+
};
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
expect(extractListEntry(entry, seen)).not.toBeNull();
|
|
51
|
+
expect(extractListEntry(entry, seen)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns null when no list payload is present', () => {
|
|
55
|
+
expect(extractListEntry({}, new Set())).toBeNull();
|
|
56
|
+
expect(extractListEntry({ content: { itemContent: {} } }, new Set())).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('parses ListsManagementPageTimeline payload instructions', () => {
|
|
60
|
+
const payload = {
|
|
61
|
+
data: {
|
|
62
|
+
viewer: {
|
|
63
|
+
list_management_timeline: {
|
|
64
|
+
timeline: {
|
|
65
|
+
instructions: [
|
|
66
|
+
{
|
|
67
|
+
entries: [
|
|
68
|
+
{
|
|
69
|
+
entryId: 'owned-list-1',
|
|
70
|
+
content: {
|
|
71
|
+
itemContent: {
|
|
72
|
+
list: { id_str: '1', name: 'Crypto', member_count: 44, subscriber_count: 8747, mode: 'Public' },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
entryId: 'subscribed-list-2',
|
|
78
|
+
content: {
|
|
79
|
+
itemContent: {
|
|
80
|
+
list: { id_str: '2', name: 'AI', member_count: 15, subscriber_count: 0, mode: 'Private' },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
41
90
|
},
|
|
42
|
-
|
|
91
|
+
};
|
|
92
|
+
const result = parseListsManagement(payload, new Set());
|
|
93
|
+
expect(result).toHaveLength(2);
|
|
94
|
+
expect(result[0]).toMatchObject({ id: '1', name: 'Crypto', mode: 'public' });
|
|
95
|
+
expect(result[1]).toMatchObject({ id: '2', name: 'AI', mode: 'private' });
|
|
43
96
|
});
|
|
44
97
|
|
|
45
|
-
it('
|
|
46
|
-
expect(
|
|
47
|
-
expect(
|
|
48
|
-
|
|
98
|
+
it('returns empty list for malformed payload', () => {
|
|
99
|
+
expect(parseListsManagement({}, new Set())).toEqual([]);
|
|
100
|
+
expect(parseListsManagement({ data: {} }, new Set())).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('dedupes across repeated entries', () => {
|
|
104
|
+
const entryA = { content: { itemContent: { list: { id_str: '1', name: 'A' } } } };
|
|
105
|
+
const payload = {
|
|
106
|
+
data: {
|
|
107
|
+
viewer: {
|
|
108
|
+
list_management_timeline: {
|
|
109
|
+
timeline: { instructions: [{ entries: [entryA, entryA] }] },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const result = parseListsManagement(payload, new Set());
|
|
115
|
+
expect(result).toHaveLength(1);
|
|
49
116
|
});
|
|
50
117
|
});
|
package/clis/twitter/shared.js
CHANGED
|
@@ -5,14 +5,19 @@ export function sanitizeQueryId(resolved, fallbackId) {
|
|
|
5
5
|
export async function resolveTwitterQueryId(page, operationName, fallbackId) {
|
|
6
6
|
const resolved = await page.evaluate(`async () => {
|
|
7
7
|
const operationName = ${JSON.stringify(operationName)};
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
8
10
|
try {
|
|
9
|
-
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
11
|
+
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json', { signal: controller.signal });
|
|
12
|
+
clearTimeout(timeout);
|
|
10
13
|
if (ghResp.ok) {
|
|
11
14
|
const data = await ghResp.json();
|
|
12
15
|
const entry = data?.[operationName];
|
|
13
16
|
if (entry && entry.queryId) return entry.queryId;
|
|
14
17
|
}
|
|
15
|
-
} catch {
|
|
18
|
+
} catch {
|
|
19
|
+
clearTimeout(timeout);
|
|
20
|
+
}
|
|
16
21
|
try {
|
|
17
22
|
const scripts = performance.getEntriesByType('resource')
|
|
18
23
|
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|