Files
jackwener__opencli/clis/twitter/list-tweets.js

187 lines
8.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
const OPERATION_NAME = 'ListLatestTweetsTimeline';
const FEATURES = {
rweb_video_screen_enabled: false,
profile_label_improvements_pcf_label_in_post_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,
responsive_web_jetfuel_frame: false,
responsive_web_grok_share_attachment_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,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: false,
creator_subscriptions_quote_tweet_preview_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_enhance_cards_enabled: false,
};
function buildUrl(queryId, listId, count, cursor) {
const vars = { listId: String(listId), count };
if (cursor)
vars.cursor = cursor;
return `/i/api/graphql/${queryId}/${OPERATION_NAME}`
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
}
export function extractTimelineTweet(result, seen) {
if (!result)
return null;
const tw = 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;
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,
created_at: legacy.created_at || '',
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
};
}
export function parseListTimeline(data, seen) {
const tweets = [];
let nextCursor = null;
const instructions = data?.data?.list?.tweets_timeline?.timeline?.instructions || [];
for (const inst of instructions) {
for (const entry of inst.entries || []) {
const content = entry.content;
if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') {
if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore')
nextCursor = content.value;
continue;
}
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
nextCursor = content?.value || content?.itemContent?.value || nextCursor;
continue;
}
const direct = extractTimelineTweet(content?.itemContent?.tweet_results?.result, seen);
if (direct) {
tweets.push(direct);
continue;
}
for (const item of content?.items || []) {
const nested = extractTimelineTweet(item.item?.itemContent?.tweet_results?.result, seen);
if (nested)
tweets.push(nested);
}
}
}
return { tweets, nextCursor };
}
cli({
site: 'twitter',
name: 'list-tweets',
access: 'read',
description: 'Fetch tweets from a Twitter/X list timeline',
domain: 'x.com',
strategy: Strategy.COOKIE,
browser: true,
siteSession: 'persistent',
args: [
{ name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of a Twitter/X list (e.g. from `opencli twitter lists`)' },
{ name: 'limit', type: 'int', default: 50 },
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the list timeline 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 list\'s native (recency) ordering.' },
],
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url'],
func: async (page, kwargs) => {
const listId = String(kwargs.listId || '').trim();
if (!listId || !/^\d+$/.test(listId)) {
throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected a numeric ID (see \`opencli twitter lists\`).`);
}
const limit = kwargs.limit || 50;
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 queryId = await page.evaluate(`async () => {
try {
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
if (ghResp.ok) {
const data = await ghResp.json();
const entry = data['${OPERATION_NAME}'];
if (entry && entry.queryId) return entry.queryId;
}
} catch {}
try {
const scripts = performance.getEntriesByType('resource')
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
.map(r => r.name);
for (const scriptUrl of scripts.slice(0, 15)) {
try {
const text = await (await fetch(scriptUrl)).text();
const re = /queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"${OPERATION_NAME}"/;
const m = text.match(re);
if (m) return m[1];
} catch {}
}
} catch {}
return null;
}`) || LIST_TWEETS_QUERY_ID;
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 allTweets = [];
const seen = new Set();
let cursor = null;
for (let i = 0; i < 10 && allTweets.length < limit; i++) {
const fetchCount = Math.min(100, limit - allTweets.length + 10);
const apiUrl = buildUrl(queryId, listId, fetchCount, cursor);
const data = await page.evaluate(`async () => {
const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' });
return r.ok ? await r.json() : { error: r.status };
}`);
if (data?.error) {
if (allTweets.length === 0)
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch list timeline. queryId may have expired or list may be private.`);
break;
}
const { tweets, nextCursor } = parseListTimeline(data, seen);
allTweets.push(...tweets);
if (!nextCursor || nextCursor === cursor || tweets.length === 0)
break;
cursor = nextCursor;
}
const trimmed = allTweets.slice(0, limit);
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
},
});