feat: add home timeline command
Add `bird home` command to fetch the authenticated user's home timeline. - Supports "For You" feed (default) and "Following" feed (--latest) - Uses HomeTimeline and HomeLatestTimeline GraphQL operations - Includes count option (-n), JSON output, and pagination Usage: bird home # Get "For You" feed bird home --latest # Get "Following" (chronological) feed bird home -n 50 # Fetch 50 tweets
This commit is contained in:
committed by
Peter Steinberger
parent
05e54350c7
commit
ba7b00eb29
@@ -18,6 +18,7 @@ export {
|
||||
type TwitterClientOptions,
|
||||
type TwitterUser,
|
||||
} from './twitter-client.js';
|
||||
export type { HomeTimelineFetchOptions } from './twitter-client-home.js';
|
||||
export type { SearchFetchOptions } from './twitter-client-search.js';
|
||||
export type { TimelineFetchOptions } from './twitter-client-timelines.js';
|
||||
export type { TweetFetchOptions } from './twitter-client-tweet-detail.js';
|
||||
|
||||
@@ -67,6 +67,16 @@ export abstract class TwitterClientBase {
|
||||
return Array.from(new Set([primary, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ']));
|
||||
}
|
||||
|
||||
protected async getHomeTimelineQueryIds(): Promise<string[]> {
|
||||
const primary = await this.getQueryId('HomeTimeline');
|
||||
return Array.from(new Set([primary, 'edseUwk9sP5Phz__9TIRnA']));
|
||||
}
|
||||
|
||||
protected async getHomeLatestTimelineQueryIds(): Promise<string[]> {
|
||||
const primary = await this.getQueryId('HomeLatestTimeline');
|
||||
return Array.from(new Set([primary, 'iOEZpOdfekFsxSlPQCQtPg']));
|
||||
}
|
||||
|
||||
protected async fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
||||
if (!this.timeoutMs || this.timeoutMs <= 0) {
|
||||
return fetch(url, init);
|
||||
|
||||
@@ -30,6 +30,8 @@ export const FALLBACK_QUERY_IDS = {
|
||||
ListMemberships: 'BlEXXdARdSeL_0KyKHHvvg',
|
||||
ListLatestTweetsTimeline: '2TemLyqrMpTeAmysdbnVqw',
|
||||
ListByRestId: 'wXzyA5vM_aVkBL9G8Vp3kw',
|
||||
HomeTimeline: 'edseUwk9sP5Phz__9TIRnA',
|
||||
HomeLatestTimeline: 'iOEZpOdfekFsxSlPQCQtPg',
|
||||
} as const;
|
||||
|
||||
export type OperationName = keyof typeof FALLBACK_QUERY_IDS;
|
||||
|
||||
@@ -226,6 +226,12 @@ export function buildListsFeatures(): Record<string, boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
export function buildHomeTimelineFeatures(): Record<string, boolean> {
|
||||
return applyFeatureOverrides('homeTimeline', {
|
||||
...buildTimelineFeatures(),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFollowingFeatures(): Record<string, boolean> {
|
||||
return applyFeatureOverrides('following', {
|
||||
rweb_video_screen_enabled: true,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
|
||||
import { TWITTER_API_BASE } from './twitter-client-constants.js';
|
||||
import { buildHomeTimelineFeatures } from './twitter-client-features.js';
|
||||
import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
|
||||
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
|
||||
|
||||
/** Options for home timeline fetch methods */
|
||||
export interface HomeTimelineFetchOptions {
|
||||
/** Include raw GraphQL response in `_raw` field */
|
||||
includeRaw?: boolean;
|
||||
}
|
||||
|
||||
export interface TwitterClientHomeMethods {
|
||||
getHomeTimeline(count?: number, options?: HomeTimelineFetchOptions): Promise<SearchResult>;
|
||||
getHomeLatestTimeline(count?: number, options?: HomeTimelineFetchOptions): Promise<SearchResult>;
|
||||
}
|
||||
|
||||
export function withHome<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||
Base: TBase,
|
||||
): Mixin<TBase, TwitterClientHomeMethods> {
|
||||
abstract class TwitterClientHome extends Base {
|
||||
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated user's "For You" home timeline
|
||||
*/
|
||||
async getHomeTimeline(count = 20, options: HomeTimelineFetchOptions = {}): Promise<SearchResult> {
|
||||
return this.fetchHomeTimeline('HomeTimeline', count, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated user's "Following" (latest/chronological) home timeline
|
||||
*/
|
||||
async getHomeLatestTimeline(count = 20, options: HomeTimelineFetchOptions = {}): Promise<SearchResult> {
|
||||
return this.fetchHomeTimeline('HomeLatestTimeline', count, options);
|
||||
}
|
||||
|
||||
private async fetchHomeTimeline(
|
||||
operation: 'HomeTimeline' | 'HomeLatestTimeline',
|
||||
count: number,
|
||||
options: HomeTimelineFetchOptions,
|
||||
): Promise<SearchResult> {
|
||||
const { includeRaw = false } = options;
|
||||
const features = buildHomeTimelineFeatures();
|
||||
const pageSize = 20;
|
||||
const seen = new Set<string>();
|
||||
const tweets: TweetData[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
const fetchPage = async (pageCount: number, pageCursor?: string) => {
|
||||
let lastError: string | undefined;
|
||||
let had404 = false;
|
||||
const queryIds =
|
||||
operation === 'HomeTimeline'
|
||||
? await this.getHomeTimelineQueryIds()
|
||||
: await this.getHomeLatestTimelineQueryIds();
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const variables = {
|
||||
count: pageCount,
|
||||
includePromotedContent: true,
|
||||
latestControlAvailable: true,
|
||||
requestContext: 'launch',
|
||||
withCommunity: true,
|
||||
...(pageCursor ? { cursor: pageCursor } : {}),
|
||||
};
|
||||
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
features: JSON.stringify(features),
|
||||
});
|
||||
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/${operation}?${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?: {
|
||||
home?: {
|
||||
home_timeline_urt?: {
|
||||
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?.home?.home_timeline_urt?.instructions;
|
||||
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
|
||||
const nextCursor = extractCursorFromInstructions(instructions);
|
||||
|
||||
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false as const, error: lastError ?? 'Unknown error fetching home timeline', had404 };
|
||||
};
|
||||
|
||||
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
|
||||
const firstAttempt = await fetchPage(pageCount, pageCursor);
|
||||
if (firstAttempt.success) {
|
||||
return firstAttempt;
|
||||
}
|
||||
if (firstAttempt.had404) {
|
||||
await this.refreshQueryIds();
|
||||
const secondAttempt = await fetchPage(pageCount, pageCursor);
|
||||
if (secondAttempt.success) {
|
||||
return secondAttempt;
|
||||
}
|
||||
return { success: false as const, error: secondAttempt.error };
|
||||
}
|
||||
return { success: false as const, error: firstAttempt.error };
|
||||
};
|
||||
|
||||
while (tweets.length < count) {
|
||||
const pageCount = Math.min(pageSize, count - tweets.length);
|
||||
const page = await fetchWithRefresh(pageCount, cursor);
|
||||
if (!page.success) {
|
||||
return { success: false, error: page.error };
|
||||
}
|
||||
|
||||
for (const tweet of page.tweets) {
|
||||
if (seen.has(tweet.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(tweet.id);
|
||||
tweets.push(tweet);
|
||||
if (tweets.length >= count) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!page.cursor || page.cursor === cursor || page.tweets.length === 0) {
|
||||
break;
|
||||
}
|
||||
cursor = page.cursor;
|
||||
}
|
||||
|
||||
return { success: true, tweets };
|
||||
}
|
||||
}
|
||||
|
||||
return TwitterClientHome;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AbstractConstructor } from './twitter-client-base.js';
|
||||
import { TwitterClientBase } from './twitter-client-base.js';
|
||||
import { type TwitterClientBookmarkMethods, withBookmarks } from './twitter-client-bookmarks.js';
|
||||
import { type TwitterClientHomeMethods, withHome } from './twitter-client-home.js';
|
||||
import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js';
|
||||
import { type TwitterClientMediaMethods, withMedia } from './twitter-client-media.js';
|
||||
import { type TwitterClientPostingMethods, withPosting } from './twitter-client-posting.js';
|
||||
@@ -11,6 +12,7 @@ import { type TwitterClientUserMethods, withUsers } from './twitter-client-users
|
||||
|
||||
type TwitterClientInstance = TwitterClientBase &
|
||||
TwitterClientBookmarkMethods &
|
||||
TwitterClientHomeMethods &
|
||||
TwitterClientListMethods &
|
||||
TwitterClientMediaMethods &
|
||||
TwitterClientPostingMethods &
|
||||
@@ -20,7 +22,9 @@ type TwitterClientInstance = TwitterClientBase &
|
||||
TwitterClientUserMethods;
|
||||
|
||||
const MixedTwitterClient = withUsers(
|
||||
withLists(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))),
|
||||
withLists(
|
||||
withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))),
|
||||
),
|
||||
) as AbstractConstructor<TwitterClientInstance>;
|
||||
|
||||
export class TwitterClient extends MixedTwitterClient {}
|
||||
|
||||
Reference in New Issue
Block a user