Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -40617,7 +40617,8 @@
"url",
"has_media",
"media_urls",
"media_posters"
"media_posters",
"media_durations"
],
"type": "js",
"modulePath": "twitter/bookmark-folder.js",
Expand Down Expand Up @@ -40704,7 +40705,8 @@
"url",
"has_media",
"media_urls",
"media_posters"
"media_posters",
"media_durations"
],
"type": "js",
"modulePath": "twitter/bookmarks.js",
Expand Down Expand Up @@ -41121,7 +41123,8 @@
"url",
"has_media",
"media_urls",
"media_posters"
"media_posters",
"media_durations"
],
"type": "js",
"modulePath": "twitter/likes.js",
Expand Down Expand Up @@ -41420,16 +41423,19 @@
"columns": [
"id",
"author",
"author_avatar",
"bio",
"text",
"likes",
"retweets",
"replies",
"views",
"created_at",
"url",
"has_media",
"media_urls",
"media_posters",
"media_durations",
"card",
"quoted_tweet"
],
Expand Down Expand Up @@ -41880,6 +41886,7 @@
"columns": [
"id",
"author",
"author_avatar",
"bio",
"text",
"created_at",
Expand All @@ -41889,6 +41896,7 @@
"has_media",
"media_urls",
"media_posters",
"media_durations",
"card",
"quoted_tweet"
],
Expand Down Expand Up @@ -41931,14 +41939,17 @@
"columns": [
"id",
"author",
"author_avatar",
"bio",
"text",
"likes",
"retweets",
"views",
"url",
"has_media",
"media_urls",
"media_posters",
"media_durations",
"card",
"quoted_tweet"
],
Expand Down Expand Up @@ -41996,6 +42007,7 @@
"has_media",
"media_urls",
"media_posters",
"media_durations",
"card",
"quoted_tweet"
],
Expand Down Expand Up @@ -42083,6 +42095,7 @@
"has_media",
"media_urls",
"media_posters",
"media_durations",
"quoted_tweet"
],
"type": "js",
Expand Down
2 changes: 1 addition & 1 deletion clis/twitter/bookmark-folder.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ cli({
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the folder 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 API\'s native (saved-time) ordering.' },
],
columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters'],
columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters', 'media_durations'],
func: async (page, kwargs) => {
const folderId = String(kwargs['folder-id'] || '').trim();
if (!folderId || !FOLDER_ID_PATTERN.test(folderId)) {
Expand Down
1 change: 1 addition & 0 deletions clis/twitter/bookmark-folder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ describe('twitter bookmark-folder timeline parser', () => {
has_media: false,
media_urls: [],
media_posters: [],
media_durations: [],
},
]);
expect(nextCursor).toBe('NEXT_CURSOR');
Expand Down
2 changes: 1 addition & 1 deletion clis/twitter/bookmarks.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ cli({
{ name: 'max-pages', type: 'int', help: `Optional pagination safety cap (default ${DEFAULT_MAX_PAGINATION_PAGES}; raised automatically with --all).` },
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the bookmarks 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 API\'s native (saved-time) ordering. Incompatible with --output-file.' },
],
columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters'],
columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters', 'media_durations'],
func: async (page, kwargs) => {
const fetchAll = Boolean(kwargs.all);
const limit = fetchAll ? Number.POSITIVE_INFINITY : (kwargs.limit || 20);
Expand Down
1 change: 1 addition & 0 deletions clis/twitter/bookmarks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('twitter bookmarks parser', () => {
has_media: false,
media_urls: [],
media_posters: [],
media_durations: [],
});
});

Expand Down
2 changes: 1 addition & 1 deletion clis/twitter/likes.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ cli({
{ name: 'max-pages', type: 'int', help: `Optional pagination safety cap (default ${DEFAULT_MAX_PAGINATION_PAGES}; raised automatically with --all).` },
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the liked 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 API\'s native (recency) ordering. Incompatible with --output-file.' },
],
columns: ['id', 'author', 'name', 'text', 'likes', 'retweets', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters'],
columns: ['id', 'author', 'name', 'text', 'likes', 'retweets', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters', 'media_durations'],
func: async (page, kwargs) => {
const fetchAll = Boolean(kwargs.all);
const limit = fetchAll ? Number.POSITIVE_INFINITY : (kwargs.limit || 20);
Expand Down
8 changes: 6 additions & 2 deletions clis/twitter/list-tweets.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { BROWSER_JSON_SNIFF_FN, throwIfLoginWall } from '@jackwener/opencli/utils';
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
import { extractCard, extractQuotedTweet, extractMedia, describeTwitterApiError } from './shared.js';
import { extractAuthorAvatar, extractCard, extractQuotedTweet, extractMedia, describeTwitterApiError } from './shared.js';

const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
const OPERATION_NAME = 'ListLatestTweetsTimeline';
Expand Down Expand Up @@ -68,11 +68,15 @@ export function extractTimelineTweet(result, seen) {
id: tw.rest_id,
author: screenName,
name: displayName,
author_avatar: extractAuthorAvatar(user),
bio,
text: noteText || legacy.full_text || '',
likes: legacy.favorite_count || 0,
retweets: legacy.retweet_count || 0,
replies: legacy.reply_count || 0,
// Same shape `twitter search` emits: a numeric string, '0' when X omits it.
// Also feeds the log10(views) term of --top-by-engagement, which scored 0 before.
views: tw.views?.count || '0',
created_at: legacy.created_at || '',
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
...extractMedia(legacy),
Expand Down Expand Up @@ -125,7 +129,7 @@ cli({
{ 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', 'bio', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters', 'card', 'quoted_tweet'],
columns: ['id', 'author', 'author_avatar', 'bio', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters', 'media_durations', 'card', 'quoted_tweet'],
func: async (page, kwargs) => {
const listId = String(kwargs.listId || '').trim();
if (!listId || !/^\d+$/.test(listId)) {
Expand Down
24 changes: 24 additions & 0 deletions clis/twitter/list-tweets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,19 @@ describe('twitter list-tweets parser', () => {
id: '99',
author: 'bob',
name: 'Bob',
author_avatar: '',
bio: 'List author bio',
text: 'hello list',
likes: 3,
retweets: 1,
replies: 2,
views: '0',
created_at: 'Wed Apr 16 10:00:00 +0000 2026',
url: 'https://x.com/bob/status/99',
has_media: false,
media_urls: [],
media_posters: [],
media_durations: [],
card: null,
quoted_tweet: null,
});
Expand Down Expand Up @@ -74,9 +77,30 @@ describe('twitter list-tweets parser', () => {
has_media: true,
media_urls: ['https://pbs.twimg.com/media/x.jpg'],
media_posters: ['https://pbs.twimg.com/media/x.jpg'],
media_durations: [0],
});
});

it('exposes views and the author avatar when X returns them', () => {
const tweet = extractTimelineTweet({
rest_id: '77',
legacy: { full_text: 'metrics' },
views: { count: '12345' },
core: {
user_results: {
result: {
legacy: {
screen_name: 'carol',
profile_image_url_https: 'https://pbs.twimg.com/profile_images/1/c_normal.jpg',
},
},
},
},
}, new Set());
expect(tweet?.views).toBe('12345');
expect(tweet?.author_avatar).toBe('https://pbs.twimg.com/profile_images/1/c_400x400.jpg');
});

it('includes photo media URLs from extended_entities', () => {
const tweet = extractTimelineTweet({
rest_id: '101',
Expand Down
5 changes: 3 additions & 2 deletions clis/twitter/search.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { extractMedia, extractCard, extractQuotedTweet, normalizeTwitterGraphqlPayload, resolveTwitterOperationMetadata, describeTwitterApiError } from './shared.js';
import { extractAuthorAvatar, extractMedia, extractCard, extractQuotedTweet, normalizeTwitterGraphqlPayload, resolveTwitterOperationMetadata, describeTwitterApiError } from './shared.js';
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';

// ── Public-search operator surface ─────────────────────────────────────
Expand Down Expand Up @@ -216,6 +216,7 @@ function tweetToRow(result, seen) {
return {
id: tweet.rest_id,
author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '',
author_avatar: extractAuthorAvatar(tweetUser),
bio,
text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
created_at: tweet.legacy?.created_at || '',
Expand Down Expand Up @@ -275,7 +276,7 @@ cli({
{ name: 'limit', type: 'int', default: 15, help: 'Maximum number of tweets to return (default 15). Result count after server-side filtering.' },
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the results by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X\'s native ordering.' },
],
columns: ['id', 'author', 'bio', 'text', 'created_at', 'likes', 'views', 'url', 'has_media', 'media_urls', 'media_posters', 'card', 'quoted_tweet'],
columns: ['id', 'author', 'author_avatar', 'bio', 'text', 'created_at', 'likes', 'views', 'url', 'has_media', 'media_urls', 'media_posters', 'media_durations', 'card', 'quoted_tweet'],
func: async (page, kwargs) => {
const finalQuery = buildSearchQuery(kwargs.query, kwargs);
if (!finalQuery) {
Expand Down
2 changes: 2 additions & 0 deletions clis/twitter/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ describe('twitter search command', () => {
{
id: '1',
author: 'alice',
author_avatar: '',
bio: 'Search author bio',
text: 'hello world',
created_at: 'Thu Mar 26 10:30:00 +0000 2026',
Expand All @@ -90,6 +91,7 @@ describe('twitter search command', () => {
has_media: false,
media_urls: [],
media_posters: [],
media_durations: [],
card: null,
quoted_tweet: null,
},
Expand Down
38 changes: 34 additions & 4 deletions clis/twitter/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,32 +360,60 @@ export async function resolveTwitterQueryId(page, operationName, fallbackId) {
* for photos it equals the photo URL itself. Each entry is a non-null string —
* it falls back to the media URL if a thumbnail is somehow absent, preserving
* alignment.
*
* And `media_durations`, also index-aligned 1:1: `video_info.duration_millis`
* in milliseconds for videos / animated GIFs, `0` for photos (and for videos
* where X omits the duration). This is the only place the tweet's video length
* is available — the timeline row itself carries no duration.
*/
export function extractMedia(legacy) {
const media = legacy?.extended_entities?.media || legacy?.entities?.media;
if (!Array.isArray(media) || media.length === 0) {
return { has_media: false, media_urls: [], media_posters: [] };
return { has_media: false, media_urls: [], media_posters: [], media_durations: [] };
}
const urls = [];
const posters = [];
const durations = [];
for (const m of media) {
if (!m) continue;
if (m.type === 'video' || m.type === 'animated_gif') {
const variants = m.video_info?.variants || [];
const mp4 = variants.find((v) => v?.content_type === 'video/mp4');
const url = mp4?.url || m.media_url_https;
// X returns variants in ascending bitrate order, so picking the first mp4
// always yielded the lowest rendition (480x270). Take the highest bitrate.
let best = null;
for (const v of variants) {
if (v?.content_type !== 'video/mp4' || !v.url) continue;
const bitrate = Number(v.bitrate) || 0;
if (!best || bitrate > best.bitrate) best = { url: v.url, bitrate };
}
const url = best?.url || m.media_url_https;
if (url) {
urls.push(url);
posters.push(m.media_url_https || url);
durations.push(Number(m.video_info?.duration_millis) || 0);
}
} else {
if (m.media_url_https) {
urls.push(m.media_url_https);
posters.push(m.media_url_https);
durations.push(0);
}
}
}
return { has_media: urls.length > 0, media_urls: urls, media_posters: posters };
return { has_media: urls.length > 0, media_urls: urls, media_posters: posters, media_durations: durations };
}

/**
* Author avatar URL from a GraphQL `user_results.result` object.
*
* Reads `legacy.profile_image_url_https` (older schema) or `avatar.image_url`
* (newer schema, the one that also moved `screen_name` under `core`). X serves
* that URL at the 48x48 `_normal` size; swapping the suffix for `_400x400`
* returns the same image at a usable resolution. Returns '' when absent.
*/
export function extractAuthorAvatar(user) {
const raw = user?.legacy?.profile_image_url_https || user?.avatar?.image_url || '';
return String(raw).replace(/_normal(\.[A-Za-z0-9]+)?(\?.*)?$/, '_400x400$1$2');
}

/**
Expand Down Expand Up @@ -526,6 +554,7 @@ export function extractQuotedTweet(tweet) {
has_media: qMedia.has_media,
media_urls: qMedia.media_urls,
media_posters: qMedia.media_posters,
media_durations: qMedia.media_durations,
};
if (qCard) out.card = qCard;
return out;
Expand Down Expand Up @@ -577,6 +606,7 @@ export const __test__ = {
normalizeTwitterGraphqlPayload,
normalizeTwitterScreenName,
extractMedia,
extractAuthorAvatar,
extractCard,
extractQuotedTweet,
parseTweetUrl,
Expand Down
Loading