9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
348 lines
16 KiB
JavaScript
348 lines
16 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||
import { resolveTwitterOperationMetadata, sanitizeQueryId, extractMedia, extractQuotedTweet, normalizeTwitterGraphqlPayload, unwrapBrowserResult, normalizeTwitterScreenName, describeTwitterApiError } from './shared.js';
|
||
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
||
|
||
const USER_TWEETS_QUERY_ID = 'lrMzG9qPQHpqJdP3AbM-bQ';
|
||
const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
|
||
const MAX_PAGINATION_PAGES = 100;
|
||
const USER_TWEETS_PAGE_SIZE = 100;
|
||
const MAX_TWEETS_LIMIT = MAX_PAGINATION_PAGES * USER_TWEETS_PAGE_SIZE;
|
||
const DEFAULT_PAGE_DELAY_SECONDS = 2;
|
||
|
||
const USER_TWEETS_FEATURES = {
|
||
rweb_video_screen_enabled: true,
|
||
rweb_cashtags_enabled: true,
|
||
payments_enabled: false,
|
||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||
responsive_web_profile_redirect_enabled: true,
|
||
rweb_tipjar_consumption_enabled: true,
|
||
verified_phone_label_enabled: false,
|
||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||
premium_content_api_read_enabled: false,
|
||
communities_web_enable_tweet_community_results_fetch: true,
|
||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||
responsive_web_grok_analyze_post_followups_enabled: true,
|
||
rweb_cashtags_composer_attachment_enabled: true,
|
||
responsive_web_jetfuel_frame: true,
|
||
responsive_web_grok_share_attachment_enabled: true,
|
||
responsive_web_grok_annotations_enabled: true,
|
||
articles_preview_enabled: true,
|
||
responsive_web_edit_tweet_api_enabled: true,
|
||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||
view_counts_everywhere_api_enabled: true,
|
||
longform_notetweets_consumption_enabled: true,
|
||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||
tweet_awards_web_tipping_enabled: false,
|
||
content_disclosure_indicator_enabled: true,
|
||
content_disclosure_ai_generated_indicator_enabled: true,
|
||
responsive_web_grok_show_grok_translated_post: false,
|
||
responsive_web_grok_analysis_button_from_backend: true,
|
||
post_ctas_fetch_enabled: false,
|
||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||
standardized_nudges_misinfo: true,
|
||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||
longform_notetweets_rich_text_read_enabled: true,
|
||
longform_notetweets_inline_media_enabled: true,
|
||
responsive_web_grok_image_annotation_enabled: true,
|
||
responsive_web_grok_imagine_annotation_enabled: true,
|
||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||
responsive_web_enhance_cards_enabled: false,
|
||
};
|
||
|
||
const USER_TWEETS_FIELD_TOGGLES = {
|
||
withPayments: true,
|
||
withAuxiliaryUserLabels: true,
|
||
withArticleRichContentState: true,
|
||
withArticlePlainText: true,
|
||
withArticleSummaryText: true,
|
||
withArticleVoiceOver: true,
|
||
withGrokAnalyze: true,
|
||
withDisallowedReplyControls: true,
|
||
};
|
||
|
||
const USER_BY_SCREEN_NAME_FEATURES = {
|
||
hidden_profile_subscriptions_enabled: true,
|
||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||
responsive_web_profile_redirect_enabled: true,
|
||
rweb_tipjar_consumption_enabled: true,
|
||
responsive_web_graphql_exclude_directive_enabled: true,
|
||
verified_phone_label_enabled: false,
|
||
subscriptions_verification_info_is_identity_verified_enabled: true,
|
||
subscriptions_verification_info_verified_since_enabled: true,
|
||
highlights_tweets_tab_ui_enabled: true,
|
||
responsive_web_twitter_article_notes_tab_enabled: true,
|
||
subscriptions_feature_can_gift_premium: true,
|
||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||
};
|
||
|
||
const USER_BY_SCREEN_NAME_FIELD_TOGGLES = {
|
||
withPayments: true,
|
||
withAuxiliaryUserLabels: true,
|
||
};
|
||
|
||
const USER_TWEETS_OPERATION = {
|
||
queryId: USER_TWEETS_QUERY_ID,
|
||
features: USER_TWEETS_FEATURES,
|
||
fieldToggles: USER_TWEETS_FIELD_TOGGLES,
|
||
};
|
||
|
||
const USER_BY_SCREEN_NAME_OPERATION = {
|
||
queryId: USER_BY_SCREEN_NAME_QUERY_ID,
|
||
features: USER_BY_SCREEN_NAME_FEATURES,
|
||
fieldToggles: USER_BY_SCREEN_NAME_FIELD_TOGGLES,
|
||
};
|
||
|
||
function normalizeUserTweetsOperation(operation) {
|
||
if (typeof operation === 'string') {
|
||
return { queryId: operation, features: USER_TWEETS_FEATURES, fieldToggles: USER_TWEETS_FIELD_TOGGLES };
|
||
}
|
||
return {
|
||
queryId: operation?.queryId || USER_TWEETS_QUERY_ID,
|
||
features: operation?.features || USER_TWEETS_FEATURES,
|
||
fieldToggles: operation?.fieldToggles || USER_TWEETS_FIELD_TOGGLES,
|
||
};
|
||
}
|
||
|
||
function normalizeUserByScreenNameOperation(operation) {
|
||
if (typeof operation === 'string') {
|
||
return { queryId: operation, features: USER_BY_SCREEN_NAME_FEATURES, fieldToggles: USER_BY_SCREEN_NAME_FIELD_TOGGLES };
|
||
}
|
||
return {
|
||
queryId: operation?.queryId || USER_BY_SCREEN_NAME_QUERY_ID,
|
||
features: operation?.features || USER_BY_SCREEN_NAME_FEATURES,
|
||
fieldToggles: operation?.fieldToggles || USER_BY_SCREEN_NAME_FIELD_TOGGLES,
|
||
};
|
||
}
|
||
|
||
function appendGraphqlParams(path, variables, operation) {
|
||
const fieldToggles = operation.fieldToggles || {};
|
||
const params = [
|
||
`variables=${encodeURIComponent(JSON.stringify(variables))}`,
|
||
`features=${encodeURIComponent(JSON.stringify(operation.features || {}))}`,
|
||
];
|
||
if (Object.keys(fieldToggles).length > 0) {
|
||
params.push(`fieldToggles=${encodeURIComponent(JSON.stringify(fieldToggles))}`);
|
||
}
|
||
return `${path}?${params.join('&')}`;
|
||
}
|
||
|
||
function buildUserTweetsUrl(operation, userId, count, cursor) {
|
||
const normalized = normalizeUserTweetsOperation(operation);
|
||
const vars = {
|
||
userId,
|
||
count,
|
||
includePromotedContent: false,
|
||
withQuickPromoteEligibilityTweetFields: true,
|
||
withVoice: true,
|
||
};
|
||
if (cursor) vars.cursor = cursor;
|
||
return appendGraphqlParams(`/i/api/graphql/${normalized.queryId}/UserTweets`, vars, normalized);
|
||
}
|
||
|
||
function buildUserByScreenNameUrl(operation, screenName) {
|
||
const normalized = normalizeUserByScreenNameOperation(operation);
|
||
const vars = { screen_name: screenName, withSafetyModeUserFields: true };
|
||
return appendGraphqlParams(`/i/api/graphql/${normalized.queryId}/UserByScreenName`, vars, normalized);
|
||
}
|
||
|
||
function extractTweet(result, seen) {
|
||
if (!result) return null;
|
||
const tw = result.__typename === 'TweetWithVisibilityResults' && result.tweet
|
||
? result.tweet
|
||
: (result.tweet || result);
|
||
const legacy = tw.legacy || {};
|
||
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
|
||
seen.add(tw.rest_id);
|
||
const user = tw.core?.user_results?.result;
|
||
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
|
||
const displayName = user?.legacy?.name || user?.core?.name || '';
|
||
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
||
const isRetweet = Boolean(legacy.retweeted_status_result || legacy.full_text?.startsWith('RT @'));
|
||
return {
|
||
id: tw.rest_id,
|
||
author: screenName,
|
||
name: displayName,
|
||
text: noteText || legacy.full_text || '',
|
||
likes: legacy.favorite_count || 0,
|
||
retweets: legacy.retweet_count || 0,
|
||
replies: legacy.reply_count || 0,
|
||
views: Number(tw.views?.count) || 0,
|
||
is_retweet: isRetweet,
|
||
created_at: legacy.created_at || '',
|
||
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
||
...extractMedia(legacy),
|
||
quoted_tweet: extractQuotedTweet(tw),
|
||
};
|
||
}
|
||
|
||
function parseUserTweets(data, seen) {
|
||
const tweets = [];
|
||
let nextCursor = null;
|
||
const result = data?.data?.user?.result || {};
|
||
const instructionSets = [
|
||
result.timeline_v2?.timeline?.instructions,
|
||
result.timeline?.timeline?.instructions,
|
||
].filter(Array.isArray);
|
||
const instructions = instructionSets.flat();
|
||
const visit = (value) => {
|
||
if (!value || typeof value !== 'object') return;
|
||
if (value.type === 'TimelinePinEntry') return;
|
||
if (value.tweet_results?.result) {
|
||
const tweet = extractTweet(value.tweet_results.result, seen);
|
||
if (tweet) tweets.push(tweet);
|
||
}
|
||
if (
|
||
(value.entryType === 'TimelineTimelineCursor' || value.__typename === 'TimelineTimelineCursor')
|
||
&& (value.cursorType === 'Bottom' || value.cursorType === 'ShowMore')
|
||
&& value.value
|
||
) {
|
||
nextCursor = value.value;
|
||
}
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) visit(item);
|
||
return;
|
||
}
|
||
for (const child of Object.values(value)) {
|
||
if (child && typeof child === 'object') visit(child);
|
||
}
|
||
};
|
||
visit(instructions);
|
||
return { tweets, nextCursor };
|
||
}
|
||
|
||
function normalizeLimit(rawLimit) {
|
||
const limit = rawLimit ?? 20;
|
||
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_TWEETS_LIMIT) {
|
||
throw new ArgumentError(
|
||
`twitter tweets --limit must be an integer between 1 and ${MAX_TWEETS_LIMIT}`,
|
||
`Example: opencli twitter tweets @jack --limit 250`
|
||
);
|
||
}
|
||
return limit;
|
||
}
|
||
|
||
function normalizePageDelaySeconds(rawDelay) {
|
||
const delay = rawDelay ?? DEFAULT_PAGE_DELAY_SECONDS;
|
||
if (!Number.isInteger(delay) || delay < 0 || delay > 60) {
|
||
throw new ArgumentError(
|
||
'twitter tweets --page-delay must be an integer between 0 and 60 seconds',
|
||
'Example: opencli twitter tweets @jack --limit 250 --page-delay 2'
|
||
);
|
||
}
|
||
return delay;
|
||
}
|
||
|
||
cli({
|
||
site: 'twitter',
|
||
name: 'tweets',
|
||
access: 'read',
|
||
description: "Fetch a Twitter user's most recent tweets (chronological, excludes pinned; defaults to the logged-in user when no username is given)",
|
||
domain: 'x.com',
|
||
strategy: Strategy.COOKIE,
|
||
browser: true,
|
||
args: [
|
||
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
|
||
{ name: 'limit', type: 'int', default: 20, help: `Max tweets to return (1-${MAX_TWEETS_LIMIT}; fetched across cursor pages)` },
|
||
{ name: 'page-delay', type: 'int', default: DEFAULT_PAGE_DELAY_SECONDS, help: 'Seconds to wait between paginated timeline requests to reduce rate-limit risk. Use 0 to disable.' },
|
||
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the tweets by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the chronological ordering.' },
|
||
],
|
||
columns: ['id', 'author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls', 'media_posters', 'quoted_tweet'],
|
||
func: async (page, kwargs) => {
|
||
const limit = normalizeLimit(kwargs.limit);
|
||
const pageDelaySeconds = normalizePageDelaySeconds(kwargs['page-delay']);
|
||
const rawUsername = String(kwargs.username ?? '').trim();
|
||
let username = normalizeTwitterScreenName(rawUsername);
|
||
if (rawUsername && !username) {
|
||
throw new ArgumentError('twitter tweets username must be a valid Twitter/X handle', 'Example: opencli twitter tweets @jack --limit 20');
|
||
}
|
||
// When no username is given, detect the logged-in user (own tweets).
|
||
// Mirrors the self-detection pattern used by twitter/profile and
|
||
// twitter/likes so agents can pull own-account data without having
|
||
// to know their own screen name up front.
|
||
if (!username) {
|
||
await page.goto('https://x.com/home');
|
||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||
// Bridge wraps primitive page.evaluate returns as { session, data:<value> }.
|
||
// unwrapBrowserResult drops that envelope so the href string is usable.
|
||
const href = unwrapBrowserResult(await page.evaluate(`() => {
|
||
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
||
return link ? link.getAttribute('href') : null;
|
||
}`));
|
||
if (!href || typeof href !== 'string')
|
||
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
|
||
username = normalizeTwitterScreenName(href);
|
||
if (!username) {
|
||
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
|
||
}
|
||
}
|
||
|
||
const cookies = await page.getCookies({ url: 'https://x.com' });
|
||
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
||
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
||
|
||
const userTweetsOperation = await resolveTwitterOperationMetadata(page, 'UserTweets', USER_TWEETS_OPERATION);
|
||
const userByScreenNameOperation = await resolveTwitterOperationMetadata(page, 'UserByScreenName', USER_BY_SCREEN_NAME_OPERATION);
|
||
|
||
const headers = JSON.stringify({
|
||
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
||
'X-Csrf-Token': ct0,
|
||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||
'X-Twitter-Active-User': 'yes',
|
||
});
|
||
|
||
const ubsUrl = buildUserByScreenNameUrl(userByScreenNameOperation, username);
|
||
const userId = unwrapBrowserResult(await page.evaluate(`async () => {
|
||
const resp = await fetch("${ubsUrl}", { headers: ${headers}, credentials: 'include' });
|
||
if (!resp.ok) return null;
|
||
const d = await resp.json();
|
||
return d?.data?.user?.result?.rest_id || null;
|
||
}`));
|
||
if (!userId) throw new CommandExecutionError(`Could not resolve @${username}`);
|
||
|
||
const seen = new Set();
|
||
const all = [];
|
||
let cursor = null;
|
||
// Runaway guard only; --limit and cursor exhaustion control normal pagination.
|
||
for (let i = 0; i < MAX_PAGINATION_PAGES && all.length < limit; i++) {
|
||
if (i > 0 && pageDelaySeconds > 0) {
|
||
await page.wait(pageDelaySeconds);
|
||
}
|
||
const fetchCount = Math.min(USER_TWEETS_PAGE_SIZE, limit - all.length + 10);
|
||
const url = buildUserTweetsUrl(userTweetsOperation, userId, fetchCount, cursor);
|
||
const data = normalizeTwitterGraphqlPayload(await page.evaluate(`async () => {
|
||
const r = await fetch("${url}", { headers: ${headers}, credentials: 'include' });
|
||
return r.ok ? await r.json() : { error: r.status };
|
||
}`));
|
||
if (data?.error) {
|
||
if (all.length === 0) throw new CommandExecutionError(describeTwitterApiError('UserTweets', data.error));
|
||
break;
|
||
}
|
||
const { tweets, nextCursor } = parseUserTweets(data, seen);
|
||
all.push(...tweets);
|
||
if (!nextCursor || nextCursor === cursor) break;
|
||
cursor = nextCursor;
|
||
}
|
||
|
||
if (all.length === 0) throw new EmptyResultError(`@${username} has no recent tweets`, 'Account may be private or suspended');
|
||
const trimmed = all.slice(0, limit);
|
||
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
||
},
|
||
});
|
||
|
||
export const __test__ = {
|
||
MAX_TWEETS_LIMIT,
|
||
sanitizeQueryId,
|
||
buildUserTweetsUrl,
|
||
buildUserByScreenNameUrl,
|
||
extractTweet,
|
||
parseUserTweets,
|
||
normalizeLimit,
|
||
normalizePageDelaySeconds,
|
||
};
|