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
347 lines
15 KiB
JavaScript
347 lines
15 KiB
JavaScript
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 { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
||
|
||
// ── Public-search operator surface ─────────────────────────────────────
|
||
//
|
||
// X's web search supports a small set of inline operators (from:, filter:,
|
||
// -filter:, etc.) plus a tab-selector URL param `f=`. We expose the most
|
||
// useful subset as flags so callers don't have to memorise the operator
|
||
// strings, while still letting power users append raw operators in <query>.
|
||
|
||
/** Operands accepted by `--has`. Map 1:1 to Twitter's `filter:<x>` operator. */
|
||
const HAS_CHOICES = Object.freeze(['media', 'images', 'videos', 'links', 'replies']);
|
||
|
||
/**
|
||
* Operands accepted by `--exclude`. Note that `retweets` is exposed as the
|
||
* friendlier name but X's actual operator stays as `-filter:nativeretweets`
|
||
* (the historical "native" prefix is preserved by their backend).
|
||
*/
|
||
const EXCLUDE_CHOICES = Object.freeze(['replies', 'retweets', 'media', 'links']);
|
||
|
||
/**
|
||
* Operands accepted by `--product`. `photos`/`videos` are the human-friendly
|
||
* forms used by the X UI tabs; the URL param uses the singular forms (image,
|
||
* video). `people` is intentionally NOT supported here because that tab
|
||
* returns User objects, not tweets, and would need a different output schema.
|
||
*/
|
||
const PRODUCT_CHOICES = Object.freeze(['top', 'live', 'photos', 'videos']);
|
||
|
||
const PRODUCT_TO_F_PARAM = Object.freeze({
|
||
top: 'top',
|
||
live: 'live',
|
||
photos: 'image',
|
||
videos: 'video',
|
||
});
|
||
|
||
const PRODUCT_TO_GRAPHQL_PRODUCT = Object.freeze({
|
||
top: 'Top',
|
||
live: 'Latest',
|
||
photos: 'Photos',
|
||
videos: 'Videos',
|
||
});
|
||
const MAX_PAGINATION_PAGES = 100;
|
||
|
||
const SEARCH_TIMELINE_OPERATION = {
|
||
queryId: 'Yw6L66Pw54NHKuq4Dp7b4Q',
|
||
features: {
|
||
rweb_video_screen_enabled: true,
|
||
rweb_cashtags_enabled: true,
|
||
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,
|
||
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,
|
||
},
|
||
fieldToggles: {
|
||
withPayments: true,
|
||
withAuxiliaryUserLabels: true,
|
||
withArticleRichContentState: true,
|
||
withArticlePlainText: true,
|
||
withArticleSummaryText: true,
|
||
withArticleVoiceOver: true,
|
||
withGrokAnalyze: true,
|
||
withDisallowedReplyControls: true,
|
||
},
|
||
};
|
||
|
||
const FROM_USER_PATTERN = /^[A-Za-z0-9_]{1,15}$/;
|
||
|
||
const EXCLUDE_TO_OPERATOR = Object.freeze({
|
||
replies: '-filter:replies',
|
||
// `retweets` is a CLI-friendly alias for X's actual `-filter:nativeretweets`.
|
||
retweets: '-filter:nativeretweets',
|
||
media: '-filter:media',
|
||
links: '-filter:links',
|
||
});
|
||
|
||
/**
|
||
* Compose the final search query string by appending operator clauses for
|
||
* --from / --has / --exclude. Pure synchronous — exported via __test__ for
|
||
* unit coverage.
|
||
*
|
||
* Behaviour notes:
|
||
* - Trims leading `@` from --from so callers can pass `@alice` or `alice`.
|
||
* - Order is `<query> from:X filter:Y -filter:Z` (matches what X's own search
|
||
* bar emits when you click the suggestions UI).
|
||
* - Empty <query> with non-empty filters is allowed — the resulting string
|
||
* is just the operator clauses joined; X handles that fine.
|
||
*
|
||
* @param {string} rawQuery
|
||
* @param {{ from?: string, has?: string, exclude?: string }} kwargs
|
||
* @returns {string}
|
||
*/
|
||
function buildSearchQuery(rawQuery, kwargs) {
|
||
const parts = [String(rawQuery ?? '').trim()];
|
||
if (kwargs.from) {
|
||
const fromUser = String(kwargs.from).trim().replace(/^@+/, '');
|
||
if (fromUser && !FROM_USER_PATTERN.test(fromUser)) {
|
||
throw new ArgumentError(
|
||
`Invalid --from username: ${JSON.stringify(kwargs.from)}`,
|
||
'Use a Twitter/X handle with 1-15 letters, numbers, or underscores; omit @ or pass @handle.',
|
||
);
|
||
}
|
||
if (fromUser) parts.push(`from:${fromUser}`);
|
||
}
|
||
if (kwargs.has) {
|
||
parts.push(`filter:${kwargs.has}`);
|
||
}
|
||
if (kwargs.exclude) {
|
||
const op = EXCLUDE_TO_OPERATOR[kwargs.exclude];
|
||
if (op) parts.push(op);
|
||
}
|
||
return parts.filter(Boolean).join(' ');
|
||
}
|
||
|
||
/**
|
||
* Resolve which X search tab (`f=` URL param) to land on. `--product` wins
|
||
* over the legacy `--filter` so adding `--product` doesn't break callers that
|
||
* were already setting `--filter top|live`.
|
||
*
|
||
* @param {{ product?: string, filter?: string }} kwargs
|
||
* @returns {string} URL `f=` value: top|live|image|video
|
||
*/
|
||
function resolveSearchFParam(kwargs) {
|
||
if (kwargs.product) {
|
||
const mapped = PRODUCT_TO_F_PARAM[kwargs.product];
|
||
if (mapped) return mapped;
|
||
}
|
||
return kwargs.filter === 'live' ? 'live' : 'top';
|
||
}
|
||
|
||
function resolveSearchProduct(kwargs) {
|
||
const product = kwargs.product || (kwargs.filter === 'live' ? 'live' : 'top');
|
||
return PRODUCT_TO_GRAPHQL_PRODUCT[product] || 'Top';
|
||
}
|
||
|
||
function normalizeOperation(operation) {
|
||
if (typeof operation === 'string') {
|
||
return {
|
||
queryId: operation,
|
||
features: SEARCH_TIMELINE_OPERATION.features,
|
||
fieldToggles: SEARCH_TIMELINE_OPERATION.fieldToggles,
|
||
};
|
||
}
|
||
return {
|
||
queryId: operation?.queryId || SEARCH_TIMELINE_OPERATION.queryId,
|
||
features: operation?.features || SEARCH_TIMELINE_OPERATION.features,
|
||
fieldToggles: operation?.fieldToggles || SEARCH_TIMELINE_OPERATION.fieldToggles,
|
||
};
|
||
}
|
||
|
||
function buildSearchTimelineRequest(operation, rawQuery, product, count, cursor) {
|
||
const normalized = normalizeOperation(operation);
|
||
const vars = {
|
||
rawQuery,
|
||
count,
|
||
querySource: 'typed_query',
|
||
product,
|
||
};
|
||
if (cursor) vars.cursor = cursor;
|
||
return [
|
||
`/i/api/graphql/${normalized.queryId}/SearchTimeline`,
|
||
{
|
||
variables: vars,
|
||
features: normalized.features,
|
||
fieldToggles: normalized.fieldToggles,
|
||
},
|
||
];
|
||
}
|
||
|
||
function unwrapTweetResult(result) {
|
||
if (!result) return null;
|
||
if (result.__typename === 'TweetWithVisibilityResults' && result.tweet) return result.tweet;
|
||
if (result.tweet) return result.tweet;
|
||
return result;
|
||
}
|
||
|
||
function tweetToRow(result, seen) {
|
||
const tweet = unwrapTweetResult(result);
|
||
if (!tweet?.rest_id || seen.has(tweet.rest_id)) return null;
|
||
seen.add(tweet.rest_id);
|
||
const tweetUser = tweet.core?.user_results?.result;
|
||
const bio = tweetUser?.legacy?.description || '';
|
||
return {
|
||
id: tweet.rest_id,
|
||
author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || '',
|
||
bio,
|
||
text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
|
||
created_at: tweet.legacy?.created_at || '',
|
||
likes: tweet.legacy?.favorite_count || 0,
|
||
views: tweet.views?.count || '0',
|
||
url: `https://x.com/i/status/${tweet.rest_id}`,
|
||
...extractMedia(tweet.legacy),
|
||
card: extractCard(tweet),
|
||
quoted_tweet: extractQuotedTweet(tweet),
|
||
};
|
||
}
|
||
|
||
function parseSearchTimeline(data, seen) {
|
||
const rows = [];
|
||
let nextCursor = null;
|
||
const instructions = data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
|
||
const visit = (value) => {
|
||
if (!value || typeof value !== 'object') return;
|
||
if (value.tweet_results?.result) {
|
||
const row = tweetToRow(value.tweet_results.result, seen);
|
||
if (row) rows.push(row);
|
||
}
|
||
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 { rows, nextCursor };
|
||
}
|
||
|
||
cli({
|
||
site: 'twitter',
|
||
name: 'search',
|
||
access: 'read',
|
||
description: 'Search Twitter/X for tweets, with optional --from / --has / --exclude / --product filters mapped to X\'s search operators',
|
||
domain: 'x.com',
|
||
strategy: Strategy.COOKIE,
|
||
browser: true,
|
||
args: [
|
||
{ name: 'query', type: 'string', required: true, positional: true, help: 'Search query. Raw X operators (e.g. "exact phrase", #tag, OR, lang:en, since:YYYY-MM-DD, from:, since:) are passed through unchanged.' },
|
||
{ name: 'filter', type: 'string', default: 'top', choices: ['top', 'live'], help: 'Legacy alias for --product. Kept for backwards compatibility; if --product is set it wins.' },
|
||
{ name: 'product', type: 'string', choices: PRODUCT_CHOICES, help: 'Which X search tab to read: top (default), live (Latest), photos, videos. Maps to the f= URL param.' },
|
||
{ name: 'from', type: 'string', help: 'Restrict to tweets authored by <user>. Leading @ is stripped. Equivalent to appending `from:<user>` to the query.' },
|
||
{ name: 'has', type: 'string', choices: HAS_CHOICES, help: 'Restrict to tweets that have media|images|videos|links|replies. Maps to X\'s `filter:<has>` operator.' },
|
||
{ name: 'exclude', type: 'string', choices: EXCLUDE_CHOICES, help: 'Exclude tweets matching <type>: replies|retweets|media|links. Maps to X\'s `-filter:<x>` operator (retweets → -filter:nativeretweets).' },
|
||
{ 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'],
|
||
func: async (page, kwargs) => {
|
||
const finalQuery = buildSearchQuery(kwargs.query, kwargs);
|
||
if (!finalQuery) {
|
||
throw new ArgumentError('twitter search query is empty', 'Provide a non-empty <query>, or use at least one of --from / --has / --exclude.');
|
||
}
|
||
if (!Number.isInteger(Number(kwargs.limit)) || Number(kwargs.limit) <= 0) {
|
||
throw new ArgumentError('twitter search --limit must be a positive integer', 'Example: opencli twitter search opencli --limit 15');
|
||
}
|
||
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)');
|
||
await page.goto('https://x.com/home', { waitUntil: 'load', settleMs: 1000 });
|
||
const operation = await resolveTwitterOperationMetadata(page, 'SearchTimeline', SEARCH_TIMELINE_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',
|
||
'Content-Type': 'application/json',
|
||
});
|
||
const product = resolveSearchProduct(kwargs);
|
||
const results = [];
|
||
const seen = new Set();
|
||
let cursor = null;
|
||
// Runaway guard only; --limit and cursor exhaustion control normal pagination.
|
||
for (let i = 0; i < MAX_PAGINATION_PAGES && results.length < kwargs.limit; i++) {
|
||
const fetchCount = Number(kwargs.limit) - results.length + 10;
|
||
const [requestUrl, requestPayload] = buildSearchTimelineRequest(operation, finalQuery, product, fetchCount, cursor);
|
||
const requestBody = JSON.stringify(requestPayload);
|
||
const data = normalizeTwitterGraphqlPayload(await page.evaluate(`async () => {
|
||
const options = {
|
||
method: 'POST',
|
||
headers: ${headers},
|
||
credentials: 'include',
|
||
};
|
||
options['body'] = ${JSON.stringify(requestBody)};
|
||
const r = await fetch(${JSON.stringify(requestUrl)}, {
|
||
...options,
|
||
});
|
||
return r.ok ? await r.json() : { error: r.status };
|
||
}`));
|
||
if (data?.error) {
|
||
if (results.length === 0) throw new CommandExecutionError(describeTwitterApiError('SearchTimeline', data.error));
|
||
break;
|
||
}
|
||
const { rows, nextCursor } = parseSearchTimeline(data, seen);
|
||
results.push(...rows);
|
||
if (!nextCursor || nextCursor === cursor) break;
|
||
cursor = nextCursor;
|
||
}
|
||
const trimmed = results.slice(0, kwargs.limit);
|
||
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
||
}
|
||
});
|
||
|
||
export const __test__ = {
|
||
buildSearchQuery,
|
||
resolveSearchFParam,
|
||
resolveSearchProduct,
|
||
buildSearchTimelineRequest,
|
||
parseSearchTimeline,
|
||
HAS_CHOICES,
|
||
EXCLUDE_CHOICES,
|
||
PRODUCT_CHOICES,
|
||
EXCLUDE_TO_OPERATOR,
|
||
PRODUCT_TO_F_PARAM,
|
||
FROM_USER_PATTERN,
|
||
};
|