Add search/mentions commands and fix cookie decryption
- Added `search <query>` command to search tweets - Added `mentions` command to find @clawdbot mentions - Fixed Chrome cookie decryption to extract hex values correctly - Added SearchTimeline query ID (may need updating as Twitter rotates these) Note: Search endpoints may return 404 if query IDs are outdated
This commit is contained in:
+104
@@ -150,6 +150,110 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Search command - find tweets
|
||||||
|
program
|
||||||
|
.command('search')
|
||||||
|
.description('Search for tweets')
|
||||||
|
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
|
||||||
|
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
|
||||||
|
const opts = program.opts();
|
||||||
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
|
authToken: opts.authToken,
|
||||||
|
ct0: opts.ct0,
|
||||||
|
chromeProfile: opts.chromeProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const warning of warnings) {
|
||||||
|
console.error(`⚠️ ${warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cookies.authToken || !cookies.ct0) {
|
||||||
|
console.error('❌ Missing required credentials');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies });
|
||||||
|
const count = parseInt(cmdOpts.count || '10', 10);
|
||||||
|
const result = await client.search(query, count);
|
||||||
|
|
||||||
|
if (result.success && result.tweets) {
|
||||||
|
if (cmdOpts.json) {
|
||||||
|
console.log(JSON.stringify(result.tweets, null, 2));
|
||||||
|
} else {
|
||||||
|
if (result.tweets.length === 0) {
|
||||||
|
console.log('No tweets found.');
|
||||||
|
} else {
|
||||||
|
for (const tweet of result.tweets) {
|
||||||
|
console.log(`\n@${tweet.author.username} (${tweet.author.name}):`);
|
||||||
|
console.log(tweet.text);
|
||||||
|
if (tweet.createdAt) {
|
||||||
|
console.log(`📅 ${tweet.createdAt}`);
|
||||||
|
}
|
||||||
|
console.log(`🔗 https://x.com/${tweet.author.username}/status/${tweet.id}`);
|
||||||
|
console.log('─'.repeat(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`❌ Search failed: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mentions command - shortcut to search for @username mentions
|
||||||
|
program
|
||||||
|
.command('mentions')
|
||||||
|
.description('Find tweets mentioning @clawdbot')
|
||||||
|
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
||||||
|
const opts = program.opts();
|
||||||
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
|
authToken: opts.authToken,
|
||||||
|
ct0: opts.ct0,
|
||||||
|
chromeProfile: opts.chromeProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const warning of warnings) {
|
||||||
|
console.error(`⚠️ ${warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cookies.authToken || !cookies.ct0) {
|
||||||
|
console.error('❌ Missing required credentials');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies });
|
||||||
|
const count = parseInt(cmdOpts.count || '10', 10);
|
||||||
|
const result = await client.search('@clawdbot', count);
|
||||||
|
|
||||||
|
if (result.success && result.tweets) {
|
||||||
|
if (cmdOpts.json) {
|
||||||
|
console.log(JSON.stringify(result.tweets, null, 2));
|
||||||
|
} else {
|
||||||
|
if (result.tweets.length === 0) {
|
||||||
|
console.log('No mentions found.');
|
||||||
|
} else {
|
||||||
|
console.log(`Found ${result.tweets.length} mentions:\n`);
|
||||||
|
for (const tweet of result.tweets) {
|
||||||
|
console.log(`@${tweet.author.username} (${tweet.author.name}):`);
|
||||||
|
console.log(tweet.text);
|
||||||
|
if (tweet.createdAt) {
|
||||||
|
console.log(`📅 ${tweet.createdAt}`);
|
||||||
|
}
|
||||||
|
console.log(`🔗 https://x.com/${tweet.author.username}/status/${tweet.id}`);
|
||||||
|
console.log('─'.repeat(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`❌ Failed to fetch mentions: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Check command - verify credentials
|
// Check command - verify credentials
|
||||||
program
|
program
|
||||||
.command('check')
|
.command('check')
|
||||||
|
|||||||
+9
-1
@@ -79,7 +79,15 @@ function decryptCookieValue(encryptedHex: string): string | null {
|
|||||||
let decrypted = decipher.update(encryptedData);
|
let decrypted = decipher.update(encryptedData);
|
||||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||||
|
|
||||||
return decrypted.toString('utf8');
|
// Chrome v10 cookies have key material prepended before the actual value
|
||||||
|
// Twitter cookies are hex strings, so extract the longest hex sequence
|
||||||
|
const decryptedStr = decrypted.toString('utf8');
|
||||||
|
const hexMatch = decryptedStr.match(/[a-f0-9]{32,}/i);
|
||||||
|
if (hexMatch) {
|
||||||
|
return hexMatch[0];
|
||||||
|
}
|
||||||
|
// Fallback: keep only printable ASCII characters
|
||||||
|
return decryptedStr.replace(/[^\x20-\x7E]/g, '');
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const QUERY_IDS = {
|
|||||||
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
||||||
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
||||||
TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ',
|
TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ',
|
||||||
|
SearchTimeline: 'Tp1sewRU1AsZpBWhqCZicQ',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TweetResult {
|
export interface TweetResult {
|
||||||
@@ -41,6 +42,12 @@ export interface GetTweetResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
success: boolean;
|
||||||
|
tweets?: TweetData[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TwitterClientOptions {
|
export interface TwitterClientOptions {
|
||||||
cookies: TwitterCookies;
|
cookies: TwitterCookies;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
@@ -480,4 +487,152 @@ export class TwitterClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for tweets matching a query
|
||||||
|
*/
|
||||||
|
async search(query: string, count = 20): Promise<SearchResult> {
|
||||||
|
const variables = {
|
||||||
|
rawQuery: query,
|
||||||
|
count,
|
||||||
|
querySource: 'typed_query',
|
||||||
|
product: 'Latest',
|
||||||
|
};
|
||||||
|
|
||||||
|
const features = {
|
||||||
|
rweb_tipjar_consumption_enabled: true,
|
||||||
|
responsive_web_graphql_exclude_directive_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,
|
||||||
|
communities_web_enable_tweet_community_results_fetch: true,
|
||||||
|
c9s_tweet_anatomy_moderator_badge_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,
|
||||||
|
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,
|
||||||
|
rweb_video_timestamps_enabled: true,
|
||||||
|
longform_notetweets_rich_text_read_enabled: true,
|
||||||
|
longform_notetweets_inline_media_enabled: true,
|
||||||
|
responsive_web_enhance_cards_enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
variables: JSON.stringify(variables),
|
||||||
|
features: JSON.stringify(features),
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${TWITTER_API_BASE}/${QUERY_IDS.SearchTimeline}/SearchTimeline?${params}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
data?: {
|
||||||
|
search_by_raw_query?: {
|
||||||
|
search_timeline?: {
|
||||||
|
timeline?: {
|
||||||
|
instructions?: Array<{
|
||||||
|
entries?: Array<{
|
||||||
|
content?: {
|
||||||
|
itemContent?: {
|
||||||
|
tweet_results?: {
|
||||||
|
result?: {
|
||||||
|
rest_id?: string;
|
||||||
|
legacy?: {
|
||||||
|
full_text?: string;
|
||||||
|
created_at?: string;
|
||||||
|
reply_count?: number;
|
||||||
|
retweet_count?: number;
|
||||||
|
favorite_count?: number;
|
||||||
|
in_reply_to_status_id_str?: string;
|
||||||
|
};
|
||||||
|
core?: {
|
||||||
|
user_results?: {
|
||||||
|
result?: {
|
||||||
|
legacy?: {
|
||||||
|
screen_name?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: data.errors.map((e) => e.message).join(', '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tweets: TweetData[] = [];
|
||||||
|
const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
|
||||||
|
|
||||||
|
for (const instruction of instructions) {
|
||||||
|
for (const entry of instruction.entries || []) {
|
||||||
|
const result = entry.content?.itemContent?.tweet_results?.result;
|
||||||
|
if (!result) continue;
|
||||||
|
|
||||||
|
const legacy = result.legacy;
|
||||||
|
const userLegacy = result.core?.user_results?.result?.legacy;
|
||||||
|
|
||||||
|
if (legacy?.full_text && userLegacy?.screen_name) {
|
||||||
|
tweets.push({
|
||||||
|
id: result.rest_id || '',
|
||||||
|
text: legacy.full_text,
|
||||||
|
author: {
|
||||||
|
username: userLegacy.screen_name,
|
||||||
|
name: userLegacy.name || userLegacy.screen_name,
|
||||||
|
},
|
||||||
|
createdAt: legacy.created_at,
|
||||||
|
replyCount: legacy.reply_count,
|
||||||
|
retweetCount: legacy.retweet_count,
|
||||||
|
likeCount: legacy.favorite_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
tweets,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user