@jackwener/opencli 1.7.22 → 1.8.0
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 +30 -148
- package/README.zh-CN.md +37 -211
- package/cli-manifest.json +6423 -4260
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +226 -0
- package/clis/suno/generate.test.js +243 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +62 -0
- package/clis/suno/utils.js +540 -0
- package/clis/suno/utils.test.js +223 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +12 -5
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/search.js +6 -2
- package/clis/twitter/search.test.js +6 -0
- package/clis/twitter/shared.js +144 -0
- package/clis/twitter/shared.test.js +429 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/search-regression.test.js +18 -11
- package/clis/weread/search.js +15 -7
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +2 -1
- package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +2 -1
- package/clis/xiaohongshu/creator-notes.test.js +12 -0
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +299 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +12 -0
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +15 -2
- package/clis/zhihu/collection.test.js +46 -0
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -9
- package/clis/zhihu/question.test.js +111 -9
- package/clis/zhihu/search.js +206 -43
- package/clis/zhihu/search.test.js +198 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +110 -0
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
package/clis/twitter/download.js
CHANGED
|
@@ -1,111 +1,481 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Twitter/X download — download images and videos from tweets.
|
|
3
3
|
*
|
|
4
|
+
* Profile media path uses the same GraphQL UserMedia endpoint the
|
|
5
|
+
* native client uses with cursor-based pagination, so it bypasses the
|
|
6
|
+
* virtual-scroll DOM cap that limited the previous scraper to ~visible
|
|
7
|
+
* tiles (see #1612).
|
|
8
|
+
*
|
|
4
9
|
* Usage:
|
|
5
|
-
* opencli twitter download elonmusk --limit
|
|
10
|
+
* opencli twitter download elonmusk --limit 50 --output ./twitter
|
|
6
11
|
* opencli twitter download --tweet-url https://x.com/xxx/status/123 --output ./twitter
|
|
7
12
|
*/
|
|
8
13
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
14
|
+
import { ArgumentError, AuthRequiredError, CliError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
9
15
|
import { formatCookieHeader } from '@jackwener/opencli/download';
|
|
10
16
|
import { downloadMedia } from '@jackwener/opencli/download/media-download';
|
|
17
|
+
import {
|
|
18
|
+
resolveTwitterOperationMetadata,
|
|
19
|
+
normalizeTwitterGraphqlPayload,
|
|
20
|
+
unwrapBrowserResult,
|
|
21
|
+
normalizeTwitterScreenName,
|
|
22
|
+
extractMedia,
|
|
23
|
+
parseTweetUrl,
|
|
24
|
+
} from './shared.js';
|
|
25
|
+
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
26
|
+
|
|
27
|
+
const USER_MEDIA_QUERY_ID = '9EovraBTXJYGSEQXZqlLmQ';
|
|
28
|
+
const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
|
|
29
|
+
const MAX_PAGINATION_PAGES = 100;
|
|
30
|
+
|
|
31
|
+
const USER_MEDIA_FEATURES = {
|
|
32
|
+
rweb_video_screen_enabled: true,
|
|
33
|
+
rweb_cashtags_enabled: true,
|
|
34
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
35
|
+
responsive_web_profile_redirect_enabled: true,
|
|
36
|
+
rweb_tipjar_consumption_enabled: true,
|
|
37
|
+
verified_phone_label_enabled: false,
|
|
38
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
39
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
40
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
41
|
+
premium_content_api_read_enabled: false,
|
|
42
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
43
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
44
|
+
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
45
|
+
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
46
|
+
rweb_cashtags_composer_attachment_enabled: true,
|
|
47
|
+
responsive_web_jetfuel_frame: true,
|
|
48
|
+
responsive_web_grok_share_attachment_enabled: true,
|
|
49
|
+
responsive_web_grok_annotations_enabled: true,
|
|
50
|
+
articles_preview_enabled: true,
|
|
51
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
52
|
+
rweb_conversational_replies_downvote_enabled: true,
|
|
53
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
54
|
+
view_counts_everywhere_api_enabled: true,
|
|
55
|
+
longform_notetweets_consumption_enabled: true,
|
|
56
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
57
|
+
content_disclosure_indicator_enabled: true,
|
|
58
|
+
content_disclosure_ai_generated_indicator_enabled: true,
|
|
59
|
+
responsive_web_grok_show_grok_translated_post: false,
|
|
60
|
+
responsive_web_grok_analysis_button_from_backend: true,
|
|
61
|
+
post_ctas_fetch_enabled: false,
|
|
62
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
63
|
+
standardized_nudges_misinfo: true,
|
|
64
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
65
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
66
|
+
longform_notetweets_inline_media_enabled: true,
|
|
67
|
+
responsive_web_grok_image_annotation_enabled: true,
|
|
68
|
+
responsive_web_grok_imagine_annotation_enabled: true,
|
|
69
|
+
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
|
70
|
+
responsive_web_enhance_cards_enabled: false,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const USER_MEDIA_FIELD_TOGGLES = {
|
|
74
|
+
withPayments: true,
|
|
75
|
+
withAuxiliaryUserLabels: true,
|
|
76
|
+
withArticleRichContentState: true,
|
|
77
|
+
withArticlePlainText: true,
|
|
78
|
+
withArticleSummaryText: true,
|
|
79
|
+
withArticleVoiceOver: true,
|
|
80
|
+
withGrokAnalyze: true,
|
|
81
|
+
withDisallowedReplyControls: true,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const USER_BY_SCREEN_NAME_FEATURES = {
|
|
85
|
+
hidden_profile_subscriptions_enabled: true,
|
|
86
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
87
|
+
responsive_web_profile_redirect_enabled: true,
|
|
88
|
+
rweb_tipjar_consumption_enabled: true,
|
|
89
|
+
responsive_web_graphql_exclude_directive_enabled: true,
|
|
90
|
+
verified_phone_label_enabled: false,
|
|
91
|
+
subscriptions_verification_info_is_identity_verified_enabled: true,
|
|
92
|
+
subscriptions_verification_info_verified_since_enabled: true,
|
|
93
|
+
highlights_tweets_tab_ui_enabled: true,
|
|
94
|
+
responsive_web_twitter_article_notes_tab_enabled: true,
|
|
95
|
+
subscriptions_feature_can_gift_premium: true,
|
|
96
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
97
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
98
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const USER_BY_SCREEN_NAME_FIELD_TOGGLES = {
|
|
102
|
+
withPayments: true,
|
|
103
|
+
withAuxiliaryUserLabels: true,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const USER_MEDIA_OPERATION = {
|
|
107
|
+
queryId: USER_MEDIA_QUERY_ID,
|
|
108
|
+
features: USER_MEDIA_FEATURES,
|
|
109
|
+
fieldToggles: USER_MEDIA_FIELD_TOGGLES,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const USER_BY_SCREEN_NAME_OPERATION = {
|
|
113
|
+
queryId: USER_BY_SCREEN_NAME_QUERY_ID,
|
|
114
|
+
features: USER_BY_SCREEN_NAME_FEATURES,
|
|
115
|
+
fieldToggles: USER_BY_SCREEN_NAME_FIELD_TOGGLES,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function requireLimit(value) {
|
|
119
|
+
const limit = Number(value ?? 10);
|
|
120
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
|
|
121
|
+
throw new ArgumentError('--limit must be an integer between 1 and 1000');
|
|
122
|
+
}
|
|
123
|
+
return limit;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function nextUserMediaFetchCount(limit, downloadedCount) {
|
|
127
|
+
const remaining = limit - downloadedCount;
|
|
128
|
+
if (remaining <= 0) return 0;
|
|
129
|
+
const requested = remaining + 10;
|
|
130
|
+
if (requested > 100) return 100;
|
|
131
|
+
return requested;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function downloadTwitterMedia(items, options) {
|
|
135
|
+
const rows = await downloadMedia(items, options);
|
|
136
|
+
return rows.map((row, index) => {
|
|
137
|
+
const item = items[index] || {};
|
|
138
|
+
return {
|
|
139
|
+
index: row.index,
|
|
140
|
+
tweet_id: item.tweet_id || '',
|
|
141
|
+
url: item.url || '',
|
|
142
|
+
type: row.type,
|
|
143
|
+
status: row.status,
|
|
144
|
+
size: row.size,
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeUserMediaOperation(operation) {
|
|
150
|
+
if (typeof operation === 'string') {
|
|
151
|
+
return { queryId: operation, features: USER_MEDIA_FEATURES, fieldToggles: USER_MEDIA_FIELD_TOGGLES };
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
queryId: operation?.queryId || USER_MEDIA_QUERY_ID,
|
|
155
|
+
features: operation?.features || USER_MEDIA_FEATURES,
|
|
156
|
+
fieldToggles: operation?.fieldToggles || USER_MEDIA_FIELD_TOGGLES,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizeUserByScreenNameOperation(operation) {
|
|
161
|
+
if (typeof operation === 'string') {
|
|
162
|
+
return { queryId: operation, features: USER_BY_SCREEN_NAME_FEATURES, fieldToggles: USER_BY_SCREEN_NAME_FIELD_TOGGLES };
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
queryId: operation?.queryId || USER_BY_SCREEN_NAME_QUERY_ID,
|
|
166
|
+
features: operation?.features || USER_BY_SCREEN_NAME_FEATURES,
|
|
167
|
+
fieldToggles: operation?.fieldToggles || USER_BY_SCREEN_NAME_FIELD_TOGGLES,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function appendGraphqlParams(path, variables, operation) {
|
|
172
|
+
const fieldToggles = operation.fieldToggles || {};
|
|
173
|
+
const params = [
|
|
174
|
+
`variables=${encodeURIComponent(JSON.stringify(variables))}`,
|
|
175
|
+
`features=${encodeURIComponent(JSON.stringify(operation.features || {}))}`,
|
|
176
|
+
];
|
|
177
|
+
if (Object.keys(fieldToggles).length > 0) {
|
|
178
|
+
params.push(`fieldToggles=${encodeURIComponent(JSON.stringify(fieldToggles))}`);
|
|
179
|
+
}
|
|
180
|
+
return `${path}?${params.join('&')}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildUserMediaUrl(operation, userId, count, cursor) {
|
|
184
|
+
const normalized = normalizeUserMediaOperation(operation);
|
|
185
|
+
const vars = {
|
|
186
|
+
userId,
|
|
187
|
+
count,
|
|
188
|
+
includePromotedContent: false,
|
|
189
|
+
withClientEventToken: false,
|
|
190
|
+
withBirdwatchNotes: false,
|
|
191
|
+
withVoice: true,
|
|
192
|
+
};
|
|
193
|
+
if (cursor) vars.cursor = cursor;
|
|
194
|
+
return appendGraphqlParams(`/i/api/graphql/${normalized.queryId}/UserMedia`, vars, normalized);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function buildUserByScreenNameUrl(operation, screenName) {
|
|
198
|
+
const normalized = normalizeUserByScreenNameOperation(operation);
|
|
199
|
+
const vars = { screen_name: screenName, withSafetyModeUserFields: true };
|
|
200
|
+
return appendGraphqlParams(`/i/api/graphql/${normalized.queryId}/UserByScreenName`, vars, normalized);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function classifyMediaUrl(url) {
|
|
204
|
+
if (!url) return 'unknown';
|
|
205
|
+
if (/video\.twimg\.com|\.mp4(\?|$)|\.m3u8(\?|$)/.test(url)) return 'video';
|
|
206
|
+
return 'image';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function requireObjectPayload(value, context) {
|
|
210
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
211
|
+
throw new CommandExecutionError(`Twitter ${context} returned malformed payload`);
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function throwGraphqlFetchError(context, status, message) {
|
|
217
|
+
if (status === 401 || status === 403) {
|
|
218
|
+
throw new AuthRequiredError('x.com', `Twitter ${context} requires an authenticated x.com session`);
|
|
219
|
+
}
|
|
220
|
+
if (status === 404) {
|
|
221
|
+
throw new EmptyResultError(`twitter download ${context}`, message || 'Twitter returned not found');
|
|
222
|
+
}
|
|
223
|
+
const statusText = status ? `HTTP ${status}` : 'fetch failed';
|
|
224
|
+
throw new CommandExecutionError(`Twitter ${context} fetch failed: ${statusText}${message ? ` - ${message}` : ''}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function requireFetchPayload(value, context) {
|
|
228
|
+
const result = requireObjectPayload(unwrapBrowserResult(value), context);
|
|
229
|
+
if (result.ok === true) {
|
|
230
|
+
return result.payload;
|
|
231
|
+
}
|
|
232
|
+
if (result.ok === false) {
|
|
233
|
+
throwGraphqlFetchError(context, Number(result.status) || 0, typeof result.error === 'string' ? result.error : '');
|
|
234
|
+
}
|
|
235
|
+
throw new CommandExecutionError(`Twitter ${context} returned malformed fetch result`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function requireUserMediaPayload(data) {
|
|
239
|
+
const payload = requireObjectPayload(data, 'UserMedia');
|
|
240
|
+
if (Array.isArray(payload.errors) && payload.errors.length > 0) {
|
|
241
|
+
throw new CommandExecutionError(`Twitter UserMedia returned GraphQL errors: ${JSON.stringify(payload.errors).slice(0, 200)}`);
|
|
242
|
+
}
|
|
243
|
+
const result = payload.data?.user?.result;
|
|
244
|
+
if (!result || typeof result !== 'object') {
|
|
245
|
+
throw new CommandExecutionError('Twitter UserMedia returned malformed user result');
|
|
246
|
+
}
|
|
247
|
+
const instructions = result.timeline_v2?.timeline?.instructions || result.timeline?.timeline?.instructions;
|
|
248
|
+
if (!Array.isArray(instructions)) {
|
|
249
|
+
throw new CommandExecutionError('Twitter UserMedia returned malformed timeline instructions');
|
|
250
|
+
}
|
|
251
|
+
return payload;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseUserMedia(data, seen) {
|
|
255
|
+
const items = [];
|
|
256
|
+
let nextCursor = null;
|
|
257
|
+
const result = requireUserMediaPayload(data).data.user.result;
|
|
258
|
+
const instructionSets = [
|
|
259
|
+
result.timeline_v2?.timeline?.instructions,
|
|
260
|
+
result.timeline?.timeline?.instructions,
|
|
261
|
+
].filter(Array.isArray);
|
|
262
|
+
const instructions = instructionSets.flat();
|
|
263
|
+
const visit = (value) => {
|
|
264
|
+
if (!value || typeof value !== 'object') return;
|
|
265
|
+
if (value.type === 'TimelinePinEntry') return;
|
|
266
|
+
if (value.tweet_results?.result) {
|
|
267
|
+
const raw = value.tweet_results.result;
|
|
268
|
+
const tw = raw.__typename === 'TweetWithVisibilityResults' && raw.tweet
|
|
269
|
+
? raw.tweet
|
|
270
|
+
: (raw.tweet || raw);
|
|
271
|
+
const tweetId = typeof tw.rest_id === 'string' || typeof tw.rest_id === 'number' ? String(tw.rest_id) : '';
|
|
272
|
+
if (!tweetId) {
|
|
273
|
+
throw new CommandExecutionError('Twitter UserMedia returned a tweet without rest_id');
|
|
274
|
+
}
|
|
275
|
+
if (!seen.has(tweetId)) {
|
|
276
|
+
seen.add(tweetId);
|
|
277
|
+
const { media_urls } = extractMedia(tw.legacy || {});
|
|
278
|
+
for (const url of media_urls) {
|
|
279
|
+
items.push({ tweet_id: tweetId, url, type: classifyMediaUrl(url) });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (
|
|
284
|
+
(value.entryType === 'TimelineTimelineCursor' || value.__typename === 'TimelineTimelineCursor')
|
|
285
|
+
&& (value.cursorType === 'Bottom' || value.cursorType === 'ShowMore')
|
|
286
|
+
&& value.value
|
|
287
|
+
) {
|
|
288
|
+
nextCursor = value.value;
|
|
289
|
+
}
|
|
290
|
+
if (Array.isArray(value)) {
|
|
291
|
+
for (const item of value) visit(item);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
for (const child of Object.values(value)) {
|
|
295
|
+
if (child && typeof child === 'object') visit(child);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
visit(instructions);
|
|
299
|
+
return { items, nextCursor };
|
|
300
|
+
}
|
|
301
|
+
|
|
11
302
|
cli({
|
|
12
303
|
site: 'twitter',
|
|
13
304
|
name: 'download',
|
|
14
305
|
access: 'read',
|
|
15
|
-
description: 'Download Twitter/X media (images and videos). Provide either <username> to
|
|
306
|
+
description: 'Download Twitter/X media (images and videos). Provide either <username> to fetch every media item from their profile via the GraphQL UserMedia endpoint with cursor pagination, or --tweet-url to download a single tweet.',
|
|
16
307
|
domain: 'x.com',
|
|
17
308
|
strategy: Strategy.COOKIE,
|
|
309
|
+
browser: true,
|
|
18
310
|
args: [
|
|
19
|
-
{ name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their
|
|
311
|
+
{ name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their profile media. Either <username> or --tweet-url is required.' },
|
|
20
312
|
{ name: 'tweet-url', help: 'Single tweet URL to download. Use this OR <username>, not both required at once.' },
|
|
21
313
|
{ name: 'limit', type: 'int', default: 10, help: 'Maximum number of media items to download when scanning a profile (default 10). Ignored when --tweet-url is used.' },
|
|
22
314
|
{ name: 'output', default: './twitter-downloads', help: 'Output directory (default ./twitter-downloads). A per-source subdir is created inside.' },
|
|
23
315
|
],
|
|
24
|
-
columns: ['index', 'type', 'status', 'size'],
|
|
316
|
+
columns: ['index', 'tweet_id', 'url', 'type', 'status', 'size'],
|
|
25
317
|
func: async (page, kwargs) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
318
|
+
try {
|
|
319
|
+
const rawUsername = String(kwargs.username ?? '').trim();
|
|
320
|
+
const tweetUrl = String(kwargs['tweet-url'] ?? '').trim();
|
|
321
|
+
const output = kwargs.output;
|
|
322
|
+
if (!rawUsername && !tweetUrl) {
|
|
323
|
+
throw new ArgumentError('twitter download requires either <username> or --tweet-url');
|
|
324
|
+
}
|
|
325
|
+
if (rawUsername && tweetUrl) {
|
|
326
|
+
throw new ArgumentError('Use either <username> or --tweet-url, not both');
|
|
327
|
+
}
|
|
328
|
+
if (tweetUrl) {
|
|
329
|
+
return downloadSingleTweet(page, tweetUrl, output);
|
|
330
|
+
}
|
|
331
|
+
const limit = requireLimit(kwargs.limit);
|
|
332
|
+
const username = normalizeTwitterScreenName(rawUsername);
|
|
333
|
+
if (!username) {
|
|
334
|
+
throw new ArgumentError('twitter download username must be a valid Twitter/X handle', 'Example: opencli twitter download @jack --limit 20');
|
|
335
|
+
}
|
|
336
|
+
return downloadUserMedia(page, username, limit, output);
|
|
37
337
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
338
|
+
catch (err) {
|
|
339
|
+
if (err instanceof CliError) throw err;
|
|
340
|
+
throw new CommandExecutionError(`twitter download failed: ${err?.message ?? String(err)}`);
|
|
41
341
|
}
|
|
42
|
-
|
|
43
|
-
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
async function downloadUserMedia(page, username, limit, output) {
|
|
346
|
+
await page.goto(`https://x.com/${username}`);
|
|
347
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
348
|
+
|
|
349
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
350
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
351
|
+
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
352
|
+
|
|
353
|
+
const userMediaOperation = await resolveTwitterOperationMetadata(page, 'UserMedia', USER_MEDIA_OPERATION);
|
|
354
|
+
const userByScreenNameOperation = await resolveTwitterOperationMetadata(page, 'UserByScreenName', USER_BY_SCREEN_NAME_OPERATION);
|
|
355
|
+
|
|
356
|
+
const headers = JSON.stringify({
|
|
357
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
358
|
+
'X-Csrf-Token': ct0,
|
|
359
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
360
|
+
'X-Twitter-Active-User': 'yes',
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const ubsUrl = buildUserByScreenNameUrl(userByScreenNameOperation, username);
|
|
364
|
+
const userLookup = requireFetchPayload(await page.evaluate(`async () => {
|
|
365
|
+
try {
|
|
366
|
+
const resp = await fetch("${ubsUrl}", { headers: ${headers}, credentials: 'include' });
|
|
367
|
+
if (!resp.ok) return { ok: false, status: resp.status };
|
|
368
|
+
const payload = await resp.json();
|
|
369
|
+
return { ok: true, payload };
|
|
370
|
+
} catch (err) {
|
|
371
|
+
return { ok: false, error: err?.message ?? String(err) };
|
|
372
|
+
}
|
|
373
|
+
}`));
|
|
374
|
+
const normalizedUserLookup = normalizeTwitterGraphqlPayload(userLookup);
|
|
375
|
+
if (Array.isArray(normalizedUserLookup?.errors) && normalizedUserLookup.errors.length > 0) {
|
|
376
|
+
throw new CommandExecutionError(`Twitter UserByScreenName returned GraphQL errors: ${JSON.stringify(normalizedUserLookup.errors).slice(0, 200)}`);
|
|
377
|
+
}
|
|
378
|
+
const userId = normalizedUserLookup?.data?.user?.result?.rest_id;
|
|
379
|
+
if (!userId) throw new EmptyResultError(`twitter download @${username}`, `Could not resolve @${username}`);
|
|
380
|
+
|
|
381
|
+
const seen = new Set();
|
|
382
|
+
const all = [];
|
|
383
|
+
let cursor = null;
|
|
384
|
+
let hasMorePages = false;
|
|
385
|
+
for (let i = 0; i < MAX_PAGINATION_PAGES && all.length < limit; i++) {
|
|
386
|
+
const fetchCount = nextUserMediaFetchCount(limit, all.length);
|
|
387
|
+
if (fetchCount === 0) break;
|
|
388
|
+
const url = buildUserMediaUrl(userMediaOperation, userId, fetchCount, cursor);
|
|
389
|
+
const data = normalizeTwitterGraphqlPayload(requireFetchPayload(await page.evaluate(`async () => {
|
|
390
|
+
try {
|
|
391
|
+
const r = await fetch("${url}", { headers: ${headers}, credentials: 'include' });
|
|
392
|
+
if (!r.ok) return { ok: false, status: r.status };
|
|
393
|
+
return { ok: true, payload: await r.json() };
|
|
394
|
+
} catch (err) {
|
|
395
|
+
return { ok: false, error: err?.message ?? String(err) };
|
|
44
396
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
397
|
+
}`)));
|
|
398
|
+
const { items, nextCursor } = parseUserMedia(data, seen);
|
|
399
|
+
all.push(...items);
|
|
400
|
+
hasMorePages = Boolean(nextCursor);
|
|
401
|
+
if (!nextCursor) break;
|
|
402
|
+
if (nextCursor === cursor) {
|
|
403
|
+
throw new CommandExecutionError('Twitter UserMedia pagination returned the same cursor twice');
|
|
49
404
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
(() => {
|
|
53
|
-
const media = [];
|
|
405
|
+
cursor = nextCursor;
|
|
406
|
+
}
|
|
54
407
|
|
|
55
|
-
|
|
408
|
+
if (all.length === 0) throw new EmptyResultError(`@${username} has no media`, 'Account may be private, suspended, or have no media posts');
|
|
409
|
+
if (all.length < limit && hasMorePages) {
|
|
410
|
+
throw new CommandExecutionError(`Twitter UserMedia pagination reached the ${MAX_PAGINATION_PAGES}-page safety cap before collecting ${limit} media items`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const trimmed = all.slice(0, limit);
|
|
414
|
+
return downloadTwitterMedia(trimmed, {
|
|
415
|
+
output,
|
|
416
|
+
subdir: username,
|
|
417
|
+
cookies: formatCookieHeader(cookies),
|
|
418
|
+
browserCookies: cookies,
|
|
419
|
+
filenamePrefix: username,
|
|
420
|
+
ytdlpExtraArgs: ['--merge-output-format', 'mp4'],
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function downloadSingleTweet(page, tweetUrl, output) {
|
|
425
|
+
const target = parseTweetUrl(tweetUrl);
|
|
426
|
+
await page.goto(target.url);
|
|
427
|
+
await page.wait(3);
|
|
428
|
+
const items = unwrapBrowserResult(await page.evaluate(`
|
|
429
|
+
(() => {
|
|
430
|
+
const out = [];
|
|
56
431
|
document.querySelectorAll('img[src*="pbs.twimg.com/media"]').forEach(img => {
|
|
57
432
|
let src = img.src || '';
|
|
58
|
-
// Get large version
|
|
59
433
|
src = src.replace(/&name=\\w+$/, '&name=large');
|
|
60
|
-
src = src
|
|
61
|
-
|
|
62
|
-
src = src + '&name=large';
|
|
63
|
-
}
|
|
64
|
-
media.push({ type: 'image', url: src });
|
|
434
|
+
if (!src.includes('&name=')) src = src + '&name=large';
|
|
435
|
+
out.push({ type: 'image', url: src });
|
|
65
436
|
});
|
|
66
|
-
|
|
67
|
-
// Find videos
|
|
68
437
|
document.querySelectorAll('video').forEach(video => {
|
|
69
438
|
const src = video.src || '';
|
|
70
|
-
if (src) {
|
|
71
|
-
media.push({ type: 'video', url: src, poster: video.poster || '' });
|
|
72
|
-
}
|
|
439
|
+
if (src) out.push({ type: 'video', url: src });
|
|
73
440
|
});
|
|
74
|
-
|
|
75
|
-
// Find video tweets (for yt-dlp)
|
|
76
441
|
document.querySelectorAll('[data-testid="videoPlayer"]').forEach(player => {
|
|
77
442
|
const tweetLink = player.closest('article')?.querySelector('a[href*="/status/"]');
|
|
78
443
|
const href = tweetLink?.getAttribute('href') || '';
|
|
79
|
-
if (href) {
|
|
80
|
-
const tweetUrl = 'https://x.com' + href;
|
|
81
|
-
media.push({ type: 'video-tweet', url: tweetUrl });
|
|
82
|
-
}
|
|
444
|
+
if (href) out.push({ type: 'video-tweet', url: 'https://x.com' + href });
|
|
83
445
|
});
|
|
84
|
-
|
|
85
|
-
return media;
|
|
446
|
+
return out;
|
|
86
447
|
})()
|
|
87
|
-
`);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
}
|
|
448
|
+
`));
|
|
449
|
+
if (!Array.isArray(items)) {
|
|
450
|
+
throw new CommandExecutionError('Twitter tweet media extraction returned malformed payload');
|
|
451
|
+
}
|
|
452
|
+
if (items.length === 0) {
|
|
453
|
+
throw new EmptyResultError(`twitter download ${target.id}`, 'No media found in the tweet');
|
|
454
|
+
}
|
|
455
|
+
const cookies = await page.getCookies({ domain: 'x.com' });
|
|
456
|
+
const seen = new Set();
|
|
457
|
+
const unique = items.filter((m) => {
|
|
458
|
+
if (seen.has(m.url)) return false;
|
|
459
|
+
seen.add(m.url);
|
|
460
|
+
return true;
|
|
461
|
+
}).map((m) => {
|
|
462
|
+
return { ...m, tweet_id: target.id };
|
|
463
|
+
});
|
|
464
|
+
return downloadTwitterMedia(unique, {
|
|
465
|
+
output,
|
|
466
|
+
subdir: 'tweets',
|
|
467
|
+
cookies: formatCookieHeader(cookies),
|
|
468
|
+
browserCookies: cookies,
|
|
469
|
+
filenamePrefix: 'tweet',
|
|
470
|
+
ytdlpExtraArgs: ['--merge-output-format', 'mp4'],
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export const __test__ = {
|
|
475
|
+
buildUserMediaUrl,
|
|
476
|
+
buildUserByScreenNameUrl,
|
|
477
|
+
parseUserMedia,
|
|
478
|
+
classifyMediaUrl,
|
|
479
|
+
requireLimit,
|
|
480
|
+
nextUserMediaFetchCount,
|
|
481
|
+
};
|