fix(twitter): improve createTweet posting
This commit is contained in:
@@ -6,6 +6,7 @@ import type { TwitterCookies } from './cookies.js';
|
|||||||
import queryIds from './query-ids.json' with { type: 'json' };
|
import queryIds from './query-ids.json' with { type: 'json' };
|
||||||
|
|
||||||
const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
|
const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
|
||||||
|
const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
|
||||||
|
|
||||||
// Query IDs rotate frequently; the values in query-ids.json are refreshed by
|
// Query IDs rotate frequently; the values in query-ids.json are refreshed by
|
||||||
// scripts/update-query-ids.ts. The fallback values keep the client usable if
|
// scripts/update-query-ids.ts. The fallback values keep the client usable if
|
||||||
@@ -968,6 +969,8 @@ export class TwitterClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const features = {
|
const features = {
|
||||||
|
rweb_video_screen_enabled: true,
|
||||||
|
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||||
premium_content_api_read_enabled: false,
|
premium_content_api_read_enabled: false,
|
||||||
communities_web_enable_tweet_community_results_fetch: true,
|
communities_web_enable_tweet_community_results_fetch: true,
|
||||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||||
@@ -1024,6 +1027,8 @@ export class TwitterClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const features = {
|
const features = {
|
||||||
|
rweb_video_screen_enabled: true,
|
||||||
|
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||||
premium_content_api_read_enabled: false,
|
premium_content_api_read_enabled: false,
|
||||||
communities_web_enable_tweet_community_results_fetch: true,
|
communities_web_enable_tweet_community_results_fetch: true,
|
||||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||||
@@ -1065,21 +1070,44 @@ export class TwitterClient {
|
|||||||
variables: Record<string, unknown>,
|
variables: Record<string, unknown>,
|
||||||
features: Record<string, boolean>,
|
features: Record<string, boolean>,
|
||||||
): Promise<TweetResult> {
|
): Promise<TweetResult> {
|
||||||
const url = `${TWITTER_API_BASE}/${QUERY_IDS.CreateTweet}/CreateTweet`;
|
const queryId = QUERY_IDS.CreateTweet;
|
||||||
|
const urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`;
|
||||||
|
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({ variables, features, queryId });
|
||||||
variables,
|
|
||||||
features,
|
|
||||||
queryId: QUERY_IDS.CreateTweet,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.fetchWithTimeout(url, {
|
const response = await this.fetchWithTimeout(urlWithOperation, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Twitter increasingly prefers POST to /i/api/graphql with queryId in the payload.
|
||||||
|
// If the operation URL 404s, retry the generic endpoint.
|
||||||
|
if (response.status === 404) {
|
||||||
|
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!retry.ok) {
|
||||||
|
const text = await retry.text();
|
||||||
|
return { success: false, error: `HTTP ${retry.status}: ${text.slice(0, 200)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await retry.json()) as CreateTweetResponse;
|
||||||
|
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
|
||||||
|
if (tweetId) return { success: true, tweetId };
|
||||||
|
|
||||||
|
return { success: false, error: 'Tweet created but no ID returned' };
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -79,6 +79,44 @@ describe('TwitterClient', () => {
|
|||||||
|
|
||||||
const body = JSON.parse(options.body);
|
const body = JSON.parse(options.body);
|
||||||
expect(body.variables.tweet_text).toBe('Hello world!');
|
expect(body.variables.tweet_text).toBe('Hello world!');
|
||||||
|
expect(body.features.rweb_video_screen_enabled).toBe(true);
|
||||||
|
expect(body.features.creator_subscriptions_tweet_preview_api_enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries CreateTweet via /i/api/graphql when operation URL 404s', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
text: async () => '',
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
create_tweet: {
|
||||||
|
tweet_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: '1234567890',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.tweet('Hello world!');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweetId).toBe('1234567890');
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const [firstUrl] = mockFetch.mock.calls[0];
|
||||||
|
const [secondUrl] = mockFetch.mock.calls[1];
|
||||||
|
expect(String(firstUrl)).toContain('/CreateTweet');
|
||||||
|
expect(String(secondUrl)).toBe('https://x.com/i/api/graphql');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle API errors', async () => {
|
it('should handle API errors', async () => {
|
||||||
@@ -178,6 +216,8 @@ describe('TwitterClient', () => {
|
|||||||
const body = JSON.parse(options.body);
|
const body = JSON.parse(options.body);
|
||||||
expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890');
|
expect(body.variables.reply.in_reply_to_tweet_id).toBe('1234567890');
|
||||||
expect(body.variables.tweet_text).toBe('This is a reply');
|
expect(body.variables.tweet_text).toBe('This is a reply');
|
||||||
|
expect(body.features.rweb_video_screen_enabled).toBe(true);
|
||||||
|
expect(body.features.creator_subscriptions_tweet_preview_api_enabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user