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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Rettiwt-API can be used with or without logging in to Twitter. As such, the two
- User Timeline
- User Unfollow
- User Profile Update
- Space Search

By default, Rettiwt-API uses 'guest' authentication. If however, access to the full set of resources is required, 'user' authentication can be used. This is done by using the cookies associated with your Twitter/X account, and encoding them into an `API_KEY` for convenience. The said `API_KEY` can be obtained by using a browser extension, as follows:

Expand Down Expand Up @@ -298,6 +299,26 @@ Where,
- `<username>` is the username associated with the Twitter account.
- `<password>` is the password to the Twitter account.

### 5. Searching spaces

`space.search(...)` returns an array of `Space` objects (`Space[]`).

```ts
import { Rettiwt } from 'rettiwt-api';

const rettiwt = new Rettiwt({ apiKey: API_KEY });

// Automatically appends `filter:spaces` if omitted
rettiwt.space
.search('from:tbvxyz lang:zxx', 20)
.then((spaces) => {
console.log(spaces); // Space[]
})
.catch((error) => {
console.log(error);
});
```

## Using a custom error handler

Out of the box, `Rettiwt`'s error handling is bare-minimum, only able to parse basic error messages. For advanced scenarios, where full error response might be required, in order to diagnose error reason, it's recommended to use a custom error handler, by implementing the `IErrorHandler` interface, as follows:
Expand Down Expand Up @@ -494,6 +515,11 @@ So far, the following operations are supported:
- [Removing a member from a given Twitter list](https://rishikant181.github.io/Rettiwt-API/classes/ListService.html#removeMember)
- [Getting the list of tweets from a given Twitter list](https://rishikant181.github.io/Rettiwt-API/classes/ListService.html#tweets)

### Spaces

- [Getting the details of a given space](https://rishikant181.github.io/Rettiwt-API/classes/SpaceService.html#details)
- [Searching for spaces (returns `Space[]`)](https://rishikant181.github.io/Rettiwt-API/classes/SpaceService.html#search)

### Tweets

- [Bookmarking a tweet](https://rishikant181.github.io/Rettiwt-API/classes/TweetService.html#bookmark)
Expand Down Expand Up @@ -557,6 +583,12 @@ Help for the CLI can be obtained from the CLI itself:
- For help regarding the available commands, use the command `rettiwt help`
- For help regarding a specific command, use the command `rettiwt help <command_name>`

Examples:

- `rettiwt space details <space_id>`
- `rettiwt space search "<query>" [count] [cursor]`
- `rettiwt space search "<query>" [count] [cursor] --latest`

## API Reference

The complete API reference can be found at [this](https://rishikant181.github.io/Rettiwt-API/modules) page.
Expand Down
3 changes: 3 additions & 0 deletions src/collections/Extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { IListMemberRemoveResponse } from '../types/raw/list/RemoveMember';
import { IListTweetsResponse } from '../types/raw/list/Tweets';
import { IMediaInitializeUploadResponse } from '../types/raw/media/InitalizeUpload';
import { IAudioSpaceByIdResponse } from '../types/raw/space/AudioSpaceById';
import { ISpaceSearchResponse } from '../types/raw/space/Search';
import { ITweetBookmarkResponse } from '../types/raw/tweet/Bookmark';
import { ITweetDetailsResponse } from '../types/raw/tweet/Details';
import { ITweetDetailsBulkResponse } from '../types/raw/tweet/DetailsBulk';
Expand Down Expand Up @@ -90,6 +91,8 @@ export const Extractors = {
DM_INBOX_TIMELINE: (response: IInboxTimelineResponse): Inbox => new Inbox(response),

SPACE_DETAILS: (response: IAudioSpaceByIdResponse): Space | undefined => Space.single(response),
SPACE_SEARCH: (response: ISpaceSearchResponse): CursoredData<Tweet> =>
new CursoredData<Tweet>(response, BaseType.TWEET),

TWEET_BOOKMARK: (response: ITweetBookmarkResponse): boolean => response?.data?.tweet_bookmark_put === 'Done',
TWEET_DETAILS: (response: ITweetDetailsResponse, id: string): Tweet | undefined => Tweet.single(response, id),
Expand Down
1 change: 1 addition & 0 deletions src/collections/Groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const FetchResourcesGroup = [
ResourceType.DM_INBOX_INITIAL_STATE,
ResourceType.DM_INBOX_TIMELINE,
ResourceType.SPACE_DETAILS,
ResourceType.SPACE_SEARCH,
ResourceType.TWEET_DETAILS,
ResourceType.TWEET_DETAILS_ALT,
ResourceType.TWEET_DETAILS_BULK,
Expand Down
1 change: 1 addition & 0 deletions src/collections/Requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const Requests: { [key in keyof typeof ResourceType]: (args: IFetchArgs |

SPACE_DETAILS: (args: IFetchArgs) =>
SpaceRequests.details(args.id!, args.withReplays, args.withListeners, args.isMetatagsQuery),
SPACE_SEARCH: (args: IFetchArgs) => SpaceRequests.search(args.query!, args.count, args.cursor, args.top),

TWEET_BOOKMARK: (args: IPostArgs) => TweetRequests.bookmark(args.id!),
TWEET_DETAILS: (args: IFetchArgs) => TweetRequests.details(args.id!),
Expand Down
26 changes: 26 additions & 0 deletions src/commands/Space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ interface ISpaceDetailsOptions {
metatags?: boolean;
}

interface ISpaceSearchOptions {
latest?: boolean;
}

/**
* Creates a new 'space' command which uses the given Rettiwt instance.
*
Expand Down Expand Up @@ -40,6 +44,28 @@ function createSpaceCommand(rettiwt: Rettiwt): Command {
}
});

// Search
space
.command('search')
.description('Search for spaces')
.argument('<query>', 'The search query')
.argument('[count]', 'The number of results to fetch')
.argument('[cursor]', 'The cursor to the batch of results to fetch')
.option('--latest', 'Fetch latest results instead of top results')
.action(async (query: string, count?: string, cursor?: string, options?: ISpaceSearchOptions) => {
try {
const results = await rettiwt.space.search(
query,
count ? parseInt(count) : undefined,
cursor,
!options?.latest,
);
output(results);
} catch (error) {
output(error);
}
});

return space;
}

Expand Down
1 change: 1 addition & 0 deletions src/enums/Resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export enum ResourceType {

// SPACE
SPACE_DETAILS = 'SPACE_DETAILS',
SPACE_SEARCH = 'SPACE_SEARCH',

// TWEET
TWEET_BOOKMARK = 'TWEET_BOOKMARK',
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export { IMediaFinalizeUploadResponse as IRawMediaFinalizeUploadResponse } from
export { IMediaInitializeUploadResponse as IRawMediaInitializeUploadResponse } from './types/raw/media/InitalizeUpload';
export { IMediaLiveVideoStreamResponse as IRawMediaLiveVideoStreamResponse } from './types/raw/media/LiveVideoStream';
export { IAudioSpaceByIdResponse as IRawSpaceDetailsResponse } from './types/raw/space/AudioSpaceById';
export { ISpaceSearchResponse as IRawSpaceSearchResponse } from './types/raw/space/Search';
export { ITweetDetailsResponse as IRawTweetDetailsResponse } from './types/raw/tweet/Details';
export { ITweetDetailsBulkResponse as IRawTweetDetailsBulkResponse } from './types/raw/tweet/DetailsBulk';
export { ITweetLikeResponse as IRawTweetLikeResponse } from './types/raw/tweet/Like';
Expand Down
4 changes: 4 additions & 0 deletions src/models/args/FetchArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,20 @@ export class FetchArgs implements IFetchArgs {
public isMetatagsQuery?: boolean;
public maxId?: string;
public metrics?: RawAnalyticsMetric[];
public query?: string;
public showVerifiedFollowers?: boolean;
public sortBy?: TweetRepliesSortType;
public toTime?: Date;
public top?: boolean;
public withListeners?: boolean;
public withReplays?: boolean;

/**
* @param args - Additional user-defined arguments for fetching the resource.
*/
public constructor(args: IFetchArgs) {
this.query = args.query;
this.top = args.top;
this.id = args.id;
this.ids = args.ids;
this.isMetatagsQuery = args.isMetatagsQuery;
Expand Down
82 changes: 82 additions & 0 deletions src/requests/Space.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { AxiosRequestConfig } from 'axios';

import { RawTweetSearchResultType } from '../enums/raw/Tweet';

/**
* Collection of requests related to spaces.
*
* @public
*/
export class SpaceRequests {
/**
* Normalize search query for spaces.
*
* @param query - The query to normalize.
* @returns The normalized query.
*/
private static _normalizeSearchQuery(query: string): string {
const normalized = query.trim();

if (normalized.includes('filter:spaces')) {
return normalized;
}

return `${normalized} filter:spaces`.trim();
}

/**
* @param id - The id of the space whose details are to be fetched.
* @param withReplays - Whether to include replay information.
Expand Down Expand Up @@ -73,4 +91,68 @@ export class SpaceRequests {
paramsSerializer: { encode: encodeURIComponent },
};
}

/**
* @param query - The search query. `filter:spaces` is appended if absent.
* @param count - The number of items to fetch.
* @param cursor - The cursor to the batch of results.
* @param top - Whether to fetch top results.
*/
public static search(query: string, count?: number, cursor?: string, top = true): AxiosRequestConfig {
return {
method: 'get',
url: 'https://x.com/i/api/graphql/f_A-Gyo204PRxixpkrchJg/SearchTimeline',
params: {
/* eslint-disable @typescript-eslint/naming-convention */
variables: JSON.stringify({
rawQuery: SpaceRequests._normalizeSearchQuery(query),
count: count,
cursor: cursor,
querySource: 'typed_query',
product: top ? RawTweetSearchResultType.TOP : RawTweetSearchResultType.LATEST,
withGrokTranslatedBio: false,
}),
features: JSON.stringify({
rweb_video_screen_enabled: false,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false,
rweb_tipjar_consumption_enabled: false,
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,
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,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
post_ctas_fetch_enabled: true,
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,
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,
}),
/* eslint-enable @typescript-eslint/naming-convention */
},
paramsSerializer: { encode: encodeURIComponent },
};
}
}
2 changes: 1 addition & 1 deletion src/services/public/FetcherService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export class FetcherService {
* Makes an HTTP request according to the given parameters.
*
* @param resource - The requested resource.
* @param config - The request configuration.
* @param args - The arguments required for the requested resource.
*
* @typeParam T - The type of the returned response data.
*
Expand Down
94 changes: 94 additions & 0 deletions src/services/public/SpaceService.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Extractors } from '../../collections/Extractors';
import { ResourceType } from '../../enums/Resource';
import { Space } from '../../models/data/Space';
import { Tweet } from '../../models/data/Tweet';
import { RettiwtConfig } from '../../models/RettiwtConfig';
import { ISpaceDetailsOptions } from '../../types/args/FetchArgs';
import { IAudioSpaceByIdResponse } from '../../types/raw/space/AudioSpaceById';
import { ISpaceSearchResponse } from '../../types/raw/space/Search';

import { FetcherService } from './FetcherService';

Expand All @@ -22,6 +24,40 @@ export class SpaceService extends FetcherService {
super(config);
}

/**
* Extract a space id from a space URL.
*
* @param url - The URL to inspect.
* @returns The extracted space id, if found.
*/
private static _extractSpaceIdFromUrl(url: string): string | undefined {
const match = url.match(/(?:https?:\/\/)?(?:www\.)?(?:x|twitter)\.com\/i\/spaces\/([a-zA-Z0-9]+)/i);

return match ? match[1] : undefined;
}

/**
* Extract unique space IDs from tweets.
*
* @param tweets - The list of tweets to inspect.
* @returns The list of unique space ids.
*/
private static _extractSpaceIds(tweets: Tweet[]): string[] {
const ids = new Set<string>();

for (const tweet of tweets) {
for (const url of tweet.entities.urls) {
const id = SpaceService._extractSpaceIdFromUrl(url);

if (id) {
ids.add(id);
}
}
}

return Array.from(ids);
}

/**
* Get the details of a space.
*
Expand Down Expand Up @@ -62,4 +98,62 @@ export class SpaceService extends FetcherService {

return data;
}

/**
* Search for spaces using a raw query.
*
* @param query - The raw query. `filter:spaces` is appended if omitted.
* @param count - The number of results to fetch.
* @param cursor - The cursor to the batch of results to fetch.
* @param top - Whether to fetch top results. Defaults to `true`.
*
* @returns The list of spaces matching the query.
*
* @example
*
* ```ts
* import { Rettiwt } from 'rettiwt-api';
*
* const rettiwt = new Rettiwt({ apiKey: API_KEY });
*
* rettiwt.space.search('from:tbvxyz lang:zxx', 20)
* .then(res => {
* console.log(res);
* })
* .catch(err => {
* console.log(err);
* });
* ```
*/
public async search(query: string, count?: number, cursor?: string, top = true): Promise<Space[]> {
const resource = ResourceType.SPACE_SEARCH;

// Fetching raw search results
const response = await this.request<ISpaceSearchResponse>(resource, {
query: query,
count: count,
cursor: cursor,
top: top,
});

// Extracting space ids from search tweets
const searchResults = Extractors[resource](response);
const spaceIds = SpaceService._extractSpaceIds(searchResults.list);

// Fetching details for each space id
const spaces = await Promise.all(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not be nesting one API call within another, in this case, space.details within space.search. Let's say a search result returns 20 spaces, this implementation will results in a total of 21 API calls (1 for initial search result and 20 for each space result). Almost all functions of Rettiwt represent 1:1 API calls i.e, one function call results in one API call. This is done so that we leave as much freedom to the user as possible, in regards to rate limiting.

I'm curious, what does the search result for spaces look like? Is it truncated that we have to fetch the details again? If that's the case, we should return the truncated results (define additional type if necessary) from the space.search function, and leave it up to the user whether to fetch full details via space.details or not.

spaceIds.map(async (id) => {
try {
return await this.details(id, {
withReplays: false,
withListeners: false,
});
} catch {
return undefined;
}
}),
);

return spaces.filter((space): space is Space => space != undefined);
}
}
Loading