chore: merge origin/main
This commit is contained in:
+82
-11
@@ -17,6 +17,7 @@ import JSON5 from 'json5';
|
||||
import kleur from 'kleur';
|
||||
import { resolveCliInvocation } from './lib/cli-args.js';
|
||||
import { type CookieSource, resolveCredentials } from './lib/cookies.js';
|
||||
import { extractBookmarkFolderId } from './lib/extract-bookmark-folder-id.js';
|
||||
import { extractTweetId } from './lib/extract-tweet-id.js';
|
||||
import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js';
|
||||
import {
|
||||
@@ -108,6 +109,7 @@ type BirdConfig = {
|
||||
firefoxProfile?: string;
|
||||
cookieSource?: CookieSource | CookieSource[];
|
||||
timeoutMs?: number;
|
||||
quoteDepth?: number;
|
||||
};
|
||||
|
||||
function readConfigFile(path: string): Partial<BirdConfig> {
|
||||
@@ -152,6 +154,7 @@ const KNOWN_COMMANDS = new Set([
|
||||
'bookmarks',
|
||||
'following',
|
||||
'followers',
|
||||
'likes',
|
||||
'help',
|
||||
'whoami',
|
||||
'check',
|
||||
@@ -193,6 +196,7 @@ program
|
||||
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, [])
|
||||
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
|
||||
.option('--timeout <ms>', 'Request timeout in milliseconds')
|
||||
.option('--quote-depth <depth>', 'Max quoted tweet depth (default: 1; 0 disables)')
|
||||
.option('--plain', 'Plain output (stable, no emoji, no color)')
|
||||
.option('--no-emoji', 'Disable emoji output')
|
||||
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
|
||||
@@ -241,6 +245,23 @@ function resolveTimeoutFromOptions(options: { timeout?: string | number }): numb
|
||||
return resolveTimeoutMs(options.timeout, config.timeoutMs, process.env.BIRD_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function resolveQuoteDepth(...values: Array<string | number | undefined | null>): number | undefined {
|
||||
for (const value of values) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined {
|
||||
return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, process.env.BIRD_QUOTE_DEPTH);
|
||||
}
|
||||
|
||||
function detectMime(path: string): string | null {
|
||||
const ext = path.toLowerCase();
|
||||
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
|
||||
@@ -404,6 +425,7 @@ program
|
||||
.action(async (text: string) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||
let media: MediaSpec[] = [];
|
||||
try {
|
||||
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
||||
@@ -427,7 +449,7 @@ program
|
||||
console.error(`${l('source')}${cookies.source}`);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
let mediaIds: string[] | undefined;
|
||||
if (media.length > 0) {
|
||||
const uploaded: string[] = [];
|
||||
@@ -462,6 +484,7 @@ program
|
||||
.action(async (tweetIdOrUrl: string, text: string) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||
let media: MediaSpec[] = [];
|
||||
try {
|
||||
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
||||
@@ -488,7 +511,7 @@ program
|
||||
|
||||
console.error(`${p('info')}Replying to tweet: ${tweetId}`);
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
let mediaIds: string[] | undefined;
|
||||
if (media.length > 0) {
|
||||
const uploaded: string[] = [];
|
||||
@@ -523,6 +546,7 @@ program
|
||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||
|
||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||
|
||||
@@ -537,7 +561,7 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const result = await client.getTweet(tweetId);
|
||||
|
||||
if (result.success && result.tweet) {
|
||||
@@ -566,6 +590,7 @@ program
|
||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||
|
||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||
@@ -579,7 +604,7 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const result = await client.getReplies(tweetId);
|
||||
|
||||
if (result.success && result.tweets) {
|
||||
@@ -599,6 +624,7 @@ program
|
||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||
|
||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||
@@ -612,7 +638,7 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const result = await client.getThread(tweetId);
|
||||
|
||||
if (result.success && result.tweets) {
|
||||
@@ -633,6 +659,7 @@ program
|
||||
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
||||
|
||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||
@@ -646,7 +673,7 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const result = await client.search(query, count);
|
||||
|
||||
if (result.success && result.tweets) {
|
||||
@@ -667,6 +694,7 @@ program
|
||||
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
||||
|
||||
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
|
||||
@@ -688,7 +716,7 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
|
||||
if (!query) {
|
||||
const who = await client.getCurrentUser();
|
||||
@@ -718,8 +746,9 @@ program
|
||||
.command('bookmarks')
|
||||
.description('Get your bookmarked tweets')
|
||||
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
|
||||
.option('--folder-id <id>', 'Bookmark folder (collection) id')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
||||
.action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
||||
@@ -736,10 +765,18 @@ program
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const result = await client.getBookmarks(count);
|
||||
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
|
||||
if (cmdOpts.folderId && !folderId) {
|
||||
console.error(`${p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const result = folderId
|
||||
? await client.getBookmarkFolderTimeline(folderId, count)
|
||||
: await client.getBookmarks(count);
|
||||
|
||||
if (result.success && result.tweets) {
|
||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No bookmarks found.' });
|
||||
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
|
||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
|
||||
} else {
|
||||
console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`);
|
||||
process.exit(1);
|
||||
@@ -872,6 +909,39 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Likes command - get user's liked tweets
|
||||
program
|
||||
.command('likes')
|
||||
.description('Get your liked tweets')
|
||||
.option('-n, --count <number>', 'Number of likes to fetch', '20')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
||||
|
||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.error(`${p('warn')}${warning}`);
|
||||
}
|
||||
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error(`${p('err')}Missing required credentials`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const result = await client.getLikes(count);
|
||||
|
||||
if (result.success && result.tweets) {
|
||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
|
||||
} else {
|
||||
console.error(`${p('err')}Failed to fetch likes: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
// Whoami command - show the logged-in account
|
||||
program
|
||||
.command('whoami')
|
||||
@@ -879,6 +949,7 @@ program
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const quoteDepth = resolveQuoteDepthFromOptions(opts);
|
||||
|
||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||
|
||||
@@ -895,7 +966,7 @@ program
|
||||
console.error(`${l('source')}${cookies.source}`);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const result = await client.getCurrentUser();
|
||||
|
||||
const credentialSource = cookies.source ?? 'env/auto-detected cookies';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Extract bookmark folder (collection) ID from an X/Twitter bookmarks URL.
|
||||
*/
|
||||
const BOOKMARK_FOLDER_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/bookmarks\/(\d+)/i;
|
||||
const BOOKMARK_FOLDER_ID_REGEX = /^\d{5,}$/;
|
||||
|
||||
export function extractBookmarkFolderId(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const urlMatch = BOOKMARK_FOLDER_URL_REGEX.exec(trimmed);
|
||||
if (urlMatch) {
|
||||
return urlMatch[1];
|
||||
}
|
||||
if (BOOKMARK_FOLDER_ID_REGEX.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
||||
"TweetDetail": "97JF30KziU00483E_8elBA",
|
||||
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
|
||||
"Likes": "JR2gceKucIKcVNB_9JkhsA",
|
||||
"Following": "BEkNpEt5pNETESoqMsTEGA",
|
||||
"Followers": "kuFUYP9eV1FPoEy4N-pi7w"
|
||||
}
|
||||
|
||||
+280
-3
@@ -30,6 +30,8 @@ const FALLBACK_QUERY_IDS = {
|
||||
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
|
||||
Following: 'BEkNpEt5pNETESoqMsTEGA',
|
||||
Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
|
||||
Likes: 'JR2gceKucIKcVNB_9JkhsA',
|
||||
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
|
||||
} as const;
|
||||
|
||||
type OperationName = keyof typeof FALLBACK_QUERY_IDS;
|
||||
@@ -41,7 +43,18 @@ const QUERY_IDS: Record<OperationName, string> = {
|
||||
|
||||
const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array<OperationName>;
|
||||
|
||||
function normalizeQuoteDepth(value?: number): number {
|
||||
if (value === undefined || value === null) {
|
||||
return 1;
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
type GraphqlTweetResult = {
|
||||
__typename?: string;
|
||||
rest_id?: string;
|
||||
legacy?: {
|
||||
full_text?: string;
|
||||
@@ -186,6 +199,10 @@ type GraphqlTweetResult = {
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
tweet?: GraphqlTweetResult;
|
||||
quoted_status_result?: {
|
||||
result?: GraphqlTweetResult;
|
||||
};
|
||||
};
|
||||
|
||||
export type TweetResult =
|
||||
@@ -218,6 +235,8 @@ export interface TweetData {
|
||||
likeCount?: number;
|
||||
conversationId?: string;
|
||||
inReplyToStatusId?: string;
|
||||
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
|
||||
quotedTweet?: TweetData;
|
||||
}
|
||||
|
||||
export interface GetTweetResult {
|
||||
@@ -264,6 +283,8 @@ export interface TwitterClientOptions {
|
||||
cookies: TwitterCookies;
|
||||
userAgent?: string;
|
||||
timeoutMs?: number;
|
||||
// Max depth for quoted tweets (0 disables). Defaults to 1.
|
||||
quoteDepth?: number;
|
||||
}
|
||||
|
||||
interface CreateTweetResponse {
|
||||
@@ -288,6 +309,7 @@ export class TwitterClient {
|
||||
private cookieHeader: string;
|
||||
private userAgent: string;
|
||||
private timeoutMs?: number;
|
||||
private quoteDepth: number;
|
||||
private clientUuid: string;
|
||||
private clientDeviceId: string;
|
||||
private clientUserId?: string;
|
||||
@@ -303,6 +325,7 @@ export class TwitterClient {
|
||||
options.userAgent ||
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
this.timeoutMs = options.timeoutMs;
|
||||
this.quoteDepth = normalizeQuoteDepth(options.quoteDepth);
|
||||
this.clientUuid = randomUUID();
|
||||
this.clientDeviceId = randomUUID();
|
||||
}
|
||||
@@ -729,7 +752,17 @@ export class TwitterClient {
|
||||
);
|
||||
}
|
||||
|
||||
private mapTweetResult(result: GraphqlTweetResult | undefined): TweetData | undefined {
|
||||
private unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined {
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
if (result.tweet) {
|
||||
return result.tweet;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth = this.quoteDepth): TweetData | undefined {
|
||||
const userResult = result?.core?.user_results?.result;
|
||||
const userLegacy = userResult?.legacy;
|
||||
const userCore = userResult?.core;
|
||||
@@ -745,6 +778,14 @@ export class TwitterClient {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let quotedTweet: TweetData | undefined;
|
||||
if (quoteDepth > 0) {
|
||||
const quotedResult = this.unwrapTweetResult(result.quoted_status_result?.result);
|
||||
if (quotedResult) {
|
||||
quotedTweet = this.mapTweetResult(quotedResult, quoteDepth - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: result.rest_id,
|
||||
text,
|
||||
@@ -759,6 +800,7 @@ export class TwitterClient {
|
||||
name: name || username,
|
||||
},
|
||||
authorId: userId,
|
||||
quotedTweet,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1890,10 +1932,10 @@ export class TwitterClient {
|
||||
return { success: true, tweets: thread };
|
||||
}
|
||||
|
||||
private buildBookmarksFeatures(): Record<string, boolean> {
|
||||
// Shared timeline features for likes/bookmarks-style timelines.
|
||||
private buildTimelineFeatures(): Record<string, boolean> {
|
||||
return {
|
||||
...this.buildSearchFeatures(),
|
||||
graphql_timeline_v2_bookmark_timeline: true,
|
||||
blue_business_profile_image_shape_enabled: true,
|
||||
responsive_web_text_conversations_enabled: false,
|
||||
tweetypie_unmention_optimization_enabled: true,
|
||||
@@ -1905,11 +1947,27 @@ export class TwitterClient {
|
||||
};
|
||||
}
|
||||
|
||||
private buildBookmarksFeatures(): Record<string, boolean> {
|
||||
return {
|
||||
...this.buildTimelineFeatures(),
|
||||
graphql_timeline_v2_bookmark_timeline: true,
|
||||
};
|
||||
}
|
||||
|
||||
private buildLikesFeatures(): Record<string, boolean> {
|
||||
return this.buildTimelineFeatures();
|
||||
}
|
||||
|
||||
private async getBookmarksQueryIds(): Promise<string[]> {
|
||||
const primary = await this.getQueryId('Bookmarks');
|
||||
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
|
||||
}
|
||||
|
||||
private async getBookmarkFolderQueryIds(): Promise<string[]> {
|
||||
const primary = await this.getQueryId('BookmarkFolderTimeline');
|
||||
return Array.from(new Set([primary, 'KJIQpsvxrTfRIlbaRIySHQ']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated user's bookmarks
|
||||
*/
|
||||
@@ -2309,6 +2367,225 @@ export class TwitterClient {
|
||||
return { success: false, error: secondAttempt.error };
|
||||
}
|
||||
|
||||
return { success: false, error: firstAttempt.error };
|
||||
}
|
||||
private async getLikesQueryIds(): Promise<string[]> {
|
||||
const primary = await this.getQueryId('Likes');
|
||||
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated user's liked tweets
|
||||
*/
|
||||
async getLikes(count = 20): Promise<SearchResult> {
|
||||
const userResult = await this.getCurrentUser();
|
||||
if (!userResult.success || !userResult.user) {
|
||||
return { success: false, error: userResult.error ?? 'Could not determine current user' };
|
||||
}
|
||||
|
||||
const variables = {
|
||||
userId: userResult.user.id,
|
||||
count,
|
||||
includePromotedContent: false,
|
||||
withClientEventToken: false,
|
||||
withBirdwatchNotes: false,
|
||||
withVoice: true,
|
||||
};
|
||||
|
||||
const features = this.buildLikesFeatures();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
features: JSON.stringify(features),
|
||||
});
|
||||
|
||||
const tryOnce = async () => {
|
||||
let lastError: string | undefined;
|
||||
let had404 = false;
|
||||
const queryIds = await this.getLikesQueryIds();
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
had404 = true;
|
||||
lastError = `HTTP ${response.status}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
data?: {
|
||||
user?: {
|
||||
result?: {
|
||||
timeline?: {
|
||||
timeline?: {
|
||||
instructions?: Array<{
|
||||
entries?: Array<{
|
||||
content?: {
|
||||
itemContent?: {
|
||||
tweet_results?: {
|
||||
result?: GraphqlTweetResult;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
|
||||
}
|
||||
|
||||
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
|
||||
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||
|
||||
return { success: true as const, tweets, had404 };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false as const, error: lastError ?? 'Unknown error fetching likes', had404 };
|
||||
};
|
||||
|
||||
const firstAttempt = await tryOnce();
|
||||
if (firstAttempt.success) {
|
||||
return { success: true, tweets: firstAttempt.tweets };
|
||||
}
|
||||
|
||||
if (firstAttempt.had404) {
|
||||
await this.refreshQueryIds();
|
||||
const secondAttempt = await tryOnce();
|
||||
if (secondAttempt.success) {
|
||||
return { success: true, tweets: secondAttempt.tweets };
|
||||
}
|
||||
return { success: false, error: secondAttempt.error };
|
||||
}
|
||||
|
||||
return { success: false, error: firstAttempt.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated user's bookmark folder timeline
|
||||
*/
|
||||
async getBookmarkFolderTimeline(folderId: string, count = 20): Promise<SearchResult> {
|
||||
const variablesWithCount = {
|
||||
bookmark_collection_id: folderId,
|
||||
includePromotedContent: true,
|
||||
count,
|
||||
};
|
||||
|
||||
const variablesWithoutCount = {
|
||||
bookmark_collection_id: folderId,
|
||||
includePromotedContent: true,
|
||||
};
|
||||
|
||||
const features = this.buildBookmarksFeatures();
|
||||
|
||||
const tryOnce = async (variables: Record<string, unknown>) => {
|
||||
let lastError: string | undefined;
|
||||
let had404 = false;
|
||||
const queryIds = await this.getBookmarkFolderQueryIds();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
features: JSON.stringify(features),
|
||||
});
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
had404 = true;
|
||||
lastError = `HTTP ${response.status}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
data?: {
|
||||
bookmark_collection_timeline?: {
|
||||
timeline?: {
|
||||
instructions?: Array<{
|
||||
entries?: Array<{
|
||||
content?: {
|
||||
itemContent?: {
|
||||
tweet_results?: {
|
||||
result?: GraphqlTweetResult;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
|
||||
}
|
||||
|
||||
const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions;
|
||||
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||
|
||||
return { success: true as const, tweets, had404 };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 };
|
||||
};
|
||||
|
||||
let firstAttempt = await tryOnce(variablesWithCount);
|
||||
if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) {
|
||||
firstAttempt = await tryOnce(variablesWithoutCount);
|
||||
}
|
||||
if (firstAttempt.success) {
|
||||
return { success: true, tweets: firstAttempt.tweets };
|
||||
}
|
||||
|
||||
if (firstAttempt.had404) {
|
||||
await this.refreshQueryIds();
|
||||
let secondAttempt = await tryOnce(variablesWithCount);
|
||||
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
|
||||
secondAttempt = await tryOnce(variablesWithoutCount);
|
||||
}
|
||||
if (secondAttempt.success) {
|
||||
return { success: true, tweets: secondAttempt.tweets };
|
||||
}
|
||||
return { success: false, error: secondAttempt.error };
|
||||
}
|
||||
|
||||
return { success: false, error: firstAttempt.error };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user